chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
src/gen/
+8
View File
@@ -0,0 +1,8 @@
node_modules
src/
tsconfig.json
build.ts
openapi.ts
readme.md
*.tsbuildinfo
.ignore.me.*
+48
View File
@@ -0,0 +1,48 @@
import esbuild from 'esbuild'
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
import { dependencies } from './package.json'
const external = Object.keys(dependencies)
const entryPoint = './src/index.ts'
const buildNode = async () =>
esbuild.build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
format: 'cjs',
external,
outfile: './dist/index.cjs',
sourcemap: true,
})
const buildBrowser = async () =>
esbuild.build({
entryPoints: [entryPoint],
bundle: true,
platform: 'browser',
format: 'esm',
outfile: './dist/index.mjs',
sourcemap: true,
plugins: [polyfillNode({ polyfills: { crypto: true } })],
})
const main = async (argv: string[]) => {
if (argv.includes('--node')) {
return buildNode()
}
if (argv.includes('--browser')) {
return buildBrowser()
}
throw new Error('Please specify --node, --browser, or --bundle')
}
void main(process.argv.slice(2))
.then(() => {
console.info('Done')
process.exit(0)
})
.catch((error) => {
console.error(error)
process.exit(1)
})
+20
View File
@@ -0,0 +1,20 @@
const _configKeys = ['API_URL', 'ENCRYPTION_KEY'] as const
const values: Partial<Record<ConfigKey, string>> = {}
export type ConfigKey = (typeof _configKeys)[number]
export const get = (key: ConfigKey): string => {
const cached = values[key]
if (cached) {
return cached
}
const value = process.env[key]
if (!value) {
throw new Error(`${key} not set`)
}
values[key] = value
console.info(`config: ${key}=${value}`)
return value
}
@@ -0,0 +1,81 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as utils from './utils'
import * as config from './config'
import * as chat from '../src'
const apiUrl = config.get('API_URL')
test('api prevents creating conversation with an invalid fid', async () => {
const client = await chat.Client.connect({ apiUrl })
const convFid = utils.getConversationFid()
const invalidUserIds = [
`invalid+${convFid}`, // invalid character
`invalid_${convFid}_${convFid}_${convFid}`, // too long
]
for (const id of invalidUserIds) {
const promise = client.createConversation({ id })
await expect(promise).rejects.toThrow(chat.InvalidPayloadError)
}
for (const id of invalidUserIds) {
const promise = client.getOrCreateConversation({ id })
await expect(promise).rejects.toThrow(chat.InvalidPayloadError)
}
})
test('api prevents creating multiple conversations with the same foreign id', async () => {
const client = await chat.Client.connect({ apiUrl })
const conversationFid = utils.getConversationFid()
const createConversation = () => client.createConversation({ id: conversationFid })
await createConversation()
await expect(createConversation).rejects.toThrow(chat.AlreadyExistsError)
})
test('get or create a conversation always returns the same conversation', async () => {
const client = await chat.Client.connect({ apiUrl })
const {
conversation: { id: conversationId },
} = await client.createConversation({})
const getOrCreate = () => client.getConversation({ id: conversationId }).then((r) => r.conversation.id)
expect(await getOrCreate()).toBe(conversationId)
expect(await getOrCreate()).toBe(conversationId) // operation is idempotent
})
test('get or create a conversation with a fid always returns the same conversation', async () => {
const client = await chat.Client.connect({ apiUrl })
const conversationFid = utils.getConversationFid()
await client.createConversation({ id: conversationFid })
const getOrCreate = () => client.getConversation({ id: conversationFid }).then((r) => r.conversation.id)
expect(await getOrCreate()).toBe(conversationFid)
expect(await getOrCreate()).toBe(conversationFid) // operation is idempotent
})
test('get conversation is only allowed for participants', async () => {
const client = new chat.Client({ apiUrl })
const user1 = await client.createUser({})
const user2 = await client.createUser({})
const user3 = await client.createUser({})
const { conversation } = await client.createConversation({ 'x-user-key': user1.key })
await client.addParticipant({ conversationId: conversation.id, 'x-user-key': user1.key, userId: user2.user.id })
const promise1 = client.getConversation({ id: conversation.id, 'x-user-key': user1.key })
await expect(promise1).resolves.toBeTruthy()
const promise2 = client.getConversation({ id: conversation.id, 'x-user-key': user2.key })
await expect(promise2).resolves.toBeTruthy()
const promise3 = client.getConversation({ id: conversation.id, 'x-user-key': user3.key })
await expect(promise3).rejects.toThrow(chat.ForbiddenError)
})
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as config from './config'
import * as chat from '../src'
const apiUrl = config.get('API_URL')
test('api allows sending and receiving custom events', async () => {
const client = await chat.Client.connect({ apiUrl, debug: true })
const {
conversation: { id: conversationId },
} = await client.createConversation({})
const listener = await client.listenConversation({
id: conversationId,
})
const waitForEventPromise = new Promise<chat.Signals['event_created']>((resolve) => {
listener.onceOrMore('event_created', (ev) => {
if (ev.userId === client.user.id) {
return 'keep-listening'
}
resolve(ev)
return 'stop-listening'
})
})
const createEventRequest: chat.AuthenticatedClientRequests['createEvent'] = {
conversationId: conversationId,
payload: {
foo: 'bar',
},
}
const createEventPromise = client.createEvent(createEventRequest).then((res) => res.event)
const [eventReceived, eventSent] = await Promise.all([waitForEventPromise, createEventPromise])
const { event: eventFetched } = await client.getEvent({
id: eventSent.id,
})
expect(eventFetched).toEqual(eventSent)
expect(eventReceived.payload).toEqual(eventSent.payload) // hello bot just passes the payload through
})
+265
View File
@@ -0,0 +1,265 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as utils from './utils'
import * as config from './config'
import * as chat from '../src'
const apiUrl = config.get('API_URL')
const encryptionKey = config.get('ENCRYPTION_KEY')
const serverEventsProtocols = ['sse', 'websocket'] as const
type CheckApiCanSendAndReceiveMessagesProps = {
client: chat.AuthenticatedClient
conversationId: string
protocol: chat.ServerEventsProtocol
}
type MessagePayload = chat.AuthenticatedClientRequests['createMessage']['payload']
const checkApiCanSendAndReceiveMessages = async (
props: CheckApiCanSendAndReceiveMessagesProps,
payload: MessagePayload
): Promise<MessagePayload> => {
const { client, conversationId } = props
const listener = await client.listenConversation({
id: conversationId,
protocol: props.protocol,
})
const waitForResponsePromise = new Promise<chat.Signals['message_created']>((resolve) => {
listener.onceOrMore('message_created', (ev) => {
if (ev.userId === client.user.id) {
return 'keep-listening'
}
resolve(ev)
return 'stop-listening'
})
})
const createMessageRequest: chat.AuthenticatedClientRequests['createMessage'] = {
conversationId: conversationId,
payload,
}
const createMessagePromise = client.createMessage(createMessageRequest).then((res) => res.message)
const [{ isBot, ...messageReceived }, messageSent] = await Promise.all([waitForResponsePromise, createMessagePromise])
const { messages } = await client
.listMessages({
conversationId,
})
.then(({ messages }) => ({
messages: _.sortBy(messages, (m) => new Date(m.createdAt).getTime()),
}))
expect(messages.length).toBe(2)
expect(messages[0]).toEqual(messageSent)
expect(messages[1]).toEqual(messageReceived)
return messageReceived.payload
}
test.each(serverEventsProtocols)('api allows sending and receiving messages using botpress IDs', async (protocol) => {
const client = await chat.Client.connect({ apiUrl })
const {
conversation: { id: conversationId },
} = await client.createConversation({})
await checkApiCanSendAndReceiveMessages(
{
client,
conversationId,
protocol,
},
{
type: 'text',
text: 'hello world',
}
)
})
test.each(serverEventsProtocols)('api allows sending and receiving messages using foreign IDs', async (protocol) => {
const userId = utils.getUserFid()
const conversationId = utils.getConversationFid()
const client = await chat.Client.connect({ apiUrl, userId })
await client.createConversation({ id: conversationId })
await checkApiCanSendAndReceiveMessages(
{
client,
conversationId,
protocol,
},
{
type: 'text',
text: 'hello world',
}
)
})
test.each(serverEventsProtocols)(
'api allows sending and receiving messages using remotly generated JWTs',
async (protocol) => {
const userId = utils.getUserFid()
const conversationId = utils.getConversationFid()
const client = await chat.Client.connect({ apiUrl, userId, encryptionKey })
await client.getOrCreateConversation({ id: conversationId })
await checkApiCanSendAndReceiveMessages(
{
client,
conversationId,
protocol,
},
{
type: 'text',
text: 'hello world',
}
)
}
)
test.each(serverEventsProtocols)('api allows deleting a message', async (protocol) => {
const client = await chat.Client.connect({ apiUrl })
const { conversation } = await client.createConversation({})
const signalListener = await client.listenConversation({ id: conversation.id, protocol })
const [{ isBot, ...createdMessage }] = await Promise.all([
utils.waitFor(signalListener, 'message_created'),
client.createMessage({
conversationId: conversation.id,
payload: {
type: 'text',
text: 'hello world',
},
}),
])
const { message: fetchedMessage } = await client.getMessage({
id: createdMessage.id,
})
expect(fetchedMessage).toEqual(createdMessage)
const [deletedMessage] = await Promise.all([
utils.waitFor(signalListener, 'message_deleted'),
client.deleteMessage({
id: createdMessage.id,
}),
])
expect(deletedMessage.id).toEqual(createdMessage.id)
await expect(
client.getMessage({
id: createdMessage.id,
})
).rejects.toThrow(chat.ResourceNotFoundError)
})
test.each(serverEventsProtocols)('api allows sending and receiving messages with metadata', async (protocol) => {
type Message = Awaited<ReturnType<chat.Client['listMessages']>>['messages'][number]
const metadata = { foo: 'bar' }
const client = await chat.Client.connect({ apiUrl })
const { conversation } = await client.createConversation({})
const { id: conversationId } = conversation
const sendMessagePromise = client.createMessage({
conversationId,
payload: { type: 'text', text: 'metadata' }, // hello-bot forwards metadata when message is 'metadata'
metadata,
})
const listener = await client.listenConversation({ id: conversationId, protocol })
const receiveSelfMessagePromise = new Promise<Message>((resolve) =>
listener.on('message_created', (m) => {
if (m.userId === client.user.id) {
resolve(m)
}
})
)
const receiveBotMessagePromise = new Promise<Message>((resolve) =>
listener.on('message_created', (m) => {
if (m.userId !== client.user.id) {
resolve(m)
}
})
)
const [sentMessage, receivedSelfMessage, receivedBotMessage] = await Promise.all([
sendMessagePromise,
receiveSelfMessagePromise,
receiveBotMessagePromise,
])
expect(sentMessage.message.metadata).toEqual(metadata)
expect(receivedSelfMessage.metadata).toEqual(metadata)
expect(receivedBotMessage.metadata).toEqual(metadata)
expect(sentMessage.message.payload).not.toHaveProperty('metadata')
expect(receivedSelfMessage.payload).not.toHaveProperty('metadata')
expect(receivedBotMessage.payload).not.toHaveProperty('metadata')
const fetchedMessages = await client.listMessages({ conversationId }).then((r) => r.messages)
const fetchedSelfMessages = fetchedMessages.filter((m) => m.userId === client.user.id)
expect(fetchedSelfMessages.length).toBe(1)
const [fetchedSelfMessage] = fetchedSelfMessages
expect(fetchedSelfMessage!.metadata).toEqual(metadata)
})
test.each(serverEventsProtocols)('api allows sending bloc messages', async (protocol) => {
const client = await chat.Client.connect({ apiUrl })
const {
conversation: { id: conversationId },
} = await client.createConversation({})
await checkApiCanSendAndReceiveMessages(
{
client,
conversationId,
protocol,
},
{
type: 'bloc',
items: [
{
type: 'text',
text: 'hello world',
},
{
type: 'image',
imageUrl: 'https://fastly.picsum.photos/id/329/200/300.jpg?hmac=_yLyj0EqdpQ-cX84OlMxz3YzOjjd7liq6b25ldkVSpA',
},
],
}
)
})
test.each(serverEventsProtocols)('api allows receiving bloc messages from bot', async (protocol) => {
const client = await chat.Client.connect({ apiUrl })
const {
conversation: { id: conversationId },
} = await client.createConversation({})
const responsePayload: MessagePayload = await checkApiCanSendAndReceiveMessages(
{ client, conversationId, protocol },
{ type: 'text', text: 'bloc' }
)
expect(responsePayload.type).toEqual('bloc')
expect(responsePayload.items.length).toEqual(3)
})
@@ -0,0 +1,161 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as utils from './utils'
import * as config from './config'
import * as chat from '../src'
const apiUrl = config.get('API_URL')
const protocols = ['sse', 'websocket'] as const
test.each(protocols)('api allows adding and removing conversation participants', async (protocol) => {
const client = new chat.Client({ apiUrl })
const { key: userKey1 } = await client.createUser({})
const { user: user2, key: userKey2 } = await client.createUser({})
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
const listener = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol })
await expect(
client.createMessage({
'x-user-key': userKey2,
conversationId: conversation.id,
payload: {
type: 'text',
text: 'hello world',
},
})
).rejects.toThrow(chat.ForbiddenError)
await Promise.all([
utils.waitFor(listener, 'participant_added'),
client.addParticipant({
'x-user-key': userKey1,
conversationId: conversation.id,
userId: user2.id,
}),
])
await expect(
client.createMessage({
'x-user-key': userKey2,
conversationId: conversation.id,
payload: {
type: 'text',
text: 'hello world',
},
})
).resolves.toBeTruthy()
await Promise.all([
utils.waitFor(listener, 'participant_removed'),
client.removeParticipant({
'x-user-key': userKey1,
conversationId: conversation.id,
userId: user2.id,
}),
])
await expect(
client.createMessage({
'x-user-key': userKey2,
conversationId: conversation.id,
payload: {
type: 'text',
text: 'hello world',
},
})
).rejects.toThrow(chat.ForbiddenError)
})
test('api forbids non-participants from listing participants', async () => {
const client = new chat.Client({ apiUrl })
const user1 = await client.createUser({})
const user2 = await client.createUser({})
const user3 = await client.createUser({})
const { conversation } = await client.createConversation({ 'x-user-key': user1.key })
await client.addParticipant({ 'x-user-key': user1.key, conversationId: conversation.id, userId: user2.user.id })
const promise1 = client.listParticipants({ 'x-user-key': user1.key, conversationId: conversation.id })
await expect(promise1).resolves.toBeTruthy()
const promise2 = client.listParticipants({ 'x-user-key': user2.key, conversationId: conversation.id })
await expect(promise2).resolves.toBeTruthy()
const promise3 = client.listParticipants({ 'x-user-key': user3.key, conversationId: conversation.id })
await expect(promise3).rejects.toThrow(chat.ForbiddenError)
})
test.each(protocols)('signal listener is disconnected when participant is removed', async (protocol) => {
const client = new chat.Client({ apiUrl })
const { user: user1, key: userKey1 } = await client.createUser({})
const { user: user2, key: userKey2 } = await client.createUser({})
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
await client.addParticipant({ 'x-user-key': userKey1, conversationId: conversation.id, userId: user2.id })
const listener1 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey1, protocol })
const listener2 = await client.listenConversation({ id: conversation.id, 'x-user-key': userKey2, protocol })
const messages1: chat.Signals['message_created'][] = []
const messages2: chat.Signals['message_created'][] = []
listener1.on('message_created', (message) => messages1.push(message))
listener2.on('message_created', (message) => messages2.push(message))
await Promise.all([
utils.waitFor(listener1, 'message_created'),
client.createMessage({
'x-user-key': userKey1,
conversationId: conversation.id,
payload: {
type: 'text',
text: 'foo',
},
}),
])
await client.removeParticipant({ 'x-user-key': userKey1, conversationId: conversation.id, userId: user2.id })
await Promise.all([
utils.waitFor(listener1, 'message_created'),
client.createMessage({
'x-user-key': userKey1,
conversationId: conversation.id,
payload: {
type: 'text',
text: 'bar',
},
}),
])
const incomingMessages1 = messages1.filter((m) => !m.isBot)
const incomingMessages2 = messages2.filter((m) => !m.isBot)
expect(incomingMessages1.length).toBe(2)
expect(incomingMessages2.length).toBe(1)
await listener1.disconnect()
listener1.cleanup()
await listener2.disconnect()
listener2.cleanup()
})
test('api forbids removing owner from conversation participants', async () => {
const client = new chat.Client({ apiUrl })
const { user: user1, key: userKey1 } = await client.createUser({})
const { conversation } = await client.createConversation({ 'x-user-key': userKey1 })
await expect(
client.removeParticipant({
'x-user-key': userKey1,
conversationId: conversation.id,
userId: user1.id,
})
).rejects.toThrow(chat.InvalidPayloadError)
})
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as utils from './utils'
import * as config from './config'
import * as chat from '../src'
const apiUrl = config.get('API_URL')
test('api prevents creating user with an invalid fid', async () => {
const client = new chat.Client({ apiUrl })
const userFid = utils.getUserFid()
const invalidUserIds = [
`invalid+${userFid}`, // invalid character
`invalid_${userFid}_${userFid}_${userFid}_${userFid}`, // too long
]
for (const id of invalidUserIds) {
await expect(client.createUser({ id })).rejects.toThrow(chat.InvalidPayloadError)
}
for (const id of invalidUserIds) {
const key = await utils.getUserKey(id)
await expect(client.getOrCreateUser({ 'x-user-key': key })).rejects.toThrow(chat.InvalidPayloadError)
}
})
test('api prevents creating multiple users with the same foreign id', async () => {
const client = new chat.Client({ apiUrl })
const userFid = utils.getUserFid()
const createUser = () =>
client.createUser({
id: userFid,
})
await createUser()
await expect(createUser).rejects.toThrow(chat.AlreadyExistsError)
})
test('get or create user should create a user', async () => {
const client = new chat.Client({ apiUrl })
const userFid = utils.getUserFid()
const userKey = await utils.getUserKey(userFid)
await client.getOrCreateUser({ 'x-user-key': userKey })
const {
user: { id: userId },
} = await client.getOrCreateUser({ 'x-user-key': userKey })
expect(userId).toBeDefined()
})
test('get or create user always returns the same user', async () => {
const client = new chat.Client({ apiUrl })
const { key: userKey } = await client.createUser({})
const {
user: { id: userId },
} = await client.getUser({
'x-user-key': userKey,
})
const getOrCreate = () =>
client
.getOrCreateUser({
'x-user-key': userKey,
})
.then((r) => r.user.id)
expect(await getOrCreate()).toBe(userId)
expect(await getOrCreate()).toBe(userId) // operation is idempotent
})
test('get or create user with a fid always returns the same user', async () => {
const client = new chat.Client({ apiUrl })
const userFid = utils.getUserFid()
const userKey = await utils.getUserKey(userFid)
await client.createUser({ id: userFid })
const {
user: { id: userId },
} = await client.getUser({
'x-user-key': userKey,
})
const getOrCreate = () =>
client
.getOrCreateUser({
'x-user-key': userKey,
})
.then((r) => r.user.id)
expect(await getOrCreate()).toBe(userId)
expect(await getOrCreate()).toBe(userId) // operation is idempotent
})
+23
View File
@@ -0,0 +1,23 @@
import * as uuid from 'uuid'
import * as chat from '../src'
import { signJwt } from '../src/jwt'
import * as config from './config'
const encryptionKey = config.get('ENCRYPTION_KEY')
export const halfId = () => {
const id = uuid.v4()
return id.slice(0, id.length / 2)
}
export const getUserFid = (): string => `test-user-${halfId()}`
export const getConversationFid = (): string => `test-conversation-${halfId()}`
export const getUserKey = (userId: string): Promise<string> => signJwt({ id: userId }, encryptionKey)
export const waitFor = <S extends keyof chat.Signals>(
listener: chat.SignalListener,
signal: S
): Promise<chat.Signals[S]> =>
new Promise((resolve, reject) => {
listener.once(signal, resolve)
listener.once('error', reject)
})
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from 'vitest'
import _ from 'lodash'
import * as config from './config'
import * as chat from '../src'
const invalidApiUrl = config.get('API_URL') + '1234'
test('client throws if webhook id is incorrect', async () => {
const client = new chat.Client({ apiUrl: invalidApiUrl })
const userPromise = client.createUser({ id: 'invalid' })
expect(userPromise).rejects.toThrow(chat.ChatClientError)
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+22
View File
@@ -0,0 +1,22 @@
import { api, signals } from '@botpress/chat-api'
import pathlib from 'path'
const main = async (argv: string[]) => {
const outDir = argv[0]
if (!outDir) {
throw new Error('Missing output directory')
}
const clientDir = pathlib.join(outDir, 'client')
const signalsDir = pathlib.join(outDir, 'signals')
await api.exportClient(clientDir, { generator: 'opapi' })
await signals.exportSchemas(signalsDir)
}
void main(process.argv.slice(2))
.then(() => {
process.exit(0)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@botpress/chat",
"version": "1.0.0",
"description": "Botpress Chat API Client",
"license": "MIT",
"repository": {
"url": "https://github.com/botpress/botpress"
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"check:type": "tsc --noEmit",
"generate": "ts-node -T ./openapi.ts ./src/gen",
"build:type": "tsc -p ./tsconfig.build.json",
"build:browser": "ts-node -T ./build.ts --browser",
"build:node": "ts-node -T ./build.ts --node",
"build": "pnpm build:type && pnpm build:node && pnpm build:browser",
"test:e2e": "vitest run --config vitest.config.ts"
},
"dependencies": {
"axios": "1.2.5",
"browser-or-node": "^2.1.1",
"event-source-polyfill": "^1.0.31",
"eventsource": "^2.0.2",
"jose": "^6.1.3",
"qs": "^6.11.0",
"verror": "^1.10.1",
"zod": "^3.21.4"
},
"devDependencies": {
"@botpress/chat-api": "workspace:*",
"@types/event-source-polyfill": "^1.0.2",
"@types/eventsource": "^1.1.12",
"@types/json-schema": "^7.0.12",
"@types/lodash": "^4.14.191",
"@types/qs": "^6.9.7",
"@types/uuid": "^9.0.1",
"@types/verror": "^1.10.6",
"@types/web": "^0.0.115",
"@types/ws": "^8.5.10",
"dotenv": "^16.4.4",
"esbuild": "^0.25.10",
"esbuild-plugin-polyfill-node": "^0.3.0",
"lodash": "^4.17.21",
"uuid": "^9.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23.0.0"
},
"packageManager": "pnpm@10.29.3"
}
+122
View File
@@ -0,0 +1,122 @@
# Chat Client
## Installation
```bash
npm install @botpress/chat # for npm
yarn add @botpress/chat # for yarn
pnpm add @botpress/chat # for pnpm
```
## Usage
### API Queries
The `@botpress/chat` package exports a `Client` class that can be used to interact with the Botpress Chat API.
```ts
import _ from 'lodash'
import * as chat from '@botpress/chat'
const main = async () => {
/**
* You can find your webhook id in the the Botpress Dashboard.
* Navigate to your bot's Chat Integration configuration. Look for:
* https://webhook.botpress.cloud/$YOUR_WEBHOOK_ID
*/
const webhookId = process.env.WEBHOOK_ID
if (!webhookId) {
throw new Error('WEBHOOK_ID is required')
}
// 0. connect and create a user
const client = await chat.Client.connect({ webhookId })
// 1. create a conversation
const { conversation } = await client.createConversation({})
// 2. send a message
await client.createMessage({
conversationId: conversation.id,
payload: {
type: 'text',
text: 'hello world',
},
})
// 3. sleep for a bit
await new Promise((resolve) => setTimeout(resolve, 2000))
// 4. list messages
const { messages } = await client
.listMessages({
conversationId: conversation.id,
})
.then(({ messages }) => ({
messages: _.sortBy(messages, (m) => new Date(m.createdAt).getTime()),
}))
const botResponse = messages[1]
console.log("Bot's response:", botResponse.payload)
}
void main()
.then(() => {
console.log('done')
process.exit(0)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
```
### Realtime Events
You can also listen for messages and events in real-time.
```ts
// ...
const listener = await client.listenConversation({
id: conversation.id,
})
const botResponse = await new Promise<chat.Message>((resolve) => {
const onMessage = (ev: chat.Signals['message_created']) => {
if (ev.userId === client.user.id) {
// message created by my current user, ignoring...
return
}
listener.off('message_created', onMessage)
resolve(ev)
}
listener.on('message_created', onMessage)
})
console.log("Bot's response:", botResponse.payload)
```
### Reconnection
The `Client` class does not automatically reconnect to the server if the connection is lost. This allow's you to handle reconnection in a way that makes sense for your application. To be notified when the connection is lost, you can listen for the `error` event:
```ts
const state = { messages } // your application state
const onDisconnection = async () => {
try {
await listener.connect()
const { messages } = await client.listMessages({ conversationId: conversation.id })
state.messages = messages
} catch (thrown) {
console.error('failed to reconnect, retrying...', thrown)
setTimeout(onDisconnection, 1000) // consider using a backoff strategy
}
}
listener.on('error', (err) => {
console.error('connection lost', err)
void onDisconnection()
})
```
+293
View File
@@ -0,0 +1,293 @@
import axios from 'axios'
import { isBrowser } from 'browser-or-node'
import * as consts from './consts'
import * as errors from './errors'
import { ServerEventsProtocol } from './eventsource'
import { apiVersion, Client as AutoGeneratedClient } from './gen/client'
import { signJwt } from './jwt'
import { AsyncCollection } from './listing'
import { SignalListener } from './signal-listener'
import * as types from './types'
const _100mb = 100 * 1024 * 1024
const maxBodyLength = _100mb
const maxContentLength = _100mb
const defaultTimeout = 60_000
const _createAuthClient = Symbol('_createAuthClient')
type Merge<A, B> = Omit<A, keyof B> & B
type IClient = Merge<
{
[K in types.ClientOperation]: (x: types.ClientRequests[K]) => Promise<types.ClientResponses[K]>
},
{
listenConversation: (
args: types.ClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
) => Promise<SignalListener>
}
>
type IAuthenticatedClient = Merge<
{
[K in types.AuthenticatedOperation]: (x: types.AuthenticatedClientRequests[K]) => Promise<types.ClientResponses[K]>
},
{
listenConversation: (
args: types.AuthenticatedClientRequests['listenConversation'] & { protocol?: ServerEventsProtocol }
) => Promise<SignalListener>
}
>
export class Client implements IClient {
private _connectionTested = false
private _auto: AutoGeneratedClient
public constructor(public readonly props: Readonly<types.ClientProps>) {
const axiosClient = Client._createAxios(props)
this._auto = new AutoGeneratedClient(axiosClient)
}
public get apiVersion() {
return apiVersion
}
/**
* Gets or creates a user based on the provided props and returns an authenticated client.
*/
public static async connect(props: types.ConnectProps): Promise<AuthenticatedClient> {
const { userId, userKey, encryptionKey, ...clientProps } = props
const client = new Client(clientProps)
await client._testConnection()
if (userKey) {
const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
}
if (encryptionKey) {
if (!userId) {
throw new errors.ChatConfigError(
'userId is required when connecting with an encryption key. You may pick any userId of your choice that is not already taken by another user.'
)
}
const userKey = await signJwt({ id: userId }, encryptionKey)
const { user } = await client.getOrCreateUser({ 'x-user-key': userKey })
return AuthenticatedClient[_createAuthClient](client, { ...user, key: userKey })
}
const { user, key } = await client.createUser({ id: userId })
return AuthenticatedClient[_createAuthClient](client, { ...user, key })
}
public readonly createConversation: IClient['createConversation'] = (x) => this._call('createConversation', x)
public readonly getConversation: IClient['getConversation'] = (x) => this._call('getConversation', x)
public readonly getOrCreateConversation: IClient['getOrCreateConversation'] = (x) =>
this._call('getOrCreateConversation', x)
public readonly deleteConversation: IClient['deleteConversation'] = (x) => this._call('deleteConversation', x)
public readonly listConversations: IClient['listConversations'] = (x) => this._call('listConversations', x)
public readonly listMessages: IClient['listMessages'] = (x) => this._call('listMessages', x)
public readonly addParticipant: IClient['addParticipant'] = (x) => this._call('addParticipant', x)
public readonly removeParticipant: IClient['removeParticipant'] = (x) => this._call('removeParticipant', x)
public readonly getParticipant: IClient['getParticipant'] = (x) => this._call('getParticipant', x)
public readonly listParticipants: IClient['listParticipants'] = (x) => this._call('listParticipants', x)
public readonly createMessage: IClient['createMessage'] = (x) => this._call('createMessage', x)
public readonly getMessage: IClient['getMessage'] = (x) => this._call('getMessage', x)
public readonly deleteMessage: IClient['deleteMessage'] = (x) => this._call('deleteMessage', x)
public readonly createUser: IClient['createUser'] = (x) => this._call('createUser', x)
public readonly getUser: IClient['getUser'] = (x) => this._call('getUser', x)
public readonly getOrCreateUser: IClient['getOrCreateUser'] = (x) => this._call('getOrCreateUser', x)
public readonly updateUser: IClient['updateUser'] = (x) => this._call('updateUser', x)
public readonly deleteUser: IClient['deleteUser'] = (x) => this._call('deleteUser', x)
public readonly createEvent: IClient['createEvent'] = (x) => this._call('createEvent', x)
public readonly getEvent: IClient['getEvent'] = (x) => this._call('getEvent', x)
public get list() {
return {
conversations: (props: types.ClientRequests['listConversations']) =>
new AsyncCollection(({ nextToken }) =>
this.listConversations({ nextToken, ...props }).then((r) => ({ ...r, items: r.conversations }))
),
messages: (props: types.ClientRequests['listMessages']) =>
new AsyncCollection(({ nextToken }) =>
this.listMessages({ nextToken, ...props }).then((r) => ({ ...r, items: r.messages }))
),
participants: (props: types.ClientRequests['listParticipants']) =>
new AsyncCollection(({ nextToken }) =>
this.listParticipants({ nextToken, ...props }).then((r) => ({ ...r, items: r.participants }))
),
}
}
public readonly listenConversation: IClient['listenConversation'] = async ({
id,
'x-user-key': userKey,
protocol,
}) => {
const signalListener = await SignalListener.listen({
url: this._apiUrl,
conversationId: id,
userKey,
debug: this.props.debug ?? false,
protocol: protocol ?? 'sse',
})
return signalListener
}
private _call = async (operation: types.ClientOperation, args: any): Promise<any> => {
try {
await this._testConnection()
const response = await this._auto[operation](args)
const res = this._checkPayloadForError(response)
return res
} catch (thrown) {
if (errors.isApiError(thrown)) {
throw thrown
}
throw errors.ChatClientError.map(thrown)
}
}
/**
* The Chat-API is called like any other integrations by sending requests to the bridge webhook endpoint.
* This endpoint may return a successful status code even when the payload contains an error.
* This method parses the payload to check for an error and throws an error if one is found.
*/
private _checkPayloadForError = <T>(response: unknown): T => {
if (typeof response !== 'object' || response === null) {
return response as T
}
if (!('code' in response)) {
return response as T
}
const { code } = response
if (typeof code !== 'number') {
return response as T
}
if (code < 400 || code >= 600) {
return response as T
}
const message = 'message' in response ? String(response['message']) : 'An error occurred'
throw new errors.ChatHTTPError(code, message)
}
private static _createAxios = (props: types.ClientProps) => {
const headers: types.Headers = {
...props.headers,
}
const timeout = props.timeout ?? defaultTimeout
const withCredentials = isBrowser
const baseURL = this._getApiUrl(props)
return axios.create({
baseURL,
headers,
withCredentials,
timeout,
maxBodyLength,
maxContentLength,
validateStatus: (status) => status >= 200 && status < 400,
})
}
private get _apiUrl() {
return Client._getApiUrl(this.props)
}
private static _getApiUrl = (props: types.ClientProps) => {
if ('apiUrl' in props) {
return props.apiUrl
}
const baseApiUrl = props.baseApiUrl ?? consts.defaultBaseApiUrl
const { webhookId } = props
return `${baseApiUrl}/${webhookId}`
}
private _testConnection = async () => {
if (this._connectionTested) {
return
}
const url = `${this._apiUrl}/hello`
const axiosInstance = axios.create({ baseURL: url })
try {
const response = await axiosInstance.get('/')
this._checkPayloadForError(response.data)
} catch (thrown) {
throw errors.ChatClientError.wrap(thrown, `Failed to connect to url "${this._apiUrl}"`)
}
this._connectionTested = true
}
}
export class AuthenticatedClient implements IAuthenticatedClient {
private constructor(
private _client: Client,
public readonly user: types.AuthenticatedUser
) {}
// can not be instantiated outside of this module
public static [_createAuthClient] = (client: Client, user: types.AuthenticatedUser) => {
return new AuthenticatedClient(client, user)
}
public get apiVersion() {
return this._client.apiVersion
}
public readonly createConversation: IAuthenticatedClient['createConversation'] = (x) =>
this._client.createConversation({ 'x-user-key': this.user.key, ...x })
public readonly getConversation: IAuthenticatedClient['getConversation'] = (x) =>
this._client.getConversation({ 'x-user-key': this.user.key, ...x })
public readonly getOrCreateConversation: IAuthenticatedClient['getOrCreateConversation'] = (x) =>
this._client.getOrCreateConversation({ 'x-user-key': this.user.key, ...x })
public readonly deleteConversation: IAuthenticatedClient['deleteConversation'] = (x) =>
this._client.deleteConversation({ 'x-user-key': this.user.key, ...x })
public readonly listConversations: IAuthenticatedClient['listConversations'] = (x) =>
this._client.listConversations({ 'x-user-key': this.user.key, ...x })
public readonly listMessages: IAuthenticatedClient['listMessages'] = (x) =>
this._client.listMessages({ 'x-user-key': this.user.key, ...x })
public readonly listenConversation: IAuthenticatedClient['listenConversation'] = (x) =>
this._client.listenConversation({ 'x-user-key': this.user.key, ...x })
public readonly addParticipant: IAuthenticatedClient['addParticipant'] = (x) =>
this._client.addParticipant({ 'x-user-key': this.user.key, ...x })
public readonly removeParticipant: IAuthenticatedClient['removeParticipant'] = (x) =>
this._client.removeParticipant({ 'x-user-key': this.user.key, ...x })
public readonly getParticipant: IAuthenticatedClient['getParticipant'] = (x) =>
this._client.getParticipant({ 'x-user-key': this.user.key, ...x })
public readonly listParticipants: IAuthenticatedClient['listParticipants'] = (x) =>
this._client.listParticipants({ 'x-user-key': this.user.key, ...x })
public readonly createMessage: IAuthenticatedClient['createMessage'] = (x) =>
this._client.createMessage({ 'x-user-key': this.user.key, ...x })
public readonly getMessage: IAuthenticatedClient['getMessage'] = (x) =>
this._client.getMessage({ 'x-user-key': this.user.key, ...x })
public readonly deleteMessage: IAuthenticatedClient['deleteMessage'] = (x) =>
this._client.deleteMessage({ 'x-user-key': this.user.key, ...x })
public readonly getUser: IAuthenticatedClient['getUser'] = (x) =>
this._client.getUser({ 'x-user-key': this.user.key, ...x })
public readonly updateUser: IAuthenticatedClient['updateUser'] = (x) =>
this._client.updateUser({ 'x-user-key': this.user.key, ...x })
public readonly deleteUser: IAuthenticatedClient['deleteUser'] = (x) =>
this._client.deleteUser({ 'x-user-key': this.user.key, ...x })
public readonly createEvent: IAuthenticatedClient['createEvent'] = (x) =>
this._client.createEvent({ 'x-user-key': this.user.key, ...x })
public readonly getEvent: IAuthenticatedClient['getEvent'] = (x) =>
this._client.getEvent({ 'x-user-key': this.user.key, ...x })
public get list() {
return {
conversations: (x: types.AuthenticatedClientRequests['listConversations']) =>
this._client.list.conversations({ 'x-user-key': this.user.key, ...x }),
messages: (x: types.AuthenticatedClientRequests['listMessages']) =>
this._client.list.messages({ 'x-user-key': this.user.key, ...x }),
participants: (x: types.AuthenticatedClientRequests['listParticipants']) =>
this._client.list.participants({ 'x-user-key': this.user.key, ...x }),
}
}
}
+1
View File
@@ -0,0 +1 @@
export const defaultBaseApiUrl = 'https://chat.botpress.cloud'
+69
View File
@@ -0,0 +1,69 @@
import axios, { AxiosError } from 'axios'
import { VError } from 'verror'
export * from './gen/client/errors'
export class ChatClientError extends VError {
public static wrap(thrown: unknown, message: string): ChatClientError {
const err = ChatClientError.map(thrown)
return new ChatClientError(err, message ?? '')
}
public static map(thrown: unknown): ChatClientError {
if (thrown instanceof ChatClientError) {
return thrown
}
if (axios.isAxiosError(thrown)) {
return ChatHTTPError.fromAxios(thrown)
}
if (thrown instanceof Error) {
const { message } = thrown
return new ChatClientError(message)
}
return new ChatClientError(String(thrown))
}
public constructor(error: ChatClientError, message: string)
public constructor(message: string)
public constructor(first: ChatClientError | string, second?: string) {
if (typeof first === 'string') {
super(first)
return
}
super(first, second!)
}
}
export class ChatHTTPError extends ChatClientError {
public constructor(
public readonly status: number | undefined,
message: string
) {
super(message)
}
public static fromAxios(e: AxiosError<{ message?: string }>): ChatHTTPError {
const message = this._axiosMsg(e)
return new ChatHTTPError(e.response?.status, message)
}
private static _axiosMsg(e: AxiosError<{ message?: string }>): string {
let message = e.message
if (e.response?.statusText) {
message += `\n ${e.response?.statusText}`
}
if (e.response?.status && e.request?.method && e.request?.path) {
message += `\n (${e.response?.status}) ${e.request.method} ${e.request.path}`
}
if (e.response?.data?.message) {
message += `\n ${e.response?.data?.message}`
}
return message
}
}
export class ChatConfigError extends ChatClientError {
public constructor(message: string) {
super(message)
}
}
+57
View File
@@ -0,0 +1,57 @@
export type ListenStatus = 'keep-listening' | 'stop-listening'
export class EventEmitter<E extends object> {
private _listeners: {
[K in keyof E]?: ((event: E[K]) => void)[]
} = {}
public emit<K extends keyof E>(type: K, event: E[K]) {
const listeners = this._listeners[type]
if (!listeners) {
return
}
for (const listener of [...listeners]) {
listener(event)
}
}
public onceOrMore<K extends keyof E>(type: K, listener: (event: E[K]) => ListenStatus) {
const wrapped = (event: E[K]) => {
const status = listener(event)
if (status === 'stop-listening') {
this.off(type, wrapped)
}
}
this.on(type, wrapped)
}
public once<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
const wrapped = (event: E[K]) => {
this.off(type, wrapped)
listener(event)
}
this.on(type, wrapped)
}
public on<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
if (!this._listeners[type]) {
this._listeners[type] = []
}
this._listeners[type]!.push(listener)
}
public off<K extends keyof E>(type: K, listener: (event: E[K]) => void) {
const listeners = this._listeners[type]
if (!listeners) {
return
}
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}
public cleanup() {
this._listeners = {}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { isBrowser } from 'browser-or-node'
import type EventSourceBrowser from 'event-source-polyfill'
import type EventSourceNodeJs from 'eventsource'
import { EventEmitter } from './event-emitter'
type WebSocketOnOpen = NonNullable<WebSocket['onopen']>
type WebSocketOnMessage = NonNullable<WebSocket['onmessage']>
type WebSocketOnError = NonNullable<WebSocket['onerror']>
type WebSocketOnClose = NonNullable<WebSocket['onclose']>
type WebSocketOpenEvent = Parameters<WebSocketOnOpen>[0]
type WebSocketMessageEvent = Parameters<WebSocketOnMessage>[0]
type WebSocketErrorEvent = Parameters<WebSocketOnError>[0]
type WebSocketCloseEvent = Parameters<WebSocketOnClose>[0]
type NodeOnOpen = EventSourceNodeJs['onopen']
type NodeOnMessage = EventSourceNodeJs['onmessage']
type NodeOnError = EventSourceNodeJs['onerror']
type NodeOpenEvent = Parameters<NodeOnOpen>[0]
type NodeMessageEvent = Parameters<NodeOnMessage>[0]
type NodeErrorEvent = Parameters<NodeOnError>[0]
type BrowserOnOpen = NonNullable<EventSourceBrowser.EventSourcePolyfill['onopen']>
type BrowserOnMessage = NonNullable<EventSourceBrowser.EventSourcePolyfill['onmessage']>
type BrowserOnError = NonNullable<EventSourceBrowser.EventSourcePolyfill['onerror']>
type BrowserOpenEvent = Parameters<BrowserOnOpen>[0]
type BrowserMessageEvent = Parameters<BrowserOnMessage>[0]
type BrowserErrorEvent = Parameters<BrowserOnError>[0]
export type OpenEvent = NodeOpenEvent | BrowserOpenEvent | WebSocketOpenEvent
export type MessageEvent = NodeMessageEvent | BrowserMessageEvent | WebSocketMessageEvent
export type ErrorEvent = NodeErrorEvent | BrowserErrorEvent | WebSocketErrorEvent
export type CloseEvent = WebSocketCloseEvent
export type Events = {
open: OpenEvent
message: MessageEvent
error: ErrorEvent
close: CloseEvent
}
export type ServerEventsProtocol = 'websocket' | 'sse'
export type Props = {
headers?: Record<string, string>
protocol?: ServerEventsProtocol
}
type ServerEventsSource = EventSourceBrowser.EventSourcePolyfill | EventSourceNodeJs | WebSocket
const makeEventSource = (url: string, props: Props = {}) => {
let source: ServerEventsSource
const emitter = new EventEmitter<Events>()
if (props.protocol === 'websocket') {
url = url.replace(/^http/, 'ws')
if (props.headers?.['x-user-key']) {
url = `${url}?x-user-key=${encodeURIComponent(props.headers['x-user-key'])}`
}
source = new WebSocket(url)
source.onclose = (ev: CloseEvent) => emitter.emit('close', ev)
} else {
if (isBrowser) {
const module: typeof EventSourceBrowser = require('event-source-polyfill')
source = new module.EventSourcePolyfill(url, { headers: props.headers })
} else {
const module: typeof EventSourceNodeJs = require('eventsource')
source = new module(url, { headers: props.headers })
}
}
source.onopen = (ev: OpenEvent) => emitter.emit('open', ev)
source.onmessage = (ev: MessageEvent) => emitter.emit('message', ev)
source.onerror = (ev: ErrorEvent) => emitter.emit('error', ev)
return {
emitter,
source,
}
}
export type EventSourceEmitter = {
on: EventEmitter<Events>['on']
close: () => void
}
export const listenEventSource = async (url: string, props: Props = {}): Promise<EventSourceEmitter> => {
const { emitter, source } = makeEventSource(url, props)
await new Promise<void>((resolve, reject) => {
emitter.on('open', () => {
resolve()
})
emitter.on('error', (thrown) => {
reject(thrown)
})
emitter.on('close', () => {
reject(new Error('Connection closed before opening'))
})
}).finally(() => emitter.cleanup())
return {
on: emitter.on.bind(emitter),
close: () => {
emitter.cleanup()
source.close()
},
}
}
+6
View File
@@ -0,0 +1,6 @@
export * as axios from 'axios'
export * from './types'
export * from './errors'
export * from './client'
export * from './signal-listener'
export { ServerEventsProtocol } from './eventsource'
+16
View File
@@ -0,0 +1,16 @@
import { type JWTPayload, SignJWT } from 'jose'
import { ChatClientError } from './errors'
export async function signJwt(payload: JWTPayload, encryptionKey: string) {
try {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(new TextEncoder().encode(encryptionKey))
} catch (thrown: unknown) {
throw ChatClientError.wrap(
thrown,
'Failed to sign the user key. Signing requires the WebCrypto API (Node.js 20+, or a secure context in browsers).'
)
}
}
+29
View File
@@ -0,0 +1,29 @@
export type PageLister<R> = (t: { nextToken?: string }) => Promise<{ items: R[]; meta: { nextToken?: string } }>
export class AsyncCollection<T> {
public constructor(private _list: PageLister<T>) {}
public async *[Symbol.asyncIterator]() {
let nextToken: string | undefined
do {
const { items, meta } = await this._list({ nextToken })
nextToken = meta.nextToken
for (const item of items) {
yield item
}
} while (nextToken)
}
public async collect(props: { limit?: number } = {}) {
const limit = props.limit ?? Number.POSITIVE_INFINITY
const arr: T[] = []
let count = 0
for await (const item of this) {
arr.push(item)
count++
if (count >= limit) {
break
}
}
return arr
}
}
+193
View File
@@ -0,0 +1,193 @@
import { EventEmitter } from './event-emitter'
import { listenEventSource, EventSourceEmitter, MessageEvent, ErrorEvent, ServerEventsProtocol } from './eventsource'
import { zod as signals, Types } from './gen/signals'
import { WatchDog } from './watchdog'
const CONNECTION_TIMEOUT = 60_000
const DEFAULT_ERROR_MESSAGE = 'unknown error'
type ValueOf<T> = T[keyof T]
type _Signals = Types & {
unknown: {
type: 'unknown'
data: unknown
}
}
type SignalListenerState =
| {
status: 'disconnected'
}
| {
status: 'connecting'
connectionPromise: Promise<EventSourceEmitter>
}
| {
status: 'connected'
source: EventSourceEmitter
watchdog: WatchDog
}
export type Signals = {
[K in keyof _Signals as _Signals[K]['type']]: _Signals[K]['data']
}
type Events = Signals & {
error: Error
close: undefined
}
export type SignalListenerStatus = SignalListenerState['status']
export type SignalListenerProps = {
url: string
userKey: string
conversationId: string
debug: boolean
protocol?: ServerEventsProtocol
}
export class SignalListener extends EventEmitter<Events> {
private _state: SignalListenerState = { status: 'disconnected' }
private constructor(private _props: SignalListenerProps) {
super()
}
public static listen = async (props: SignalListenerProps): Promise<SignalListener> => {
const inst = new SignalListener(props)
await inst.connect()
return inst
}
public get status(): SignalListenerStatus {
return this._state.status
}
public readonly connect = async (): Promise<void> => {
if (this._state.status === 'connected') {
return
}
if (this._state.status === 'connecting') {
await this._state.connectionPromise
return
}
const connectionPromise = this._connect()
this._state = { status: 'connecting', connectionPromise }
await connectionPromise
}
public readonly disconnect = async (): Promise<void> => {
if (this._state.status === 'disconnected') {
return
}
let source: EventSourceEmitter
let watchdog: WatchDog | undefined
if (this._state.status === 'connecting') {
source = await this._state.connectionPromise
} else {
source = this._state.source
watchdog = this._state.watchdog
}
this._disconnectSync(source, watchdog)
}
private _connect = async (): Promise<EventSourceEmitter> => {
const source = await listenEventSource(`${this._props.url}/conversations/${this._props.conversationId}/listen`, {
headers: { 'x-user-key': this._props.userKey },
protocol: this._props.protocol,
})
const watchdog = WatchDog.init(CONNECTION_TIMEOUT)
source.on('message', this._handleMessage(source, watchdog))
source.on('error', this._handleError(source, watchdog))
source.on('close', this._handleClose(source, watchdog))
watchdog.on('error', this._handleError(source, watchdog))
this._state = { status: 'connected', source, watchdog }
return source
}
private _disconnectSync = (source: EventSourceEmitter, watchdog?: WatchDog): void => {
source.close()
watchdog?.close()
this._state = { status: 'disconnected' }
}
private _handleMessage = (_source: EventSourceEmitter, watchdog: WatchDog) => (ev: MessageEvent) => {
watchdog.reset()
const signal = this._parseSignal(ev.data)
this.emit(signal.type, signal.data)
}
private _handleClose = (source: EventSourceEmitter, watchdog: WatchDog) => () => {
this._disconnectSync(source, watchdog)
this.emit('close', undefined)
}
private _handleError = (source: EventSourceEmitter, watchdog: WatchDog) => (ev: ErrorEvent | Error) => {
this._disconnectSync(source, watchdog)
const err = this._toError(ev)
this.emit('error', err)
}
private _parseSignal = (data: unknown): ValueOf<_Signals> => {
for (const [schemaName, schema] of Object.entries(signals)) {
this._debug('trying to parse', schemaName)
const parsedData = this._safeJsonParse(data)
const parseResult = schema.safeParse(parsedData)
if (parseResult.success) {
this._debug('parsing successfull', schemaName, parseResult.data)
return parseResult.data
}
}
return {
type: 'unknown',
data,
}
}
private _safeJsonParse = (x: any) => {
try {
return JSON.parse(x)
} catch {
return x
}
}
private _toError = (thrown: unknown): Error => {
if (thrown instanceof Error) {
return thrown
}
if (typeof thrown === 'string') {
return new Error(thrown)
}
if (thrown === null) {
return new Error(DEFAULT_ERROR_MESSAGE)
}
if (typeof thrown === 'object' && 'message' in thrown) {
return this._toError(thrown.message)
}
try {
const json = JSON.stringify(thrown)
return new Error(json)
} catch {
return new Error(DEFAULT_ERROR_MESSAGE)
}
}
private _debug = (...args: any[]) => {
if (!this._props.debug) {
return
}
console.info(...args)
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Client as AutoGeneratedClient, User } from './gen/client'
export type { Message, Conversation, User, Event } from './gen/client'
type AsyncFunction = (...args: any[]) => Promise<any>
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 Headers = Record<string, string>
type CommonClientProps = {
timeout?: number
headers?: Headers
debug?: boolean
}
type ApiUrlClientProps = CommonClientProps & { apiUrl: string }
type WebhookIdClientProps = CommonClientProps & { webhookId: string; baseApiUrl?: string }
export type ClientProps = ApiUrlClientProps | WebhookIdClientProps
export type ConnectProps = ClientProps & {
encryptionKey?: string
userKey?: string
userId?: string
}
export type ClientOperation = Simplify<
keyof {
[K in keyof AutoGeneratedClient as AutoGeneratedClient[K] extends AsyncFunction ? K : never]: null
}
>
export type ClientRequests = {
[K in ClientOperation]: Parameters<AutoGeneratedClient[K]>[0]
}
export type ClientResponses = {
[K in ClientOperation]: Simplify<Awaited<ReturnType<AutoGeneratedClient[K]>>>
}
export type AuthenticatedOperation = Exclude<ClientOperation, 'createUser' | 'getOrCreateUser'>
export type AuthenticatedClientRequests = Simplify<{
[K in AuthenticatedOperation]: Omit<ClientRequests[K], 'x-user-key'>
}>
export type AuthenticatedUser = Simplify<
User & {
key: string
}
>
+38
View File
@@ -0,0 +1,38 @@
export class WatchDog {
private _listeners: ((error: Error) => void)[] = []
private _handle: ReturnType<typeof setTimeout> | null = null
private constructor(private _ms: number) {}
public static init = (ms: number): WatchDog => {
const inst = new WatchDog(ms)
inst.reset()
return inst
}
public reset() {
if (this._handle) {
clearTimeout(this._handle)
}
this._handle = setTimeout(() => {
this._emitError(new Error('Client connection timed out'))
}, this._ms)
}
public on(_type: 'error', listener: (error: Error) => void) {
this._listeners.push(listener)
}
public close() {
if (this._handle) {
clearTimeout(this._handle)
}
this._listeners = []
}
private _emitError(error: Error) {
for (const listener of this._listeners) {
listener(error)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"emitDeclarationOnly": true,
"declaration": true
},
"include": ["src/**/*"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {},
"include": ["src/**/*", "e2e/**/*", "package.json", "*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import 'dotenv/config'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
testTimeout: 20_000,
include: ['./e2e/**/*.test.ts'],
},
})