From 3171875e5021834fec1cb81f37efe96ae42dba71 Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 14:26:37 -0400 Subject: [PATCH] feat: add Twilio Conversation service --- src/constants.ts | 1 + src/services/twilio-conversation.ts | 271 ++++++++++++++++++++++++++++ test/twilio-conversation.test.ts | 103 +++++++++++ 3 files changed, 375 insertions(+) create mode 100644 src/services/twilio-conversation.ts create mode 100644 test/twilio-conversation.test.ts diff --git a/src/constants.ts b/src/constants.ts index 4146a78..f78cd92 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,2 +1,3 @@ export const defaultOpenAIModel = 'gpt-3.5-turbo' export const defaultAnthropicModel = 'claude-instant-v1' +export const BOT_NAME = 'Agentic Bot' diff --git a/src/services/twilio-conversation.ts b/src/services/twilio-conversation.ts new file mode 100644 index 0000000..9041be6 --- /dev/null +++ b/src/services/twilio-conversation.ts @@ -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 { + 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() + } + + /** + * 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() + } + + /** + * 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() + } + + /** + * Fetches all messages in a conversation. + */ + async fetchMessages(conversationSid: string) { + return this.api + .get(`Conversations/${conversationSid}/Messages`) + .json() + } + + /** + * 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') + } +} diff --git a/test/twilio-conversation.test.ts b/test/twilio-conversation.test.ts new file mode 100644 index 0000000..55d0b41 --- /dev/null +++ b/test/twilio-conversation.test.ts @@ -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' + } + ) +})