kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: add Twilio Conversation service
rodzic
0f17f53fcf
commit
3171875e50
|
@ -1,2 +1,3 @@
|
||||||
export const defaultOpenAIModel = 'gpt-3.5-turbo'
|
export const defaultOpenAIModel = 'gpt-3.5-turbo'
|
||||||
export const defaultAnthropicModel = 'claude-instant-v1'
|
export const defaultAnthropicModel = 'claude-instant-v1'
|
||||||
|
export const BOT_NAME = 'Agentic Bot'
|
||||||
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
import ky, { KyResponse } from 'ky'
|
||||||
|
|
||||||
|
import { BOT_NAME } from '@/constants'
|
||||||
|
import { sleep } from '@/utils'
|
||||||
|
|
||||||
|
export const TWILIO_CONVERSATION_BASE_URL =
|
||||||
|
'https://conversations.twilio.com/v1'
|
||||||
|
|
||||||
|
export const DEFAULT_TWILIO_TIMEOUT_MS = 120_000
|
||||||
|
export const DEFAULT_TWILIO_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
export interface TwilioConversation {
|
||||||
|
unique_name?: string
|
||||||
|
date_updated: Date
|
||||||
|
friendly_name: string
|
||||||
|
timers: null
|
||||||
|
account_sid: string
|
||||||
|
url: string
|
||||||
|
state: string
|
||||||
|
date_created: Date
|
||||||
|
messaging_service_sid: string
|
||||||
|
sid: string
|
||||||
|
attributes: string
|
||||||
|
bindings: null
|
||||||
|
chat_service_sid: string
|
||||||
|
links: TwilioConversationLinks
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwilioConversationLinks {
|
||||||
|
participants: string
|
||||||
|
messages: string
|
||||||
|
webhooks: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwilioConversationMessage {
|
||||||
|
body: string
|
||||||
|
index: number
|
||||||
|
author: string
|
||||||
|
date_updated: Date
|
||||||
|
media: null
|
||||||
|
participant_sid: string | null
|
||||||
|
conversation_sid: string
|
||||||
|
account_sid: string
|
||||||
|
delivery: null
|
||||||
|
url: string
|
||||||
|
date_created: Date
|
||||||
|
content_sid: string | null
|
||||||
|
sid: string
|
||||||
|
attributes: string
|
||||||
|
links: {
|
||||||
|
delivery_receipts: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwilioConversationParticipant {
|
||||||
|
last_read_message_index: null
|
||||||
|
date_updated: Date
|
||||||
|
last_read_timestamp: null
|
||||||
|
conversation_sid: string
|
||||||
|
account_sid: string
|
||||||
|
url: string
|
||||||
|
date_created: Date
|
||||||
|
role_sid: string
|
||||||
|
sid: string
|
||||||
|
attributes: string
|
||||||
|
identity?: string
|
||||||
|
messaging_binding: TwilioConversationMessagingBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwilioConversationMessagingBinding {
|
||||||
|
proxy_address: string
|
||||||
|
type: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwilioConversationMessages {
|
||||||
|
messages: TwilioConversationMessage[]
|
||||||
|
meta: {
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
first_page_url: string
|
||||||
|
previous_page_url: string | null
|
||||||
|
url: string
|
||||||
|
next_page_url: string | null
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TwilioSendAndWaitOptions = {
|
||||||
|
/**
|
||||||
|
* The recipient's phone number in E.164 format (e.g. +14565551234).
|
||||||
|
*/
|
||||||
|
recipientPhoneNumber: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of the message to send.
|
||||||
|
*/
|
||||||
|
text: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly name of the conversation.
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeout in milliseconds to wait for a reply before throwing an error.
|
||||||
|
*/
|
||||||
|
timeoutMs?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interval in milliseconds to poll for replies.
|
||||||
|
*/
|
||||||
|
intervalMs?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to validate the reply message. If the function returns `true`, the reply is considered valid and the function will return the message. If the function returns `false`, the reply is considered invalid and the function will continue to wait for a reply until the timeout is reached.
|
||||||
|
*/
|
||||||
|
validate?: (message: TwilioConversationMessage) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TwilioConversationClient {
|
||||||
|
api: typeof ky
|
||||||
|
phoneNumber: string
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
accountSid = process.env.TWILIO_ACCOUNT_SID,
|
||||||
|
authToken = process.env.TWILIO_AUTH_TOKEN,
|
||||||
|
phoneNumber = process.env.TWILIO_PHONE_NUMBER,
|
||||||
|
baseUrl = TWILIO_CONVERSATION_BASE_URL
|
||||||
|
}: {
|
||||||
|
accountSid?: string
|
||||||
|
authToken?: string
|
||||||
|
phoneNumber?: string
|
||||||
|
baseUrl?: string
|
||||||
|
} = {}) {
|
||||||
|
if (!accountSid || !authToken) {
|
||||||
|
throw new Error(
|
||||||
|
`Error TwilioConversationClient missing required "accountSid" and/or "authToken"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!phoneNumber) {
|
||||||
|
throw new Error(
|
||||||
|
`Error TwilioConversationClient missing required "phoneNumber"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.phoneNumber = phoneNumber
|
||||||
|
this.api = ky.create({
|
||||||
|
prefixUrl: baseUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
'Basic ' +
|
||||||
|
Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a conversation and all its messages.
|
||||||
|
*/
|
||||||
|
async deleteConversation(conversationSid: string): Promise<KyResponse> {
|
||||||
|
return this.api.delete(`Conversations/${conversationSid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new conversation.
|
||||||
|
*/
|
||||||
|
async createConversation(friendlyName: string) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('FriendlyName', friendlyName)
|
||||||
|
return this.api
|
||||||
|
.post('Conversations', {
|
||||||
|
body: params
|
||||||
|
})
|
||||||
|
.json<TwilioConversation>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a participant to a conversation.
|
||||||
|
*/
|
||||||
|
async addParticipant({
|
||||||
|
conversationSid,
|
||||||
|
recipientPhoneNumber
|
||||||
|
}: {
|
||||||
|
conversationSid: string
|
||||||
|
recipientPhoneNumber: string
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('MessagingBinding.Address', recipientPhoneNumber)
|
||||||
|
params.set('MessagingBinding.ProxyAddress', this.phoneNumber)
|
||||||
|
return this.api
|
||||||
|
.post(`Conversations/${conversationSid}/Participants`, {
|
||||||
|
body: params
|
||||||
|
})
|
||||||
|
.json<TwilioConversationParticipant>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to a conversation.
|
||||||
|
*/
|
||||||
|
async sendMessage({
|
||||||
|
conversationSid,
|
||||||
|
text
|
||||||
|
}: {
|
||||||
|
conversationSid: string
|
||||||
|
text: string
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('Body', text)
|
||||||
|
params.set('Author', BOT_NAME)
|
||||||
|
return this.api
|
||||||
|
.post(`Conversations/${conversationSid}/Messages`, {
|
||||||
|
body: params
|
||||||
|
})
|
||||||
|
.json<TwilioConversationMessage>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all messages in a conversation.
|
||||||
|
*/
|
||||||
|
async fetchMessages(conversationSid: string) {
|
||||||
|
return this.api
|
||||||
|
.get(`Conversations/${conversationSid}/Messages`)
|
||||||
|
.json<TwilioConversationMessages>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a SMS to a recipient and waits for a reply to the message, which is returned if it passes validation.
|
||||||
|
*
|
||||||
|
* ### Notes
|
||||||
|
*
|
||||||
|
* - The implementation will poll for replies to the message until the timeout is reached. This is not ideal, but it is the only way to retrieve replies without spinning up a local server to receive webhook events.
|
||||||
|
*/
|
||||||
|
public async sendAndWaitForReply({
|
||||||
|
text,
|
||||||
|
name,
|
||||||
|
recipientPhoneNumber,
|
||||||
|
timeoutMs = DEFAULT_TWILIO_TIMEOUT_MS,
|
||||||
|
intervalMs = DEFAULT_TWILIO_INTERVAL_MS,
|
||||||
|
validate = () => true
|
||||||
|
}: TwilioSendAndWaitOptions) {
|
||||||
|
const { sid: conversationSid } = await this.createConversation(name)
|
||||||
|
await this.addParticipant({ conversationSid, recipientPhoneNumber })
|
||||||
|
await this.sendMessage({ conversationSid, text })
|
||||||
|
const start = Date.now()
|
||||||
|
let nUserMessages = 0
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const response = await this.fetchMessages(conversationSid)
|
||||||
|
if (response.messages.length > 1) {
|
||||||
|
const candidates = response.messages.filter(
|
||||||
|
(message) => message.author !== BOT_NAME
|
||||||
|
)
|
||||||
|
const candidate = candidates[candidates.length - 1]
|
||||||
|
if (validate(candidate)) {
|
||||||
|
await this.deleteConversation(conversationSid)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
if (nUserMessages !== candidates.length) {
|
||||||
|
await this.sendMessage({
|
||||||
|
text: `Invalid response: ${candidate.body}. Please try again with a valid response format.`,
|
||||||
|
conversationSid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
nUserMessages = candidates.length
|
||||||
|
}
|
||||||
|
await sleep(intervalMs)
|
||||||
|
}
|
||||||
|
await this.deleteConversation(conversationSid)
|
||||||
|
throw new Error('Reached timeout waiting for reply')
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
import test from 'ava'
|
||||||
|
|
||||||
|
import { TwilioConversationClient } from '@/services/twilio-conversation'
|
||||||
|
|
||||||
|
import './_utils'
|
||||||
|
|
||||||
|
test('TwilioConversationClient.createConversation', async (t) => {
|
||||||
|
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) {
|
||||||
|
return t.pass()
|
||||||
|
}
|
||||||
|
const client = new TwilioConversationClient()
|
||||||
|
|
||||||
|
const friendlyName = 'create-conversation-test'
|
||||||
|
const conversation = await client.createConversation(friendlyName)
|
||||||
|
t.is(conversation.friendly_name, friendlyName)
|
||||||
|
|
||||||
|
client.deleteConversation(conversation.sid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TwilioConversationClient.addParticipant', async (t) => {
|
||||||
|
if (
|
||||||
|
!process.env.TWILIO_ACCOUNT_SID ||
|
||||||
|
!process.env.TWILIO_AUTH_TOKEN ||
|
||||||
|
!process.env.TWILIO_TEST_PHONE_NUMBER
|
||||||
|
) {
|
||||||
|
return t.pass()
|
||||||
|
}
|
||||||
|
const client = new TwilioConversationClient()
|
||||||
|
|
||||||
|
const { sid: conversationSid } = await client.createConversation(
|
||||||
|
'add-participant-test'
|
||||||
|
)
|
||||||
|
const { sid: participantSid } = await client.addParticipant({
|
||||||
|
conversationSid,
|
||||||
|
recipientPhoneNumber: process.env.TWILIO_TEST_PHONE_NUMBER
|
||||||
|
})
|
||||||
|
t.is(participantSid.startsWith('MB'), true)
|
||||||
|
|
||||||
|
await client.deleteConversation(conversationSid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TwilioConversationClient.sendMessage', async (t) => {
|
||||||
|
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) {
|
||||||
|
return t.pass()
|
||||||
|
}
|
||||||
|
const client = new TwilioConversationClient()
|
||||||
|
|
||||||
|
const text = 'Hello, world!'
|
||||||
|
const { sid: conversationSid } = await client.createConversation(
|
||||||
|
'send-message-test'
|
||||||
|
)
|
||||||
|
const message = await client.sendMessage({ conversationSid, text })
|
||||||
|
t.is(message.body, text)
|
||||||
|
|
||||||
|
await client.deleteConversation(conversationSid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TwilioConversationClient.fetchMessages', async (t) => {
|
||||||
|
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) {
|
||||||
|
return t.pass()
|
||||||
|
}
|
||||||
|
const client = new TwilioConversationClient()
|
||||||
|
|
||||||
|
const { sid: conversationSid } = await client.createConversation(
|
||||||
|
'fetch-messages-test'
|
||||||
|
)
|
||||||
|
const { messages, meta } = await client.fetchMessages(conversationSid)
|
||||||
|
t.true(Array.isArray(messages))
|
||||||
|
t.is(meta.page, 0)
|
||||||
|
|
||||||
|
await client.deleteConversation(conversationSid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TwilioConversationClient.sendAndWaitForReply', async (t) => {
|
||||||
|
if (
|
||||||
|
!process.env.TWILIO_ACCOUNT_SID ||
|
||||||
|
!process.env.TWILIO_AUTH_TOKEN ||
|
||||||
|
!process.env.TWILIO_TEST_PHONE_NUMBER
|
||||||
|
) {
|
||||||
|
return t.pass()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.timeout(2 * 60 * 1000)
|
||||||
|
const client = new TwilioConversationClient()
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
async () => {
|
||||||
|
await client.sendAndWaitForReply({
|
||||||
|
recipientPhoneNumber: process.env.TWILIO_TEST_PHONE_NUMBER as string,
|
||||||
|
text: 'Please confirm by replying with "yes" or "no".',
|
||||||
|
name: 'wait-for-reply-test',
|
||||||
|
validate: (message) =>
|
||||||
|
['yes', 'no'].includes(message.body.toLowerCase()),
|
||||||
|
timeoutMs: 10000, // 10 seconds
|
||||||
|
intervalMs: 5000 // 5 seconds
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
instanceOf: Error,
|
||||||
|
message: 'Reached timeout waiting for reply'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
Ładowanie…
Reference in New Issue