UNPKG

@botpress/webchat-client

Version:
408 lines (362 loc) 13.7 kB
import type * as ClientTypes from '../gen/client' import * as sdk from '../gen/client/sdk.gen' import { client as baseClient } from '../gen/client/client.gen' import { EventSource } from 'eventsource' import type { Signal } from './types/signals' import { throwErrorInterceptor } from './interceptor' import { createEventEmitter, EventEmitter } from './emitter' interface EventSourceLike { close(): void addEventListener(event: string, handler: (event: any) => void): void } type ConversationIdParam = { conversationId: string } type NextTokenParam = { nextToken?: string } type UserIdParam = { userId: string } type MessageIdParam = { messageId: string } type EventIdParam = { eventId: string } const extractData = <T>(res: { data: T }): T => res.data type Event = | Signal | { type: 'error' data: any } | { type: 'open' data: any } type EventMap = { [E in Event as E['type']]: E['data'] } const MAX_RETRIES = 10 const RETRY_INTERVAL = 3000 // ms const TIMEOUT = '60000' // ms type SubscribeFn<T extends Record<string, unknown>> = EventEmitter<T>['on'] export type Subscribable<T extends Record<string, unknown>> = SubscribeFn<T> & { on: SubscribeFn<T> disconnect: () => void cleanup: () => void } const createSubscribable = <T extends Record<string, unknown>>( eventEmitter: EventEmitter<T>, disconnect: () => void ): Subscribable<T> => { const fn = (...args: Parameters<SubscribeFn<T>>) => eventEmitter.on(...args) return new Proxy(fn, { get: (_target, prop) => { if (prop === 'on') return eventEmitter.on if (prop === 'disconnect') return disconnect if (prop === 'cleanup') return eventEmitter.cleanup return Reflect.get(_target, prop) }, }) as Subscribable<T> } export type Client = { createConversation: () => Promise<ClientTypes.CreateConversationResponse> getConversation: (params: ConversationIdParam) => Promise<ClientTypes.GetConversationResponse> deleteConversation: (params: ConversationIdParam) => Promise<ClientTypes.DeleteConversationResponse> listConversations: (params: NextTokenParam) => Promise<ClientTypes.ListConversationsResponse> listConversationMessages: ( params: ConversationIdParam & NextTokenParam ) => Promise<ClientTypes.ListConversationMessagesResponse> addParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.AddParticipantResponse> removeParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.RemoveParticipantResponse> getParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.GetParticipantResponse> listParticipants: (params: ConversationIdParam & NextTokenParam) => Promise<ClientTypes.ListParticipantsResponse> createMessage: (params: ClientTypes.CreateMessageBody) => Promise<ClientTypes.CreateMessageResponse> updateMessage: (params: MessageIdParam & ClientTypes.UpdateMessageBody) => Promise<ClientTypes.UpdateMessageResponse> getMessage: (params: MessageIdParam) => Promise<ClientTypes.GetMessageResponse> deleteMessage: (params: MessageIdParam) => Promise<ClientTypes.DeleteMessageResponse> createFile: (params: ClientTypes.CreateFileBody) => Promise<ClientTypes.CreateFileResponse> getUser: () => Promise<ClientTypes.GetUserResponse> updateUser: (params: ClientTypes.UpdateUserBody) => Promise<ClientTypes.UpdateUserResponse> deleteUser: (params: UserIdParam) => Promise<ClientTypes.DeleteUserResponse> createEvent: (params: ClientTypes.CreateEventBody) => Promise<ClientTypes.CreateEventResponse> getEvent: (params: EventIdParam) => Promise<ClientTypes.GetEventResponse> listenConversation: (params: ConversationIdParam) => Subscribable<EventMap> addMessageFeedback: ( params: MessageIdParam & ClientTypes.AddMessageFeedbackBody ) => Promise<ClientTypes.AddMessageFeedbackResponse> removeMessageFeedback: (params: MessageIdParam) => Promise<ClientTypes.RemoveMessageFeedbackResponse> generateUserKey: ( params: ClientTypes.GenerateUserKeyBody & { adminSecret: string } ) => Promise<ClientTypes.GenerateUserKeyResponse> } const baseConfigs = { throwOnError: true, } as const satisfies sdk.Options export const createUser = async ({ clientId, apiUrl, adminSecret, customHeaders, ...body }: { clientId: string apiUrl?: string adminSecret?: string customHeaders?: Record<string, string> } & ClientTypes.CreateUserBody) => { const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}` // @ts-expect-error - this is a private property if (baseClient.interceptors.response._fns.length === 0) { baseClient.interceptors.response.use(throwErrorInterceptor) } const configs = { ...baseConfigs, baseUrl, } const headers = { ...(adminSecret ? { 'x-admin-secret': adminSecret } : {}), ...customHeaders, } return sdk .createUser({ ...configs, headers: Object.keys(headers).length > 0 ? headers : undefined, body, }) .then(extractData) } export const initialize = ({ conversationId, userToken, clientId, apiUrl, adminSecret, }: { conversationId?: string userToken?: string apiUrl?: string clientId: string adminSecret?: string }) => { let retryCount = 0 let retryTimeout: ReturnType<typeof setTimeout> | null = null let eventSource: EventSourceLike | null = null const eventEmitter = createEventEmitter<EventMap>() let resolvedConversationId = conversationId let resolvedUserToken = userToken const disconnect = () => { if (retryTimeout) { clearTimeout(retryTimeout) retryTimeout = null } if (eventSource) { eventSource.close() eventSource = null } } const connect = () => { if (eventSource) { eventSource.close() } const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}` const url = `${baseUrl}/initialize${resolvedConversationId ? `?conversationId=${resolvedConversationId}` : ''}` const headers = { ...(adminSecret ? { 'x-admin-secret': adminSecret } : {}), ...(resolvedUserToken ? { 'x-user-key': resolvedUserToken } : {}), } eventSource = createNewEventSource(url, headers) eventSource.addEventListener('open', () => eventEmitter.emit('open')) eventSource.addEventListener('error', (err) => { if (retryCount < MAX_RETRIES) { retryCount++ retryTimeout = setTimeout(() => connect(), RETRY_INTERVAL) } else { eventEmitter.emit('error', err) } }) eventSource.addEventListener('message', (ev) => { const event = safeJsonParse(ev.data) as Signal // Save the Conversation ID and User Token from the init event so that internal // retries reconnect as the same user to the same conversation. if (event.type === 'init') { resolvedConversationId = event.data.conversation.id ?? resolvedConversationId resolvedUserToken = event.data.user.userKey ?? resolvedUserToken } eventEmitter.emit(event.type, event.data) }) } connect() return createSubscribable(eventEmitter, disconnect) } export const generateUserKey = async ({ adminSecret, expiresAt, userId, apiUrl, clientId, }: { adminSecret: string expiresAt: number userId: string apiUrl?: string clientId: string }) => { const headers = { 'x-admin-secret': adminSecret } const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}` const configs = { ...baseConfigs, baseUrl, } return sdk.generateUserKey({ ...configs, headers, body: { id: userId, expiresAt } }).then(extractData) } export const sendPerfMetrics = async ( clientId: string, body: ClientTypes.SendPerfMetricsBody, apiUrl?: string, customHeaders?: Record<string, string> ) => { const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}` const configs = { ...baseConfigs, baseUrl, } return sdk.sendPerfMetrics({ ...configs, body, headers: customHeaders }).then(extractData) } export const createClient = ({ userKey, clientId, apiUrl, customHeaders, advancedOptions, }: { userKey: string clientId: string apiUrl?: string customHeaders?: Record<string, string> advancedOptions?: { eventSource?: (params: { url: string; headers: Record<string, string> }) => EventSourceLike } }): Client => { const headers = { 'x-user-key': userKey, ...customHeaders, } const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}` const configs = { ...baseConfigs, baseUrl, } // @ts-expect-error - this is a private property if (baseClient.interceptors.response._fns.length === 0) { baseClient.interceptors.response.use(throwErrorInterceptor) } const client: Client = { createConversation: () => sdk.createConversation({ ...configs, headers, body: {} }).then(extractData), getConversation: ({ conversationId }) => sdk.getConversation({ ...configs, headers, path: { id: conversationId } }).then(extractData), deleteConversation: ({ conversationId }) => sdk.deleteConversation({ ...configs, headers, path: { id: conversationId } }).then(extractData), listConversations: ({ nextToken }) => sdk.listConversations({ ...configs, headers, query: { nextToken } }).then(extractData), listConversationMessages: ({ conversationId, nextToken }) => sdk .listConversationMessages({ ...configs, headers, path: { id: conversationId }, query: { nextToken }, }) .then(extractData), addParticipant: ({ conversationId, userId }) => sdk.addParticipant({ ...configs, headers, path: { id: conversationId }, body: { userId } }).then(extractData), removeParticipant: ({ conversationId, userId }) => sdk.removeParticipant({ ...configs, headers, path: { id: conversationId, userId } }).then(extractData), getParticipant: ({ conversationId, userId }) => sdk.getParticipant({ ...configs, headers, path: { id: conversationId, userId } }).then(extractData), listParticipants: ({ conversationId, nextToken }) => sdk .listParticipants({ ...configs, headers, path: { id: conversationId }, query: { nextToken } }) .then(extractData), createMessage: (body) => sdk.createMessage({ ...configs, headers, body }).then(extractData), updateMessage: ({ messageId, ...body }) => sdk.updateMessage({ ...configs, headers, path: { id: messageId }, body }).then(extractData), getMessage: ({ messageId }) => sdk.getMessage({ ...configs, headers, path: { id: messageId } }).then(extractData), deleteMessage: ({ messageId }) => sdk.deleteMessage({ ...configs, headers, path: { id: messageId } }).then(extractData), createFile: (body) => sdk.createFile({ ...configs, headers, body }).then(extractData), getUser: () => sdk.getUser({ ...configs, headers }).then(extractData), updateUser: (body) => sdk.updateUser({ ...configs, headers, body }).then(extractData), deleteUser: () => sdk.deleteUser({ ...configs, headers }).then(extractData), createEvent: (body) => sdk.createEvent({ ...configs, headers, body }).then(extractData), getEvent: ({ eventId }) => sdk.getEvent({ ...configs, headers, path: { id: eventId } }).then(extractData), addMessageFeedback: ({ messageId, ...body }) => sdk.addMessageFeedback({ ...configs, headers, path: { id: messageId }, body }).then(extractData), removeMessageFeedback: ({ messageId }) => sdk.removeMessageFeedback({ ...configs, headers, path: { id: messageId } }).then(extractData), generateUserKey: ({ adminSecret, ...body }) => sdk.generateUserKey({ ...configs, headers: { 'x-admin-secret': adminSecret }, body }).then(extractData), listenConversation: ({ conversationId }) => { let retryCount = 0 let retryTimeout: ReturnType<typeof setTimeout> | null = null let eventSource: EventSourceLike | null = null const eventEmitter = createEventEmitter<EventMap>() const disconnect = () => { if (retryTimeout) { clearTimeout(retryTimeout) retryTimeout = null } if (eventSource) { eventSource.close() eventSource = null } } const connect = () => { if (eventSource) { eventSource.close() } const url = `${baseUrl}/conversations/${conversationId}/listen` const providedEventSource = advancedOptions?.eventSource eventSource = providedEventSource ? providedEventSource({ url, headers }) : createNewEventSource(url, headers) eventSource.addEventListener('open', () => eventEmitter.emit('open')) eventSource.addEventListener('error', (err) => { if (retryCount < MAX_RETRIES) { retryCount++ retryTimeout = setTimeout(() => connect(), RETRY_INTERVAL) } else { eventEmitter.emit('error', err) } }) eventSource.addEventListener('message', (ev) => { const event = safeJsonParse(ev.data) as Signal eventEmitter.emit(event.type, event.data) }) } connect() return createSubscribable(eventEmitter, disconnect) }, } return client } const safeJsonParse = (data: any) => { try { return JSON.parse(data) } catch { return data } } const createNewEventSource = (url: string, headers: Record<string, string>): EventSourceLike => { return new EventSource(url, { fetch: (input, init) => fetch(input, { ...init, headers: { ...init?.headers, ...headers, timeout: TIMEOUT, }, }), }) }