chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}),
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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: {} })
|
||||
}
|
||||
@@ -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]>
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types'
|
||||
export * from './errors'
|
||||
export * from './in-memory-store'
|
||||
export * from './dynamo-db-store'
|
||||
export * from './validate-fid'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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}"`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
@@ -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.')
|
||||
}
|
||||
Reference in New Issue
Block a user