chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
+2
View File
@@ -0,0 +1,2 @@
.botpress
src/gen
+53
View File
@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
# Build this Dockerfile from the monorepo root:
# docker build -f integrations/chat/Dockerfile -t botpress-chat .
# Run with this command:
# docker run -it -e SECRET_SIGNAL_URL=https://chat.botpress.dev botpress-chat
ARG NODE_VERSION=22.17.0
ARG PNPM_VERSION=10.29.3
FROM node:${NODE_VERSION}-bullseye-slim AS base
WORKDIR /usr/app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install pnpm and build dependencies
RUN npm install -g pnpm@${PNPM_VERSION} && echo "PNPM version: ${PNPM_VERSION}"
# copy package dependencies
COPY ./packages /usr/app/packages
COPY ./tsconfig.json /usr/app/tsconfig.json
COPY integrations/chat ./integrations/chat
COPY patches/source-map-js@1.2.1.patch patches/source-map-js@1.2.1.patch
# install
RUN pnpm install --frozen-lockfile
# generate
RUN pnpm --filter @botpress/client run generate
RUN pnpm --filter @botpress/chat run generate
RUN pnpm --filter @botpresshub/chat run generate
# build
RUN pnpm --filter @bpinternal/zui run build
RUN pnpm --filter @botpress/chat run build
RUN pnpm --filter @botpress/client run build
RUN pnpm --filter @botpress/sdk run build
RUN pnpm --filter @botpress/cognitive run build
RUN pnpm --filter @botpress/cli run bundle
RUN pnpm --filter @botpresshub/chat run build
FROM node:${NODE_VERSION}-bullseye-slim AS deploy
COPY --from=base /usr/app/integrations/chat/.botpress/dist/index.cjs ./index.cjs
COPY integrations/chat/server.js ./server.js
# set port env and expose it
ENV PORT=8081
EXPOSE ${PORT}
ENTRYPOINT ["node", "server.js"]
@@ -0,0 +1,33 @@
import * as sdk from '@botpress/sdk'
import { z } from '@botpress/sdk'
const metadata = z.record(z.any()).optional()
const text = { schema: sdk.messages.defaults.text.schema.extend({ metadata }) }
const image = { schema: sdk.messages.defaults.image.schema.extend({ metadata }) }
const audio = { schema: sdk.messages.defaults.audio.schema.extend({ metadata }) }
const video = { schema: sdk.messages.defaults.video.schema.extend({ metadata }) }
const file = { schema: sdk.messages.defaults.file.schema.extend({ metadata }) }
const location = { schema: sdk.messages.defaults.location.schema.extend({ metadata }) }
const carousel = { schema: sdk.messages.defaults.carousel.schema.extend({ metadata }) }
const card = { schema: sdk.messages.defaults.card.schema.extend({ metadata }) }
const dropdown = { schema: sdk.messages.defaults.dropdown.schema.extend({ metadata }) }
const choice = { schema: sdk.messages.defaults.choice.schema.extend({ metadata }) }
const bloc = { schema: sdk.messages.markdownBloc.schema.extend({ metadata }) }
const markdown = { schema: sdk.messages.markdown.schema.extend({ metadata }) }
export const messages = {
text,
image,
audio,
video,
file,
location,
carousel,
card,
dropdown,
choice,
bloc,
markdown,
} satisfies sdk.ChannelDefinition['messages']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
The Chat integration allows you to chat with your bot using HTTP requests. Check out the [documentation](https://botpress.com/reference/introduction) for more information.
+8
View File
@@ -0,0 +1,8 @@
<svg width="241" height="241" viewBox="0 0 241 241" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="241" height="241" rx="34" fill="#3b82f6"/>
<path d="M102 112L130.5 95.5L127.5 86L98 103L102 112Z" fill="white"/>
<path d="M133 157.5L98.5 137.5L101 128.5L136 148.5L133 157.5Z" fill="white"/>
<path d="M172.014 179.812L155.014 189.672C152.532 191.112 149.468 191.112 146.986 189.672L129.986 179.812C127.519 178.381 126 175.744 126 172.892V152.608C126 149.756 127.519 147.119 129.986 145.688L146.986 135.828C149.468 134.388 152.532 134.388 155.014 135.828L172.014 145.688C174.481 147.119 176 149.756 176 152.608V172.892C176 175.744 174.481 178.381 172.014 179.812Z" fill="white"/>
<path d="M103 110.108V130.392C103 133.244 101.481 135.881 99.0137 137.312L98.5698 137.57L83.5 129L92 141.38L82.0137 147.172C79.5316 148.612 76.4684 148.612 73.9863 147.172L56.9863 137.312C54.5188 135.881 53 133.244 53 130.392V110.108C53 107.256 54.5188 104.619 56.9863 103.188L73.9863 93.328C76.4684 91.8883 79.5316 91.8883 82.0137 93.328L99.0137 103.188C101.481 104.619 103 107.256 103 110.108Z" fill="white"/>
<path d="M172.014 95.312L155.014 105.172C152.532 106.612 149.468 106.612 146.986 105.172L137.789 99.8376L147.5 85.5L130.425 95.5666L129.986 95.312C127.519 93.8809 126 91.2442 126 88.3918V68.1082C126 65.2558 127.519 62.6191 129.986 61.188L146.986 51.328C149.468 49.8883 152.532 49.8883 155.014 51.328L172.014 61.188C174.481 62.6191 176 65.2558 176 68.1082V88.3918C176 91.2442 174.481 93.8809 172.014 95.312Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+118
View File
@@ -0,0 +1,118 @@
import * as sdk from '@botpress/sdk'
import { z } from '@botpress/sdk'
import { messages } from './definitions/channels/messages'
import { apiVersion } from './src/gen/version'
export default new sdk.IntegrationDefinition({
name: 'chat',
title: 'Chat',
version: apiVersion,
readme: 'hub.md',
icon: 'icon.svg',
description: 'Chat integration for Botpress; Allows interacting with your bot using HTTP requests.',
configuration: {
schema: z.object({
encryptionKey: z
.string()
.optional()
.title('Encryption Key (HS256) - optional')
.describe(
'Only set this config if you plan on signing your user key yourself. Key used to sign and verify user keys; JWT tokens with HS256 algorithm.'
),
webhookUrl: z
.string()
.optional()
.title('Webhook URL - optional')
.describe(
'Only set this config if you want to listen on a webhook instead of the standard SSE stream. URL where all incoming and outgoing messages / events are sent to.'
),
webhookSecret: z
.string()
.optional()
.title('Webhook Secret - optional')
.describe('Secret forwarded to the webhook URL.'),
}),
},
events: {
custom: {
title: 'Custom Event',
description: 'Custom event sent from the chat client to the bot',
schema: z.object({
userId: z.string().title('User ID').describe('The ID of the user who sent the custom event'),
conversationId: z
.string()
.title('Conversation ID')
.describe('The ID of the conversation where the event was sent'),
payload: z.record(z.any()).title('Payload').describe('Custom data payload sent with the event'),
}),
},
},
actions: {
sendEvent: {
title: 'Send Custom Event',
description: 'Send a custom event from the bot to the chat client',
input: {
schema: z.object({
conversationId: z
.string()
.title('Conversation ID')
.describe('The ID of the conversation to send the event to'),
payload: z.record(z.any()).title('Payload').describe('Custom data payload to send with the event'),
}),
},
output: {
schema: z.object({}),
},
},
},
channels: {
channel: {
title: 'Chat Channel',
description: 'Web chat channel for communicating with users via HTTP requests',
conversation: {
tags: {
owner: { title: 'Conversation Owner', description: 'ID of the user who created the conversation' },
fid: { title: 'Foreign ID', description: 'Copy of the foreign ID of the conversation' },
},
},
messages,
message: {
tags: {
fid: { title: 'Foreign ID', description: 'This tag is of no use and only exists for historical reasons' },
},
},
},
},
user: {
tags: {
fid: { title: 'Foreign ID', description: 'Copy of the foreign ID of the user' },
profile: {
title: 'User Profile',
description: 'Custom profile data of the user encoded as a string',
},
},
},
secrets: {
SIGNAL_URL: {
description: 'URL of the signal side of the realtime service',
optional: false,
},
SIGNAL_SECRET: {
description:
'Secret used to authenticate with the signal service. If not provided, no authentication will be used',
optional: true,
},
AUTH_ENCRYPTION_KEY: {
description: 'Secret used to sign and verify JWT tokens',
optional: false,
},
FID_STORE_CONFIG: {
description: 'Base64 encoded JSON object containing the configuration for the foreign id store',
optional: true,
},
},
attributes: {
category: 'Communication & Channels',
repo: 'botpress',
},
})
+31
View File
@@ -0,0 +1,31 @@
import { api, signals, version } from '@botpress/chat-api'
import fs from 'fs'
import path from 'path'
const main = async (argv: string[]) => {
const outDir = argv[0]
if (!outDir) {
throw new Error('Missing output directory')
}
console.info('Generating OpenAPI server...')
await api.exportHandler(outDir)
const signalsDir = path.join(outDir, 'signals')
await signals.exportSchemas(signalsDir, { includeZodSchemas: false })
const versionDir = path.join(outDir, 'version.ts')
await fs.promises.writeFile(versionDir, `export const apiVersion = "${version}"`)
console.info('OpenAPI server generated')
}
void main(process.argv.slice(2))
.then(() => {
process.exit(0)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@botpresshub/chat",
"private": true,
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"generate": "ts-node -T ./openapi.ts ./src/gen",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.564.0",
"@botpress/sdk": "workspace:*",
"@bpinternal/pingrip": "0.1.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "1.30.0",
"@opentelemetry/exporter-trace-otlp-http": "0.54.2",
"@opentelemetry/instrumentation": "0.54.2",
"@opentelemetry/instrumentation-http": "0.54.2",
"@opentelemetry/resources": "1.27.0",
"@opentelemetry/sdk-trace-base": "1.27.0",
"@opentelemetry/sdk-trace-node": "1.27.0",
"ajv": "^8.12.0",
"axios": "1.2.5",
"chalk": "^4.1.2",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"prom-client": "^15.1.3",
"qs": "^6.11.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@botpress/chat-api": "workspace:*",
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/json-schema": "^7.0.12",
"@types/jsonwebtoken": "^9.0.3",
"@types/lodash": "^4.14.191",
"@types/qs": "^6.9.7",
"@types/uuid": "^9.0.1"
}
}
+9
View File
@@ -0,0 +1,9 @@
const server = require("./index.cjs");
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8081;
const metricsPort = process.env.METRICS_PORT ? parseInt(process.env.METRICS_PORT, 10) : 9090;
if (process.env.METRICS_ENABLED !== "false") {
server.default.startMetricsServer(metricsPort);
}
server.default.start(port);
+29
View File
@@ -0,0 +1,29 @@
import * as errors from './gen/errors'
import * as types from './types'
import * as bp from '.botpress'
export type ApiUtils = {
findParticipant: (req: types.ClientRequests['getParticipant']) => Promise<{
participant?: types.ClientResponses['getParticipant']['participant']
}>
findUser: (req: types.ClientRequests['getUser']) => Promise<{
user?: types.ClientResponses['getUser']['user']
}>
}
export const makeApiUtils = (client: bp.Client): ApiUtils => ({
findParticipant: async (req) =>
client.getParticipant(req).catch((thrown) => {
if (errors.isApiError(thrown) && (thrown.type === 'ResourceNotFound' || thrown.type === 'ReferenceNotFound')) {
return { participant: undefined }
}
throw thrown
}),
findUser: async (req) =>
client.getUser(req).catch((thrown) => {
if (errors.isApiError(thrown) && thrown.type === 'ResourceNotFound') {
return { user: undefined }
}
throw thrown
}),
})
+50
View File
@@ -0,0 +1,50 @@
import { createRouteTree } from '../gen/tree'
import { operations as ops } from './operations'
import * as types from './types'
export * from './types'
const pipe = <A, B, C>(head: types.MiddleWare<A, B>, tail: types.MiddleWare<B, C>): types.MiddleWare<A, C> => {
return async (props, input) => {
const middle = await head(props, input)
return tail(props, middle)
}
}
const authenticate = async <T extends { headers: { 'x-user-key': string } }>(
props: types.OperationProps,
req: T
): Promise<T & { auth: { userId: string } }> => {
const identity = req.headers['x-user-key']
const parsedKey = props.auth.parseKey(identity)
const userId = parsedKey.id
return { ...req, auth: { userId } }
}
export const operations: types.Operations = {
createUser: ops.createUser,
getUser: pipe(authenticate, ops.getUser),
getOrCreateUser: pipe(authenticate, ops.getOrCreateUser),
updateUser: pipe(authenticate, ops.updateUser),
deleteUser: pipe(authenticate, ops.deleteUser),
createConversation: pipe(authenticate, ops.createConversation),
getConversation: pipe(authenticate, ops.getConversation),
getOrCreateConversation: pipe(authenticate, ops.getOrCreateConversation),
deleteConversation: pipe(authenticate, ops.deleteConversation),
listConversations: pipe(authenticate, ops.listConversations),
listMessages: pipe(authenticate, ops.listMessages),
listenConversation: pipe(authenticate, ops.listenConversation),
addParticipant: pipe(authenticate, ops.addParticipant),
getParticipant: pipe(authenticate, ops.getParticipant),
removeParticipant: pipe(authenticate, ops.removeParticipant),
listParticipants: pipe(authenticate, ops.listParticipants),
createMessage: pipe(authenticate, ops.createMessage),
getMessage: pipe(authenticate, ops.getMessage),
deleteMessage: pipe(authenticate, ops.deleteMessage),
createEvent: pipe(authenticate, ops.createEvent),
getEvent: pipe(authenticate, ops.getEvent),
}
export const routes = createRouteTree(operations)
@@ -0,0 +1,66 @@
import * as chat from '../gen/models/Message.t'
import * as types from '../types'
import * as bp from '.botpress'
export type ChatMessage = {
payload: chat.Message['payload']
metadata: chat.Message['metadata']
}
export type BotpressMessage = types.ValueOf<{
[MessageType in keyof bp.channels.channel.Messages]: {
type: MessageType
payload: bp.channels.channel.Messages[MessageType]
}
}>
export const mapBotpressMessageToChat = (message: BotpressMessage): ChatMessage => {
const { metadata, ...messagePayload } = message.payload
if (message.type !== 'bloc') {
return {
metadata,
payload: {
type: message.type,
...messagePayload,
},
} as ChatMessage
}
const items = message.payload.items.map((item) => ({
type: item.type,
...item.payload,
}))
return {
metadata,
payload: {
type: 'bloc',
...messagePayload,
items,
},
} as ChatMessage
}
export const mapChatMessageToBotpress = (message: ChatMessage): BotpressMessage => {
const { payload, metadata } = message
if (payload.type !== 'bloc') {
const { type, ...payloadData } = payload
return {
type,
payload: { metadata, ...payloadData },
} as BotpressMessage
}
return {
type: payload.type,
payload: {
...payload,
metadata,
items: payload.items.map(({ type, ...payload }) => ({
type,
payload,
})),
},
} as BotpressMessage
}
@@ -0,0 +1,362 @@
import * as errors from '../../gen/errors'
import { validateFid } from '../../id-store'
import { setSpanAttributes, SPAN_ATTRS } from '../../tracing'
import * as types from '../types'
import * as fid from './fid'
import * as model from './model'
export const createConversation: types.AuthenticatedOperations['createConversation'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.createConversation(props, foreignReq)
const req = await fidHandler.mapRequest()
const {
auth: { userId },
} = req
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: userId })
const { conversation } = await props.client.createConversation({
channel: 'channel',
tags: {
owner: userId,
fid: req.body.id, // Readonly copy of the conversation's foreign ID; useful for users of the Runtime API
},
})
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversation.id })
await props.client.addParticipant({ id: conversation.id, userId })
return fidHandler.mapResponse({
body: {
conversation: model.mapConversation(conversation),
},
})
}
export const getConversation: types.AuthenticatedOperations['getConversation'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.getConversation(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: req.params.id, [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { conversation } = await props.client.getConversation({ id: req.params.id })
const { participant } = await props.apiUtils.findParticipant({
id: conversation.id,
userId: req.auth.userId,
})
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this message's conversation")
}
return fidHandler.mapResponse({
body: {
conversation: model.mapConversation(conversation),
},
})
}
export const getOrCreateConversation: types.AuthenticatedOperations['getOrCreateConversation'] = async (
props,
foreignReq
) => {
const {
body: { id: conversationFid },
} = foreignReq
const userId = await props.userIdStore.byFid.get(foreignReq.auth.userId)
const existingId = await props.convIdStore.byFid.find(conversationFid)
if (existingId) {
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: existingId, [SPAN_ATTRS.USER_ID]: userId })
const { conversation } = await props.client.getConversation({ id: existingId })
if (conversation.tags.owner !== userId) {
throw new errors.ForbiddenError('You are not the owner of this conversation')
}
const res = {
body: {
conversation: model.mapConversation(conversation),
},
}
return fid.merge(res, {
body: {
conversation: {
id: conversationFid,
},
},
})
}
const validationResult = validateFid(conversationFid)
if (!validationResult.success) {
throw new errors.InvalidPayloadError(validationResult.reason)
}
const { conversation } = await props.client.createConversation({
channel: 'channel',
tags: {
owner: userId,
},
})
const { id: conversationId } = conversation
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
await props.client.addParticipant({ id: conversationId, userId })
await props.convIdStore.byFid.set(conversationFid, conversationId)
const res = {
body: {
conversation: model.mapConversation(conversation),
},
}
return fid.merge(res, {
body: {
conversation: {
id: conversationFid,
},
},
})
}
export const deleteConversation: types.AuthenticatedOperations['deleteConversation'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.deleteConversation(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: req.params.id, [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { conversation } = await props.client.getConversation({ id: req.params.id })
if (conversation.tags.owner !== req.auth.userId) {
throw new errors.ForbiddenError('You are not the owner of this conversation')
}
await props.client.deleteConversation({ id: req.params.id })
return fidHandler.mapResponse({ body: {} })
}
export const listConversations: types.AuthenticatedOperations['listConversations'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.listConversations(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { conversations, meta } = await props.client.listConversations({
nextToken: req.query.nextToken,
tags: { owner: req.auth.userId },
})
return fidHandler.mapResponse({
body: {
conversations: conversations.map((c) => ({
id: c.id,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
})),
meta,
},
})
}
export const listMessages: types.AuthenticatedOperations['listMessages'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.listMessages(props, foreignReq)
const req = await fidHandler.mapRequest()
const { nextToken } = req.query
const { conversationId } = req.params
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId: req.auth.userId })
if (!participant) {
throw new errors.ForbiddenError('You are not a participant in this conversation')
}
const { messages, meta } = await props.client.listMessages({ conversationId, nextToken, tags: {} })
return fidHandler.mapResponse({
body: {
messages: messages.map((m) => model.mapMessage(m as types.Message)),
meta,
},
})
}
export const listenConversation: types.AuthenticatedOperations['listenConversation'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.listenConversation(props, foreignReq)
const req = await fidHandler.mapRequest()
const userId = req.auth.userId
const conversationId = req.params.id
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId })
if (!participant) {
throw new errors.ForbiddenError('You are not a participant in this conversation')
}
const body = {}
const contentLength = Buffer.byteLength(JSON.stringify(body), 'utf-8')
const keepAliveMessage = ['event: message', 'data: ping', '', ''].join('\n')
const b64KeepAlive = Buffer.from(keepAliveMessage, 'utf-8').toString('base64')
return fidHandler.mapResponse({
body,
headers: {
'Content-Type': 'text/event-stream',
'Content-Length': `${contentLength}`,
'Grip-Hold': 'stream',
'Grip-Channel': `${conversationId},${userId}`,
'Grip-Keep-Alive': `${b64KeepAlive}; format=base64; timeout=30;`,
},
})
}
export const addParticipant: types.AuthenticatedOperations['addParticipant'] = async (props, foreignReq) => {
const conversationFid = foreignReq.params.conversationId
const userFid = foreignReq.body.userId
const fidHandler = fid.handlers.addParticipant(props, foreignReq)
const req = await fidHandler.mapRequest()
const userId = req.auth.userId
const conversationId = req.params.conversationId
const participantId = req.body.userId
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
const {
conversation: {
tags: { owner },
},
} = await props.client.getConversation({ id: conversationId })
if (owner !== userId) {
throw new errors.ForbiddenError('You are not the owner of this conversation')
}
const { participant } = await props.client.addParticipant({
id: conversationId,
userId: participantId,
})
await props.signals.emit(conversationId, {
type: 'participant_added',
data: {
conversationId: conversationFid,
participantId: userFid,
},
})
return fidHandler.mapResponse({
body: {
participant: model.mapUser(participant),
},
})
}
export const getParticipant: types.AuthenticatedOperations['getParticipant'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.getParticipant(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: req.params.conversationId, [SPAN_ATTRS.USER_ID]: req.auth.userId })
const {
conversation: {
tags: { owner },
},
} = await props.client.getConversation({ id: req.params.conversationId })
if (owner !== req.auth.userId) {
throw new errors.ForbiddenError('You are not the owner of this conversation')
}
const { participant } = await props.client.getParticipant({
id: req.params.conversationId,
userId: req.params.userId,
})
return fidHandler.mapResponse({
body: {
participant: model.mapUser(participant),
},
})
}
export const removeParticipant: types.AuthenticatedOperations['removeParticipant'] = async (props, foreignReq) => {
const conversationFid = foreignReq.params.conversationId
const userFid = foreignReq.params.userId
const fidHandler = fid.handlers.removeParticipant(props, foreignReq)
const req = await fidHandler.mapRequest()
const userId = req.auth.userId
const conversationId = req.params.conversationId
const participantId = req.params.userId
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
const {
conversation: {
tags: { owner },
},
} = await props.client.getConversation({ id: conversationId })
if (owner !== userId) {
throw new errors.ForbiddenError('You are not the owner of this conversation')
}
if (participantId === owner) {
throw new errors.InvalidPayloadError('You cannot remove yourself from the conversation because you are its owner')
}
await props.client.removeParticipant({
id: conversationId,
userId: participantId,
})
await props.signals.close(participantId)
await props.signals.emit(conversationId, {
type: 'participant_removed',
data: {
conversationId: conversationFid,
participantId: userFid,
},
})
return fidHandler.mapResponse({
body: {},
})
}
export const listParticipants: types.AuthenticatedOperations['listParticipants'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.listParticipants(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: req.params.conversationId, [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { participant } = await props.apiUtils.findParticipant({
id: req.params.conversationId,
userId: req.auth.userId,
})
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this message's conversation")
}
const { users: participants, meta } = await props.client.listUsers({
conversationId: req.params.conversationId,
nextToken: req.query.nextToken,
tags: {},
})
return fidHandler.mapResponse({
body: {
participants: participants.map(model.mapUser),
meta,
},
})
}
@@ -0,0 +1,65 @@
import * as errors from '../../gen/errors'
import { setSpanAttributes, SPAN_ATTRS } from '../../tracing'
import * as types from '../types'
import * as fid from './fid'
import * as model from './model'
export const createEvent: types.AuthenticatedOperations['createEvent'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.createEvent(props, foreignReq)
const req = await fidHandler.mapRequest()
const { conversationId, payload } = req.body
const { userId } = req.auth
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId: req.auth.userId })
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this event's conversation")
}
const { event } = await props.client.createEvent({
type: 'custom',
conversationId,
userId,
payload: {
userId,
conversationId,
payload,
},
})
const res = await fidHandler.mapResponse({
body: {
event: model.mapEvent(event),
},
})
await props.signals.emit(conversationId, {
type: 'event_created',
data: { ...res.body.event, isBot: false },
})
return res
}
export const getEvent: types.AuthenticatedOperations['getEvent'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.getEvent(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { event } = await props.client.getEvent({ id: req.params.id })
const { conversationId } = event.payload
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId: req.auth.userId })
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this event's conversation")
}
return fidHandler.mapResponse({
body: {
event: model.mapEvent(event),
},
})
}
+463
View File
@@ -0,0 +1,463 @@
import _ from 'lodash'
import * as errors from '../../gen/errors'
import { validateFid } from '../../id-store'
import * as types from '../types'
type FidHandler<O extends types.OperationName> = {
mapRequest: () => Promise<types.AuthenticatedInputs[O]>
mapResponse: (res: types.OperationOutputs[O]) => Promise<types.OperationOutputs[O]>
}
type FidHandlers = {
[K in types.OperationName]: null | ((props: types.OperationProps, req: types.AuthenticatedInputs[K]) => FidHandler<K>)
}
export const merge = <T>(a: T, b: types.DeepPartial<T>): T => _.merge({}, a, b)
export const handlers = {
createUser: (props: types.OperationProps, req: types.AuthenticatedInputs['createUser']) => ({
mapRequest: async () => {
const fid = req.body.id
if (fid) {
const validationResult = validateFid(fid)
if (!validationResult.success) {
throw new errors.InvalidPayloadError(validationResult.reason)
}
const id = await props.userIdStore.byFid.find(fid)
if (id) {
throw new errors.AlreadyExistsError(`User with id ${fid} already exists`)
}
}
return merge(req, {
body: {
id: undefined,
},
})
},
mapResponse: async (res) => {
const id = res.body.user.id
const fid = req.body.id
if (fid) {
await props.userIdStore.byFid.set(fid, id)
}
return merge(res, {
body: {
user: {
id: fid,
},
},
})
},
}),
getUser: (props: types.OperationProps, req: types.AuthenticatedInputs['getUser']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) =>
merge(res, {
body: {
user: {
id: req.auth.userId,
},
},
}),
}),
getOrCreateUser: null, // done inside the operation
updateUser: (props: types.OperationProps, req: types.AuthenticatedInputs['updateUser']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) =>
merge(res, {
body: {
user: {
id: req.auth.userId,
},
},
}),
}),
deleteUser: (props: types.OperationProps, req: types.AuthenticatedInputs['deleteUser']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) => {
await props.userIdStore.byFid.delete(req.auth.userId)
return res
},
}),
createConversation: (props: types.OperationProps, req: types.AuthenticatedInputs['createConversation']) => ({
mapRequest: async () => {
const fid = req.body.id
if (fid) {
const validationResult = validateFid(fid)
if (!validationResult.success) {
throw new errors.InvalidPayloadError(validationResult.reason)
}
const id = await props.convIdStore.byFid.find(fid)
if (id) {
throw new errors.AlreadyExistsError(`Conversation with id ${fid} already exists`)
}
}
return merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
body: {
id: undefined,
},
})
},
mapResponse: async (res) => {
const id = res.body.conversation.id
const fid = req.body.id
if (fid) {
await props.convIdStore.byFid.set(fid, id)
}
return merge(res, {
body: {
conversation: {
id: fid,
},
},
})
},
}),
getConversation: (props: types.OperationProps, req: types.AuthenticatedInputs['getConversation']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.params.id),
])
return merge(req, {
auth: {
userId,
},
params: {
id: conversationId,
},
})
},
mapResponse: async (res) =>
merge(res, {
body: {
conversation: {
id: req.params.id,
},
},
}),
}),
getOrCreateConversation: null, // done inside the operation
deleteConversation: (props: types.OperationProps, req: types.AuthenticatedInputs['deleteConversation']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.params.id),
])
return merge(req, {
auth: {
userId,
},
params: {
id: conversationId,
},
})
},
mapResponse: async (res) => {
await props.convIdStore.byFid.delete(req.params.id)
return res
},
}),
listConversations: (props: types.OperationProps, req: types.AuthenticatedInputs['listConversations']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) => {
if (res.body.conversations.length === 0) {
return res
}
const keys = res.body.conversations.map((c) => c.id)
const values = await props.convIdStore.byId.fetch(keys)
return merge(res, {
body: {
conversations: res.body.conversations.map((c) =>
merge(c, {
id: values.find(c.id) ?? c.id,
})
),
},
})
},
}),
listMessages: (props: types.OperationProps, req: types.AuthenticatedInputs['listMessages']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.params.conversationId),
])
return merge(req, {
auth: {
userId,
},
params: {
conversationId,
},
})
},
mapResponse: async (res) => {
if (res.body.messages.length === 0) {
return res
}
const keys = res.body.messages.map((m) => m.userId)
const values = await props.userIdStore.byId.fetch(keys)
return merge(res, {
body: {
messages: res.body.messages.map((m) => ({
userId: values.find(m.userId) ?? m.userId,
conversationId: req.params.conversationId,
})),
},
})
},
}),
listenConversation: (props: types.OperationProps, req: types.AuthenticatedInputs['listenConversation']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.params.id),
])
return merge(req, {
auth: {
userId,
},
params: {
id: conversationId,
},
})
},
mapResponse: async (res) => res,
}),
addParticipant: (props: types.OperationProps, req: types.AuthenticatedInputs['addParticipant']) => ({
mapRequest: async () => {
const [authUserId, userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.userIdStore.byFid.get(req.body.userId),
props.convIdStore.byFid.get(req.params.conversationId),
])
return merge(req, {
auth: {
userId: authUserId,
},
body: {
userId,
} as Partial<types.OperationInputs['addParticipant']['body']>,
params: {
conversationId,
},
})
},
mapResponse: async (res) =>
merge(res, {
body: {
participant: {
id: req.body.userId,
},
},
}),
}),
getParticipant: (props: types.OperationProps, req: types.AuthenticatedInputs['getParticipant']) => ({
mapRequest: async () => {
const [authUserId, userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.userIdStore.byFid.get(req.params.userId),
props.convIdStore.byFid.get(req.params.conversationId),
])
return merge(req, {
auth: {
userId: authUserId,
},
params: {
conversationId,
userId,
},
})
},
mapResponse: async (res) =>
merge(res, {
body: {
participant: {
id: req.params.userId,
},
},
}),
}),
removeParticipant: (props: types.OperationProps, req: types.AuthenticatedInputs['removeParticipant']) => ({
mapRequest: async () => {
const [authUserId, userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.userIdStore.byFid.get(req.params.userId),
props.convIdStore.byFid.get(req.params.conversationId),
])
return merge(req, {
auth: {
userId: authUserId,
},
params: {
conversationId,
userId,
},
})
},
mapResponse: async (res) => res,
}),
listParticipants: (props: types.OperationProps, req: types.AuthenticatedInputs['listParticipants']) => ({
mapRequest: async () => {
const [authUserId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.params.conversationId),
])
return merge(req, {
auth: { userId: authUserId },
params: {
conversationId,
},
})
},
mapResponse: async (res) => {
const keys = res.body.participants.map((p) => p.id)
const values = await props.userIdStore.byId.fetch(keys)
return merge(res, {
body: {
participants: res.body.participants.map((p) => ({
id: values.find(p.id) ?? p.id,
})),
},
})
},
}),
createMessage: (props: types.OperationProps, req: types.AuthenticatedInputs['createMessage']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.body.conversationId),
])
type CreateMessageReqBody = types.OperationInputs['createMessage']['body']
return merge(req, {
auth: {
userId,
},
body: {
conversationId,
} as Partial<CreateMessageReqBody>,
})
},
mapResponse: async (res) => {
const { conversationId } = req.body
const { userId } = req.auth
return merge(res, {
body: {
message: {
conversationId,
userId,
},
},
})
},
}),
getMessage: (props: types.OperationProps, req: types.AuthenticatedInputs['getMessage']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) =>
merge(res, {
body: {
message: {
id: req.params.id,
conversationId: await props.convIdStore.byId.get(res.body.message.conversationId),
userId: req.auth.userId,
},
},
}),
}),
deleteMessage: (props: types.OperationProps, req: types.AuthenticatedInputs['deleteMessage']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) => res,
}),
createEvent: (props: types.OperationProps, req: types.AuthenticatedInputs['createEvent']) => ({
mapRequest: async () => {
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(req.auth.userId),
props.convIdStore.byFid.get(req.body.conversationId),
])
type CreateEventReqBody = types.OperationInputs['createEvent']['body']
return merge(req, {
auth: {
userId,
},
body: {
conversationId,
} as Partial<CreateEventReqBody>,
})
},
mapResponse: async (res) => {
const { conversationId } = req.body
const { userId } = req.auth
return merge(res, {
body: {
event: {
conversationId,
userId,
},
},
})
},
}),
getEvent: (props: types.OperationProps, req: types.AuthenticatedInputs['getEvent']) => ({
mapRequest: async () =>
merge(req, {
auth: {
userId: await props.userIdStore.byFid.get(req.auth.userId),
},
}),
mapResponse: async (res) =>
merge(res, {
body: {
event: {
id: req.params.id,
conversationId: await props.convIdStore.byId.get(res.body.event.conversationId),
userId: req.auth.userId,
},
},
}),
}),
} satisfies FidHandlers
@@ -0,0 +1,12 @@
import * as types from '../types'
import * as conversation from './conversation'
import * as event from './event'
import * as message from './message'
import * as user from './user'
export const operations = {
...user,
...conversation,
...message,
...event,
} satisfies { [O in types.OperationName]: types.Operations[O] | types.AuthenticatedOperations[O] }
@@ -0,0 +1,97 @@
import * as errors from '../../gen/errors'
import { setSpanAttributes, SPAN_ATTRS } from '../../tracing'
import * as msgPayload from '../message-payload'
import * as types from '../types'
import * as fid from './fid'
import * as model from './model'
export const createMessage: types.AuthenticatedOperations['createMessage'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.createMessage(props, foreignReq)
const req = await fidHandler.mapRequest()
const { conversationId, payload, metadata } = req.body
const { userId } = req.auth
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId, [SPAN_ATTRS.USER_ID]: userId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId: req.auth.userId })
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this message's conversation")
}
const { type, payload: mappedPayload } = msgPayload.mapChatMessageToBotpress({ payload, metadata })
const { message } = await props.client.createMessage({
type,
conversationId,
tags: {},
userId,
payload: mappedPayload,
})
setSpanAttributes({ [SPAN_ATTRS.MESSAGE_ID]: message.id })
const res = await fidHandler.mapResponse({
body: {
message: model.mapMessage(message),
},
})
await props.signals.emit(conversationId, {
type: 'message_created',
data: { ...res.body.message, isBot: false },
})
return res
}
export const getMessage: types.AuthenticatedOperations['getMessage'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.getMessage(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId, [SPAN_ATTRS.MESSAGE_ID]: req.params.id })
const { message } = await props.client.getMessage({ id: req.params.id })
const { conversationId } = message
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: conversationId })
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId: req.auth.userId })
if (!participant) {
throw new errors.ForbiddenError("You are not a participant in this message's conversation")
}
return fidHandler.mapResponse({
body: {
message: model.mapMessage(message),
},
})
}
export const deleteMessage: types.AuthenticatedOperations['deleteMessage'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.deleteMessage(props, foreignReq)
const req = await fidHandler.mapRequest()
const { id } = req.params
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId, [SPAN_ATTRS.MESSAGE_ID]: id })
const { message } = await props.client.getMessage({ id })
setSpanAttributes({ [SPAN_ATTRS.CONVERSATION_ID]: message.conversationId })
if (message.userId !== req.auth.userId) {
throw new errors.ForbiddenError('You are not the sender of this message')
}
await props.client.deleteMessage({ id })
await props.signals.emit(message.conversationId, {
type: 'message_deleted',
data: {
id: message.id,
conversationId: message.conversationId,
userId: message.userId,
},
})
return fidHandler.mapResponse({ body: {} })
}
@@ -0,0 +1,49 @@
import * as msgPayload from '../message-payload'
import * as types from '../types'
export type ChatConversation = types.OperationOutputs['getConversation']['body']['conversation']
export const mapConversation = (conversation: types.Conversation): ChatConversation => {
return {
id: conversation.id,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
}
}
export type ChatUser = types.OperationOutputs['getUser']['body']['user']
export const mapUser = (user: types.User): ChatUser => {
return {
id: user.id,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
name: user.name,
pictureUrl: user.pictureUrl,
profile: user.tags.profile,
}
}
export type ChatMessage = types.OperationOutputs['createMessage']['body']['message']
export const mapMessage = (message: types.Message): ChatMessage => {
const { metadata, payload } = msgPayload.mapBotpressMessageToChat(message as msgPayload.BotpressMessage)
return {
id: message.id,
createdAt: message.createdAt,
conversationId: message.conversationId,
userId: message.userId,
payload,
metadata,
}
}
export type ChatEvent = types.OperationOutputs['createEvent']['body']['event']
export const mapEvent = (event: types.Event): ChatEvent => {
const { id, createdAt, payload: data } = event
const { userId, conversationId, payload } = data
return {
id,
createdAt,
conversationId,
userId,
payload,
}
}
@@ -0,0 +1,146 @@
import * as errors from '../../gen/errors'
import { validateFid } from '../../id-store'
import { setSpanAttributes, SPAN_ATTRS } from '../../tracing'
import * as types from '../types'
import * as fid from './fid'
import * as model from './model'
export const createUser: types.Operations['createUser'] = async (props, foreignReq) => {
if (props.auth.mode === 'personal') {
throw new errors.UnauthorizedError(
'The "createUser" operation can only be called when using the shared encryption key.'
)
}
const fidHandler = fid.handlers.createUser(props, { ...foreignReq, auth: { userId: '' } })
const req = await fidHandler.mapRequest()
const {
body: { name, pictureUrl, profile },
} = req
const { user } = await props.client.createUser({
name,
pictureUrl,
tags: {
profile,
fid: req.body.id, // Readonly copy of the user's foreign ID; useful for users of the Runtime API
},
})
const userFid = foreignReq.body.id ?? user.id
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: user.id })
const userKey = props.auth.generateKey({ id: userFid })
return fidHandler.mapResponse({
body: {
user: model.mapUser(user),
key: userKey,
},
})
}
export const getUser: types.AuthenticatedOperations['getUser'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.getUser(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { user } = await props.client.getUser({ id: req.auth.userId })
return fidHandler.mapResponse({
body: {
user: model.mapUser(user),
},
})
}
export const getOrCreateUser: types.AuthenticatedOperations['getOrCreateUser'] = async (props, foreignReq) => {
const {
auth: { userId: userFid },
body: { name, pictureUrl, profile },
} = foreignReq
const existingId =
(await props.userIdStore.byFid.find(userFid)) ??
(await props.apiUtils.findUser({ id: userFid }).then((res) => res.user?.id))
if (existingId) {
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: existingId })
const { user: updatedUser } = await props.client.updateUser({
id: existingId,
name,
pictureUrl,
tags: {
profile,
},
})
const res = {
body: {
user: model.mapUser(updatedUser),
},
}
return fid.merge(res, {
body: {
user: {
id: userFid,
},
},
})
}
const validationResult = validateFid(userFid)
if (!validationResult.success) {
throw new errors.InvalidPayloadError(validationResult.reason)
}
const { user: newUser } = await props.client.createUser({
name,
pictureUrl,
tags: {
profile,
},
})
const res = {
body: {
user: model.mapUser(newUser),
},
}
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: newUser.id })
await props.userIdStore.byFid.set(userFid, newUser.id)
return fid.merge(res, {
body: {
user: {
id: userFid,
},
},
})
}
export const updateUser: types.AuthenticatedOperations['updateUser'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.updateUser(props, foreignReq)
const req = await fidHandler.mapRequest()
const {
body: { name, pictureUrl, profile },
} = req
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId })
const { user } = await props.client.updateUser({ id: req.auth.userId, name, pictureUrl, tags: { profile } })
return fidHandler.mapResponse({
body: {
user: model.mapUser(user),
},
})
}
export const deleteUser: types.AuthenticatedOperations['deleteUser'] = async (props, foreignReq) => {
const fidHandler = fid.handlers.deleteUser(props, foreignReq)
const req = await fidHandler.mapRequest()
setSpanAttributes({ [SPAN_ATTRS.USER_ID]: req.auth.userId })
await props.client.deleteUser({ id: req.auth.userId })
return fidHandler.mapResponse({ body: {} })
}
+44
View File
@@ -0,0 +1,44 @@
import { ApiUtils } from '../api-utils'
import { AuthKeyHandler } from '../auth-key'
import * as gen from '../gen/typings'
import { ChatIdStore } from '../id-store'
import { SignalEmitter } from '../signal-emitter'
import * as types from '../types'
import * as bp from '.botpress'
export * from '../types'
type SdkHandler = bp.IntegrationProps['handler']
type HandlerProps = Parameters<SdkHandler>[0]
export type OperationTools = {
signals: SignalEmitter
auth: AuthKeyHandler
apiUtils: ApiUtils
userIdStore: ChatIdStore
convIdStore: ChatIdStore
}
export type OperationProps = HandlerProps & OperationTools
export type OperationName = keyof Operations
export type Operations = gen.Operations<OperationProps>
export type Operation = gen.Operation<OperationProps>
export type OperationInputs = types.Simplify<gen.Requests>
export type OperationOutputs = types.Simplify<gen.Responses>
export type RouteTree = gen.RouteTree<OperationProps>
export type Route = gen.Route<OperationProps>
export type AuthenticationResult = { userId: string }
export type AuthenticatedInputs = {
[K in OperationName]: OperationInputs[K] & { auth: AuthenticationResult }
}
export type AuthenticatedOperations = {
[K in OperationName]: (props: OperationProps, req: AuthenticatedInputs[K]) => Promise<OperationOutputs[K]>
}
export type MiddleWare<I, O> = (props: OperationProps, input: I) => Promise<O>
export type MiddleWares<I extends Record<OperationName, any>, O extends Record<OperationName, any>> = {
[K in OperationName]: MiddleWare<I[K], O[K]>
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from '@botpress/sdk'
import * as jwt from 'jsonwebtoken'
import * as err from './gen/errors'
const keySchema = z.object({
id: z.string(),
})
const ALGO = 'HS256' as const
export type AuthKey = z.infer<typeof keySchema>
export type EncryptionMode = 'personal' | 'shared'
export class AuthKeyHandler {
public constructor(
private _sk: string,
private _mode: EncryptionMode
) {}
public get mode(): EncryptionMode {
return this._mode
}
public generateKey = (key: AuthKey): string => {
const signed = jwt.sign(key, this._sk, { algorithm: ALGO })
return signed
}
public parseKey = (key: string): AuthKey => {
try {
const verified = jwt.verify(key, this._sk, { algorithms: [ALGO] }) as unknown
return keySchema.parse(verified)
} catch {
throw new err.UnauthorizedError('Invalid user key')
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import * as sdk from '@botpress/sdk'
import * as uuid from 'uuid'
import { logger } from './logger'
import * as types from './types'
type Request = sdk.Request
type Response = sdk.Response | void
const requestId = () => {
const v4 = uuid.v4()
const short = v4.substring(0, 8)
return short
}
export const debugRequest = (req: Request) => {
const id = requestId()
const headersSummary = req.headers ? `headers=${summarize(JSON.stringify(req.headers))}` : 'headers=empty'
const bodySummary = req.body ? `body=${summarize(req.body)}` : 'body=empty'
logger.info(`[${id}] Incoming request ${req.method} ${req.path} ${headersSummary} ${bodySummary}`)
return id
}
const summarize = (text: string, len = 400) => {
return text.length > len ? `${text.substring(0, len)}...` : text
}
export const debugResponse = (id: string, res: Response) => {
if (!res) {
logger.info(`[${id}] Outgoing response empty`)
return
}
const headersSummary = res.headers ? `headers=${summarize(JSON.stringify(res.headers))}` : 'headers=empty'
const bodySummary = res.body ? `body=${summarize(res.body)}` : 'body=empty'
logger.info(`[${id}] Outgoing response ${res.status} ${headersSummary} ${bodySummary}`)
}
export const debugSignal = (args: types.MessageArgs | types.ActionArgs) => {
if ('input' in args) {
logger.debug(
`Sending signal conversationId=${args.input.conversationId} event=${summarize(
JSON.stringify(args.input.payload)
)} user=${args.ctx.botUserId}`
)
} else {
logger.debug(
`Sending signal conversationId=${args.conversation.id} message=${summarize(JSON.stringify(args.message))} user=${
args.user.id
}`
)
}
}
+51
View File
@@ -0,0 +1,51 @@
import * as api from './api'
import openapi from './gen/openapi.json'
export type RouteTree = Record<string, Record<string, api.Route>>
export const extraRoutes: RouteTree = {
'/hello': {
get: async () => ({ status: 200, body: 'Hello, Botpress User 🤖' }),
},
'/openapi.json': {
get: async () => ({ status: 200, body: JSON.stringify(openapi) }),
},
'/redoc': {
get: async ({ ctx: { webhookId } }) => ({
status: 200,
headers: {
'Content-Type': 'text/html',
},
body: `<!DOCTYPE html>
<html>
<head>
<title>Redoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
rel="stylesheet"
/>
<!--
Redoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc
spec-url="/${webhookId}/openapi.json"
></redoc>
<script src="https://unpkg.com/redoc@2.0.0-rc.72/bundles/redoc.standalone.js"></script>
</body>
</html>
`,
}),
},
}
+77
View File
@@ -0,0 +1,77 @@
import { Request } from '@botpress/sdk'
import * as api from './api'
import { extraRoutes } from './extra-routes'
import * as errors from './gen/errors'
import { handleRequest, Router } from './gen/handler'
import { httpRequestsTotal, httpRequestDuration } from './metrics'
import { Handler } from './types'
import * as websocket from './websocket'
const isPushpinRequest = (req: Request) => 'grip-sig' in req.headers
const apiRoutes = api.routes as Record<string, Record<string, api.Route>>
const routes = { ...apiRoutes, ...extraRoutes }
const router = new Router(Object.keys(routes))
export const makeHandler =
(props: api.OperationTools): Handler =>
async (args) => {
if (args.req.method.toLowerCase() === 'options') {
// preflight request
return {
status: 200,
}
}
if (!isPushpinRequest(args.req)) {
const message =
'Chat API should be called from the chat domain "chat.botpress.cloud" not directly from the webhook domain "webhook.botpress.cloud".'
return {
status: 400,
body: JSON.stringify({ message }),
}
}
if (websocket.isWebSocketRequest(args.req) && args.req.body) {
try {
return await websocket.handleWebSocketRequest(props, args.req)
} catch (thrown: unknown) {
if (errors.isApiError(thrown)) {
return {
status: thrown.code,
body: JSON.stringify(thrown.toJSON()),
}
}
return {
status: 500,
body: JSON.stringify({
message: thrown instanceof Error ? thrown.message : 'Unknown error',
}),
}
}
}
const match = router.match(args.req.path)
const normalizedPath = match?.path ?? 'not_found'
const method = args.req.method.toLowerCase()
const { auth, signals, convIdStore, userIdStore, apiUtils } = props
const start = performance.now()
let statusCode = '500'
try {
const response = await handleRequest(routes, {
...args,
auth,
signals,
convIdStore,
userIdStore,
apiUtils,
})
statusCode = String(response.status ?? 200)
return response
} finally {
const durationSeconds = (performance.now() - start) / 1000
httpRequestsTotal.inc({ method, status_code: statusCode, path: normalizedPath })
httpRequestDuration.observe({ method, status_code: statusCode, path: normalizedPath }, durationSeconds)
}
}
@@ -0,0 +1,213 @@
import * as dynamodb from '@aws-sdk/client-dynamodb'
import { logger } from '../logger'
import { runWithSpan, SPAN_ATTRS } from '../tracing'
import * as errors from './errors'
import * as types from './types'
type DynamoDbMapProps = {
botId: string
tableName: string
indexName: string | undefined
partitionKey: string
srcKeyName: string
destKeyName: string
}
class DynamoDbMap implements types.IdMap {
public constructor(
protected _client: dynamodb.DynamoDBClient,
private _props: DynamoDbMapProps
) {}
public async find(src: string): Promise<string | undefined> {
const { botId, tableName, indexName, partitionKey, srcKeyName, destKeyName } = this._props
const { Items } = await runWithSpan(
'dynamodb.find',
() =>
this._client.send(
new dynamodb.QueryCommand({
TableName: tableName,
IndexName: indexName,
ConsistentRead: true,
KeyConditionExpression: `${partitionKey} = :bot_id AND ${srcKeyName} = :src`,
ExpressionAttributeValues: {
':bot_id': { S: botId },
':src': { S: src },
},
})
),
{ attributes: { [SPAN_ATTRS.DB_TABLE]: tableName, [SPAN_ATTRS.DB_KEY]: src } }
)
const dest = Items?.[0]?.[destKeyName]?.S
this._debug('find', src, dest ?? '∅')
return dest
}
public async get(src: string): Promise<string> {
const dest = await this.find(src)
return dest ?? src
}
protected _debug = (operation: string, src: string, dest: string) => {
if (this._props.indexName === undefined) {
logger.debug(`${operation} ${src} -> ${dest}`)
} else {
logger.debug(`${operation} ${dest} <- ${src}`)
}
}
}
class IncomingDynamoDbMap extends DynamoDbMap implements types.IncomingIdMap {
public constructor(
client: dynamodb.DynamoDBClient,
private _args: DynamoDbChatIdStoreProps
) {
const { botId, tableName, partitionKey, sortKey, indexSortKey } = _args
super(client, {
botId,
tableName,
indexName: undefined,
partitionKey,
srcKeyName: sortKey,
destKeyName: indexSortKey,
})
}
public async set(fid: string, id: string): Promise<void> {
const { botId, tableName, partitionKey: partitionKeyName, sortKey, indexSortKey } = this._args
const createdAt = String(Date.now())
await runWithSpan(
'dynamodb.set',
() =>
this._client
.send(
new dynamodb.PutItemCommand({
TableName: tableName,
Item: {
[partitionKeyName]: { S: botId },
[sortKey]: { S: fid },
[indexSortKey]: { S: id },
created_at: { N: createdAt },
},
ConditionExpression: `attribute_not_exists(${partitionKeyName}) AND attribute_not_exists(${sortKey})`,
})
)
.catch((thrown) => {
if (thrown instanceof dynamodb.ConditionalCheckFailedException) {
throw new errors.IdAlreadyAssignedError(fid)
}
throw thrown
}),
{ attributes: { [SPAN_ATTRS.DB_TABLE]: tableName, [SPAN_ATTRS.DB_KEY]: fid } }
)
this._debug('set', fid, id)
}
public async delete(fid: string): Promise<void> {
const { botId, tableName, partitionKey: partitionKeyName, sortKey } = this._args
await runWithSpan(
'dynamodb.delete',
() =>
this._client.send(
new dynamodb.DeleteItemCommand({
TableName: tableName,
Key: {
[partitionKeyName]: { S: botId },
[sortKey]: { S: fid },
},
})
),
{ attributes: { [SPAN_ATTRS.DB_TABLE]: tableName, [SPAN_ATTRS.DB_KEY]: fid } }
)
}
}
class OutgoingDynamoDbMap extends DynamoDbMap implements types.OutoingIdMap {
public constructor(
client: dynamodb.DynamoDBClient,
private _args: DynamoDbChatIdStoreProps
) {
const { botId, tableName, indexName, partitionKey, sortKey, indexSortKey } = _args
super(client, {
botId,
tableName,
indexName,
partitionKey,
srcKeyName: indexSortKey,
destKeyName: sortKey,
})
}
public async fetch(
ids: string[]
): Promise<{ get: (id: string) => string; find: (id: string) => string | undefined }> {
if (!ids.length) {
return {
find: () => undefined,
get: (id) => id,
}
}
const { botId, tableName, indexName, partitionKey, indexSortKey, sortKey } = this._args
const uniqueIds = Array.from(new Set(ids))
const placeholders = uniqueIds.map(() => '?').join(', ')
const parameters = [{ S: botId }, ...uniqueIds.map((id) => ({ S: id }))]
// cannot perform a batch get operation on a secondary index, so we use PartiQl instead
const { Items } = await runWithSpan(
'dynamodb.fetch',
() =>
this._client.send(
new dynamodb.ExecuteStatementCommand({
Statement: `SELECT ${indexSortKey}, ${sortKey} FROM "${tableName}"."${indexName}" WHERE ${partitionKey} = ? AND ${indexSortKey} IN (${placeholders})`,
Parameters: parameters,
})
),
{ attributes: { [SPAN_ATTRS.DB_TABLE]: tableName, [SPAN_ATTRS.DB_COUNT]: String(uniqueIds.length) } }
)
const entries: Record<string, string> = {}
for (const item of Items ?? []) {
const id = item[indexSortKey]?.S
const fid = item[sortKey]?.S
if (id !== undefined && fid !== undefined) {
entries[id] = fid
}
}
return {
find: (id: string) => entries[id],
get: (id: string) => {
const fid = entries[id]
return fid ?? id
},
}
}
}
export type DynamoDbChatIdStoreProps = {
botId: string
tableName: string
indexName: string
partitionKey: string
sortKey: string
indexSortKey: string
}
export class DynamoDbChatIdStore implements types.ChatIdStore {
public readonly byFid: types.IncomingIdMap
public readonly byId: types.OutoingIdMap
public constructor(client: dynamodb.DynamoDBClient, props: DynamoDbChatIdStoreProps) {
this.byFid = new IncomingDynamoDbMap(client, props)
this.byId = new OutgoingDynamoDbMap(client, props)
}
}
+7
View File
@@ -0,0 +1,7 @@
import * as errors from '../gen/errors'
export class IdAlreadyAssignedError extends errors.InternalError {
public constructor(id: string) {
super(`The id "${id}" is already assigned to another value`)
}
}
@@ -0,0 +1,30 @@
import { test, expect } from 'vitest'
import { InMemoryChatIdStore } from './in-memory-store'
test('id store should set by fid and get by id', async () => {
const store = new InMemoryChatIdStore()
const fid = 'fid'
const id = 'id'
await store.byFid.set(fid, id)
expect(await store.byFid.get(fid)).toBe(id)
expect(await store.byId.get(id)).toBe(fid)
})
test('id store should set by id and get by fid', async () => {
const store = new InMemoryChatIdStore()
const fid = 'fid'
const id = 'id'
await store.byId.set(id, fid)
expect(await store.byId.get(id)).toBe(fid)
expect(await store.byFid.get(fid)).toBe(id)
})
test('id store should delete by fid', async () => {
const store = new InMemoryChatIdStore()
const fid = 'fid'
const id = 'id'
await store.byFid.set(fid, id)
await store.byFid.delete(fid)
expect(await store.byFid.find(fid)).toBeUndefined()
expect(await store.byId.find(id)).toBeUndefined()
})
@@ -0,0 +1,104 @@
import { logger } from '../logger'
import * as errors from './errors'
import * as types from './types'
class InMemoryMap implements types.IncomingIdMap, types.OutoingIdMap {
public constructor(
private _direction: 'incoming' | 'outgoing',
private _incoming: Record<string, string> = {},
private _outgoing: Record<string, string> = {}
) {}
public find = async (key: string) => {
const value = this._incoming[key]
this._debug('find', key, value ?? '∅')
return value
}
public get = async (key: string) => {
const value = this._incoming[key]
this._debug('get', key, value ?? '∅')
return value ?? key
}
public set = async (key: string, value: string) => {
const existingValue = this._incoming[key]
if (existingValue !== undefined && existingValue !== value) {
throw new errors.IdAlreadyAssignedError(key)
}
const existingKey = this._outgoing[value]
if (existingKey !== undefined && existingKey !== key) {
throw new errors.IdAlreadyAssignedError(value)
}
this._debug('set', key, value)
this._incoming[key] = value
this._outgoing[value] = key
}
public delete = async (key: string) => {
const value = this._incoming[key]
if (value === undefined) {
return
}
this._debug('delete', key, value)
delete this._incoming[key]
delete this._outgoing[value]
}
public fetch = async () => {
// store is already in memory, no need to fetch anything
return {
find: (key: string) => this._incoming[key],
get: (key: string) => {
const value = this._incoming[key]
return value ?? key
},
}
}
private _debug = (operation: string, src: string, dest: string) => {
if (this._direction === 'incoming') {
logger.debug(`${operation} ${src} -> ${dest}`)
} else {
logger.debug(`${operation} ${dest} <- ${src}`)
}
}
}
export class MemorySpace {
private _memory: Record<string, any> = {}
public subSpace(namespace: string) {
const newSpace = new MemorySpace()
newSpace._memory = this.instanciate(namespace, {})
return newSpace
}
public instanciate<T extends {}>(namespace: string, init: T) {
if (this._memory[namespace] === undefined) {
this._memory[namespace] = init
}
return this._memory[namespace] as T
}
}
export class InMemoryChatIdStore implements types.ChatIdStore {
private _byFid: Record<string, string> = {}
private _byId: Record<string, string> = {}
public constructor(memSpace?: MemorySpace) {
memSpace = memSpace ?? new MemorySpace()
this._byFid = memSpace.instanciate('byFid', {})
this._byId = memSpace.instanciate('byId', {})
}
public get byFid() {
return new InMemoryMap('incoming', this._byFid, this._byId)
}
public get byId() {
return new InMemoryMap('outgoing', this._byId, this._byFid)
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types'
export * from './errors'
export * from './in-memory-store'
export * from './dynamo-db-store'
export * from './validate-fid'
+22
View File
@@ -0,0 +1,22 @@
export type IdMap = {
find: (src: string) => Promise<string | undefined>
/** just like find, but returns the src id if not found */
get: (src: string) => Promise<string>
}
export type IncomingIdMap = IdMap & {
set: (src: string, dest: string) => Promise<void>
delete: (src: string) => Promise<void>
}
export type OutoingIdMap = IdMap & {
fetch: (srcs: string[]) => Promise<{
find: (src: string) => string | undefined
get: (src: string) => string
}>
}
export type ChatIdStore = {
byFid: IncomingIdMap
byId: OutoingIdMap
}
@@ -0,0 +1,34 @@
export type FidValidationResult =
| {
success: true
}
| {
success: false
reason: string
}
/**
* The fid store uses a local secondary index in dynamodb. The LSI are limited to 10GB of storage.
* By setting the max fid length to 70 and knowing that a botpress ID is a ULID of < 30 characters,
* we can safely assume that each bot can hold up to 10 million fids.
*/
const MAX_FID_LENGTH = 70
/**
* By limiting the set of allowed characters in a fid, we leave the door open for creating
* composite keys in the future.
*/
const FID_REGEX = /^[a-zA-Z0-9_\-\\/]+$/
export const validateFid = (fid: string): FidValidationResult => {
if (fid.length > MAX_FID_LENGTH) {
return { success: false, reason: `Fid length exceeds the maximum allowed length of ${MAX_FID_LENGTH}` }
}
const isValid = FID_REGEX.test(fid)
if (!isValid) {
return { success: false, reason: 'Fid contains invalid characters' }
}
return { success: true }
}
+228
View File
@@ -0,0 +1,228 @@
import * as dynamodb from '@aws-sdk/client-dynamodb'
import { mapBotpressMessageToChat } from './api/message-payload'
import { makeApiUtils } from './api-utils'
import { AuthKeyHandler } from './auth-key'
import * as debug from './debug'
import { makeHandler } from './handler'
import { MemorySpace, ChatIdStore, InMemoryChatIdStore, DynamoDbChatIdStore } from './id-store'
import { startMetricsServer } from './metrics-server'
import { Options, options } from './options'
import { CompositeSignalEmiter, PushpinEmitter, SignalEmitter, WebhookEmitter } from './signal-emitter'
import { initTracing, normalizePath, runWithSpan, setSpanAttributes, SPAN_ATTRS } from './tracing'
import { MessageArgs, ActionArgs } from './types'
import * as bp from '.botpress'
const tracingProvider = initTracing()
if (tracingProvider) {
process.on('SIGTERM', () => void tracingProvider.shutdown().catch(console.error))
}
const memSpace = new MemorySpace()
type ChatIdStores = Record<'convIdStore' | 'userIdStore', ChatIdStore>
const makeIdStores = (options: Options): ChatIdStores => {
if (options.fidStore.strategy === 'dynamo-db') {
const { botId } = options
const { endpoint, region, accessKeyId, secretAccessKey, conversationTable, userTable } = options.fidStore
const client = new dynamodb.DynamoDBClient({
endpoint,
region,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return {
convIdStore: new DynamoDbChatIdStore(client, { botId, ...conversationTable }),
userIdStore: new DynamoDbChatIdStore(client, { botId, ...userTable }),
}
}
return {
convIdStore: new InMemoryChatIdStore(memSpace.subSpace('conversation')),
userIdStore: new InMemoryChatIdStore(memSpace.subSpace('user')),
}
}
const makeEmitter = (options: Options): SignalEmitter => {
const { signalUrl, signalSecret, webhookUrl, webhookSecret } = options
const pushpinEmitter = new PushpinEmitter(signalUrl, signalSecret)
if (!webhookUrl) {
return pushpinEmitter
}
const webhookEmitter = new WebhookEmitter(webhookUrl, webhookSecret)
return new CompositeSignalEmiter([pushpinEmitter, webhookEmitter])
}
const mapMessageSignalFid = async (idStores: ChatIdStores, args: MessageArgs): Promise<MessageArgs> => {
const conversationId = await idStores.convIdStore.byId.get(args.conversation.id)
const userId = await idStores.userIdStore.byId.get(args.user.id)
return {
...args,
message: {
...args.message,
conversationId,
userId,
},
conversation: {
...args.conversation,
id: conversationId,
},
user: {
...args.user,
id: userId,
},
}
}
const mapEventSignalFid = async (idStores: ChatIdStores, args: ActionArgs): Promise<ActionArgs> => {
const { input } = args
const conversationId = await idStores.convIdStore.byId.get(input.conversationId)
return {
...args,
input: {
...input,
conversationId,
},
}
}
const emitMessage = async (args: MessageArgs) => {
await runWithSpan('emit.message', async () => {
const opts = options(args)
const signalEmitter = makeEmitter(opts)
const idStores = makeIdStores(opts)
const {
conversation: { id: channel },
} = args
args = await mapMessageSignalFid(idStores, args)
debug.debugSignal(args)
setSpanAttributes({
[SPAN_ATTRS.CONVERSATION_ID]: args.conversation.id,
[SPAN_ATTRS.USER_ID]: args.user.id,
[SPAN_ATTRS.MESSAGE_ID]: args.message.id,
})
const { metadata, payload } = mapBotpressMessageToChat(args)
await signalEmitter.emit(channel, {
type: 'message_created',
data: {
id: args.message.id,
conversationId: args.conversation.id,
userId: args.user.id,
createdAt: args.message.createdAt,
payload,
metadata,
isBot: true,
},
})
})
}
const emitEvent = async (args: ActionArgs) => {
await runWithSpan('emit.event', async () => {
const opts = options(args)
const signalEmitter = makeEmitter(opts)
const idStores = makeIdStores(opts)
const {
input: { conversationId: channel },
} = args
args = await mapEventSignalFid(idStores, args)
debug.debugSignal(args)
setSpanAttributes({
[SPAN_ATTRS.CONVERSATION_ID]: args.input.conversationId,
})
await signalEmitter.emit(channel, {
type: 'event_created',
data: {
id: null,
createdAt: new Date().toISOString(),
conversationId: args.input.conversationId,
userId: args.ctx.botUserId,
payload: args.input.payload,
isBot: true,
},
})
})
}
class IntegrationWithMetrics extends bp.Integration {
public startMetricsServer = startMetricsServer
}
export default new IntegrationWithMetrics({
register: async () => {},
unregister: async () => {},
__advanced: {
managesOwnTracePropagation: !!tracingProvider,
},
actions: {
sendEvent: async (props) => {
await emitEvent(props)
return {}
},
},
channels: {
channel: {
messages: {
text: emitMessage,
markdown: emitMessage,
image: emitMessage,
audio: emitMessage,
video: emitMessage,
file: emitMessage,
location: emitMessage,
carousel: emitMessage,
card: emitMessage,
dropdown: emitMessage,
choice: emitMessage,
bloc: emitMessage,
},
},
},
handler: async (props) => {
const opts = options(props)
const signalEmitter = makeEmitter(opts)
const auth = new AuthKeyHandler(opts.encryptionKey, opts.encryptionMode)
const apiUtils = makeApiUtils(props.client)
const idStores = makeIdStores(opts)
const handler = makeHandler({
...idStores,
signals: signalEmitter,
auth,
apiUtils,
})
const reqId = debug.debugRequest(props.req)
const res = await runWithSpan(
`${props.req.method} ${normalizePath(props.req.path)}`,
async () => {
setSpanAttributes({
[SPAN_ATTRS.BOT_ID]: props.ctx.botId,
[SPAN_ATTRS.INTEGRATION_ID]: props.ctx.integrationId,
})
return handler(props)
},
{
// traceHeaders is only used for W3C context extraction — header values are not stored as span attributes
traceHeaders: props.req.headers,
}
)
debug.debugResponse(reqId, res)
return res
},
})
+16
View File
@@ -0,0 +1,16 @@
import chalk from 'chalk'
export type Logger = {
debug: (message: string, ...args: any[]) => void
info: (message: string, ...args: any[]) => void
warn: (message: string, ...args: any[]) => void
error: (message: string, ...args: any[]) => void
}
export type LogLevel = keyof Logger
export const logger: Logger = {
debug: (message, ...args) => console.debug(`${chalk.blueBright('DEBUG')} ${message}`, ...args),
info: (message, ...args) => console.info(`${chalk.greenBright('INFO')} ${message}`, ...args),
warn: (message, ...args) => console.warn(`${chalk.yellowBright('WARN')} ${message}`, ...args),
error: (message, ...args) => console.error(`${chalk.redBright('ERROR')} ${message}`, ...args),
}
+39
View File
@@ -0,0 +1,39 @@
import * as http from 'http'
import { collectDefaultMetrics } from 'prom-client'
import { registry } from './metrics'
export const startMetricsServer = (port: number) => {
collectDefaultMetrics({ register: registry })
const server = http.createServer((req, res) => {
void (async () => {
try {
if (req.url === '/health') {
res.writeHead(200).end('ok')
return
}
if (req.url === '/metrics') {
const metrics = await registry.metrics()
res.writeHead(200, { 'Content-Type': registry.contentType })
res.end(metrics)
return
}
res.writeHead(404).end('Not Found')
} catch (err) {
console.error('Metrics server error:', err)
if (!res.headersSent) {
res.writeHead(500).end('Internal Server Error')
}
}
})()
})
server.on('error', (err) => {
console.error(`Metrics server failed to start: ${err.message}`)
})
server.listen(port, () => {
console.info(`Metrics server listening on port ${port}`)
})
}
+18
View File
@@ -0,0 +1,18 @@
import { Registry, Counter, Histogram } from 'prom-client'
export const registry = new Registry()
export const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'status_code', 'path'],
registers: [registry],
})
export const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'status_code', 'path'],
buckets: [0.05, 0.1, 0.5, 1, 3, 10, 60, 120],
registers: [registry],
})
+103
View File
@@ -0,0 +1,103 @@
import { z } from '@botpress/sdk'
import { EncryptionMode } from './auth-key'
import { logger } from './logger'
import { ActionArgs, HandlerProps, MessageArgs } from './types'
import * as bp from '.botpress'
const DEFAULT_FID_STORE_CONFIG: FidStoreConfig = { strategy: 'in-memory' }
type FidStoreConfig = z.infer<typeof fidStoreConfigSchema>
const tableSchema = z.object({
tableName: z.string(),
indexName: z.string(),
partitionKey: z.string(),
sortKey: z.string(),
indexSortKey: z.string(),
})
const fidStoreConfigSchema = z.union([
z.object({
strategy: z.literal('in-memory'),
}),
z.object({
strategy: z.literal('dynamo-db'),
endpoint: z.string().optional(),
region: z.string().optional(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
conversationTable: tableSchema,
userTable: tableSchema,
}),
])
type JsonSafeParseResult<T> = { success: true; data: T } | { success: false; err: Error }
const jsonSafeParse = <T = unknown>(str: string): JsonSafeParseResult<T> => {
try {
const data = JSON.parse(str)
return { success: true, data }
} catch (thrown) {
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
return { success: false, err }
}
}
const getFidStoreConfig = (b64Config: string | undefined): FidStoreConfig => {
const warningMessage = 'falling back on in-memory chat id store. This will break if used in production.'
if (!b64Config) {
logger.warn(`FID_STORE_CONFIG secret is unset; ${warningMessage}`)
return DEFAULT_FID_STORE_CONFIG
}
const fidStoreConfigDecoded = Buffer.from(b64Config, 'base64').toString()
const jsonParseResult = jsonSafeParse(fidStoreConfigDecoded)
if (!jsonParseResult.success) {
logger.warn(`FID_STORE_CONFIG should be a base64 encoded JSON string; ${warningMessage}`)
return DEFAULT_FID_STORE_CONFIG
}
const zodParseResult = fidStoreConfigSchema.safeParse(jsonParseResult.data)
if (!zodParseResult.success) {
logger.warn(`FID_STORE_CONFIG is invalid; ${warningMessage}`)
return DEFAULT_FID_STORE_CONFIG
}
return zodParseResult.data
}
const signalUrl = bp.secrets.SIGNAL_URL
const signalSecret = bp.secrets.SIGNAL_SECRET
const fidStore = getFidStoreConfig(bp.secrets.FID_STORE_CONFIG)
export type Options = {
botId: string
signalUrl: string
signalSecret?: string
webhookUrl?: string
webhookSecret?: string
encryptionKey: string
encryptionMode: EncryptionMode
fidStore: FidStoreConfig
}
export const options = (args: MessageArgs | HandlerProps | ActionArgs): Options => {
const { botId } = args.ctx
const { encryptionKey: personalKey, webhookUrl, webhookSecret } = args.ctx.configuration
const encryptionKey = personalKey ? personalKey : bp.secrets.AUTH_ENCRYPTION_KEY
const encryptionMode = personalKey ? 'personal' : 'shared'
return {
botId,
signalUrl,
signalSecret,
webhookUrl,
webhookSecret,
encryptionKey,
encryptionMode,
fidStore,
}
}
@@ -0,0 +1,13 @@
import { Signal, SignalEmitter } from './typings'
export class CompositeSignalEmiter implements SignalEmitter {
public constructor(private _emitters: SignalEmitter[]) {}
public async emit(channel: string, signal: Signal): Promise<void> {
await Promise.all(this._emitters.map((emitter) => emitter.emit(channel, signal)))
}
public async close(channel: string): Promise<void> {
await Promise.all(this._emitters.map((emitter) => emitter.close(channel)))
}
}
@@ -0,0 +1,4 @@
export * from './typings'
export * from './pushpin'
export * from './webhook'
export * from './composite'
@@ -0,0 +1,102 @@
import axios, { AxiosInstance } from 'axios'
import { logger } from '../logger'
import { SignalEmitter, Signal } from './typings'
type PublishBody = {
items: PublishItem[]
}
type PublishAction = 'send' | 'hint' | 'close' | 'refresh'
type PublishItem = {
id?: string
'prev-id'?: string
channel?: string
formats?: {
'ws-message'?: {
content?: string
'content-bin'?: string
action?: PublishAction
}
'http-stream'?: {
content?: string
'content-bin'?: string
action?: PublishAction
}
'http-response'?: {
action?: PublishAction
code?: number
reason?: string
headers?: object
body?: string
'body-bin'?: string
}
}
}
export class PushpinEmitter implements SignalEmitter {
private _client: AxiosInstance
public constructor(emitUrl: string, secreKey: string | undefined) {
const headers: Record<string, string> = !secreKey
? {}
: {
Authorization: `Basic ${secreKey}`,
}
this._client = axios.create({
baseURL: emitUrl,
headers,
})
}
public async emit(channel: string, signal: Signal): Promise<void> {
await this._publish({
items: [
{
channel,
formats: {
'http-stream': {
action: 'send',
content: this._sse(signal),
},
'ws-message': {
action: 'send',
content: JSON.stringify(signal),
},
},
},
],
}).catch(this._handleError)
}
public async close(channel: string): Promise<void> {
await this._publish({
items: [
{
channel,
formats: {
'http-stream': {
action: 'close',
},
'ws-message': {
action: 'close',
},
},
},
],
}).catch(this._handleError)
}
private async _publish(body: PublishBody): Promise<void> {
await this._client.post('/publish', body)
}
private _sse = (signal: Signal): string => {
const data = JSON.stringify(signal)
return ['event: message', `data: ${data}`, '', ''].join('\n')
}
private _handleError = (thrown: unknown): void => {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.error(`An error occured when publishing to Pushpin: "${error.message}"`)
}
}
@@ -0,0 +1,11 @@
import { Types as Signals } from '../gen/signals'
type ValueOf<T> = T[keyof T]
export type Signal = ValueOf<Signals>
export type MessageCreatedSignal = Signals['messageCreated']
export type EventCreatedSignal = Signals['eventCreated']
export type SignalEmitter = {
emit(channel: string, signal: Signal): Promise<void>
close(channel: string): Promise<void>
}
@@ -0,0 +1,32 @@
import axios, { AxiosInstance } from 'axios'
import { logger } from '../logger'
import { SignalEmitter, Signal } from './typings'
export class WebhookEmitter implements SignalEmitter {
private _client: AxiosInstance
public constructor(webhookUrl: string, secreKey: string | undefined) {
const headers: Record<string, string> = !secreKey
? {}
: {
'x-secret-key': secreKey,
}
this._client = axios.create({
baseURL: webhookUrl,
headers,
})
}
public async emit(_channel: string, signal: Signal): Promise<void> {
await this._client.post('/', signal).catch(this._handleError)
}
public async close(): Promise<void> {
// the webhook signal emitter cannot be closed since it's only accessible to the admin and not scoped to a user
}
private _handleError = (thrown: unknown): void => {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.error(`An error occured when emitting a signal to Webhook: "${error.message}"`)
}
}
+124
View File
@@ -0,0 +1,124 @@
import { context, propagation, SpanStatusCode, trace } from '@opentelemetry/api'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { Resource } from '@opentelemetry/resources'
import { BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const ULID_REGEX = /^[a-zA-Z]+_[0-9A-HJKMNP-TV-Z]{26}$/
export const normalizePath = (path: string): string =>
path
.split('?')[0]!
.split('/')
.map((part) => (UUID_REGEX.test(part) || ULID_REGEX.test(part) ? ':id' : part))
.join('/')
export const SPAN_ATTRS = {
USER_ID: 'bp.userId',
CONVERSATION_ID: 'bp.conversationId',
MESSAGE_ID: 'bp.messageId',
BOT_ID: 'bp.botId',
INTEGRATION_ID: 'bp.integrationId',
DB_TABLE: 'db.table',
DB_KEY: 'db.key',
DB_COUNT: 'db.count',
} as const
export const initTracing = (): NodeTracerProvider | null => {
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT && process.env.OTEL_CONSOLE_EXPORTER !== 'true') {
return null
}
const resourceAttrs: Record<string, string> = {
'service.name': process.env.OTEL_SERVICE_NAME ?? 'chat-integration',
}
if (process.env.OTEL_SERVICE_VERSION) {
resourceAttrs['service.version'] = process.env.OTEL_SERVICE_VERSION
}
if (process.env.OTEL_DEPLOYMENT_ENVIRONMENT) {
resourceAttrs['deployment.environment'] = process.env.OTEL_DEPLOYMENT_ENVIRONMENT
}
const provider = new NodeTracerProvider({
resource: new Resource(resourceAttrs),
})
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()))
}
if (process.env.OTEL_CONSOLE_EXPORTER === 'true') {
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()))
}
provider.register({ propagator: new W3CTraceContextPropagator() })
registerInstrumentations({
tracerProvider: provider,
instrumentations: [
new HttpInstrumentation({
// Rename spans for clarity
requestHook: (span, request) => {
if ('complete' in request) {
// IncomingMessage: use x-bp-operation header for a descriptive name
const op = request.headers['x-bp-operation']
if (typeof op === 'string') {
span.updateName(`bp:${op}`)
}
} else if ('path' in request && request.path) {
// ClientRequest (outgoing): rename from "METHOD" to "-> METHOD /normalized-path"
span.updateName(`-> ${request.method ?? 'HTTP'} ${normalizePath(request.path)}`)
}
},
}),
],
})
return provider
}
export const runWithSpan = async <T>(
spanName: string,
fn: () => Promise<T>,
opts?: {
attributes?: Record<string, string>
traceHeaders?: Record<string, string | string[] | undefined>
}
): Promise<T> => {
const tracer = trace.getTracer('chat-integration')
const parentCtx = opts?.traceHeaders ? propagation.extract(context.active(), opts.traceHeaders) : context.active()
return context.with(parentCtx, async () =>
tracer.startActiveSpan(spanName, { attributes: opts?.attributes }, async (span) => {
try {
const result = await fn()
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (err: unknown) {
if (err instanceof Error) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
} else {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) })
}
throw err
} finally {
span.end()
}
})
)
}
export const setSpanAttributes = (attrs: Record<string, string | undefined>): void => {
const span = trace.getActiveSpan()
if (!span) {
return
}
for (const [key, value] of Object.entries(attrs)) {
if (value !== undefined) {
span.setAttribute(key, value)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import * as bp from '.botpress'
export type ValueOf<T> = T[keyof T]
export type Simplify<T> = T extends (...args: infer A) => infer R
? (...args: Simplify<A>) => Simplify<R>
: T extends Buffer
? Buffer
: T extends Promise<infer R>
? Promise<Simplify<R>>
: T extends object
? T extends infer O
? { [K in keyof O]: Simplify<O[K]> }
: never
: T
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>
}
export type Handler = bp.IntegrationProps['handler']
export type HandlerProps = Parameters<Handler>[0]
export type Conversation = Awaited<ReturnType<bp.Client['getConversation']>>['conversation']
export type Message = Awaited<ReturnType<bp.Client['getMessage']>>['message']
export type User = Awaited<ReturnType<bp.Client['getUser']>>['user']
export type Event = Awaited<ReturnType<bp.Client['getEvent']>>['event']
export type MessageCallback = ValueOf<bp.Integration['channels']['channel']['messages']>
export type MessageArgs = Simplify<Parameters<MessageCallback>[0]>
type AsyncFunc = (...args: any[]) => Promise<any>
export type ClientOperation = Simplify<
keyof {
[K in keyof bp.Client as bp.Client[K] extends AsyncFunc ? K : never]: bp.Client[K]
}
>
export type ClientRequests = {
[K in ClientOperation]: Parameters<bp.Client[K]>[0]
}
export type ClientResponses = {
[K in ClientOperation]: Awaited<ReturnType<bp.Client[K]>>
}
export type ActionInputs = {
[K in keyof bp.Integration['actions']]: Parameters<bp.Integration['actions'][K]>[0]
}
export type ActionArgs = Simplify<ValueOf<ActionInputs>>
+78
View File
@@ -0,0 +1,78 @@
import { Request } from '@botpress/sdk'
import { messages, outputs } from '@bpinternal/pingrip'
import qs from 'qs'
import * as api from './api'
import * as errors from './gen/errors'
const WS_CLOSE_GOING_AWAY = 1001
export const isWebSocketRequest = (req: Request) => {
if (req.method.toLowerCase() !== 'post') {
return false
}
const parts = req.path.split('/').splice(1)
if (parts.length !== 3) {
return false
}
return parts[0] === 'conversations' && parts[2] === 'listen'
}
type RequestIdentifiers = {
conversationId: string
userId: string
}
const extractRequestIdentifiers = async (props: api.OperationTools, req: Request): Promise<RequestIdentifiers> => {
const queries = qs.parse(req.query)
if (!queries['x-user-key'] || typeof queries['x-user-key'] !== 'string') {
throw new errors.UnauthorizedError('x-user-key should be specified as a query param.')
}
const userKey = queries['x-user-key']
const _convId = req.path.split('/').splice(1)[1]
if (_convId === undefined) {
throw new errors.InternalError('An unexpected error occurred.')
}
const _userId = props.auth.parseKey(userKey).id
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(_userId),
props.convIdStore.byFid.get(_convId),
])
return {
userId,
conversationId,
}
}
export const handleWebSocketRequest = async (props: api.OperationTools, req: Request) => {
if (!req.body) {
throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.')
}
const { userId, conversationId } = await extractRequestIdentifiers(props, req)
const channels = [conversationId, userId]
const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId })
if (!participant) {
throw new errors.ForbiddenError('You are not a participant in this conversation')
}
for (const message of messages.parse(Buffer.from(req.body))) {
if (message.type === 'open') {
const response = new outputs.ResponseBuilder().open().keepAlive('ping', 30).subscribe(channels).toResponse()
return {
...response,
body: response.body.toString(),
}
}
if (message.type === 'close' || message.type === 'disconnect') {
const code = 'code' in message ? message.code : WS_CLOSE_GOING_AWAY
const response = new outputs.ResponseBuilder().close(code).unsubscribe(channels).toResponse()
return {
...response,
body: response.body.toString(),
}
}
}
throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.')
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"noErrorTruncation": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config