chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
src/gen/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
src/
|
||||
tsconfig.json
|
||||
build.ts
|
||||
openapi.ts
|
||||
readme.md
|
||||
*.tsbuildinfo
|
||||
.ignore.me.*
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
```
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const defaultBaseApiUrl = 'https://chat.botpress.cloud'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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).'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*", "e2e/**/*", "package.json", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'dotenv/config'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 20_000,
|
||||
include: ['./e2e/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user