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
+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.')
}