From 3171875e5021834fec1cb81f37efe96ae42dba71 Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 14:26:37 -0400 Subject: [PATCH 1/5] 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 4146a782..f78cd92c 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 00000000..9041be6e --- /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 00000000..55d0b41f --- /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' + } + ) +}) From affdb27c102840d1ef411e3044674025be704290 Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 17:41:51 -0400 Subject: [PATCH 2/5] docs: add setup guide for Twilio --- docs/twilio.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/twilio.md diff --git a/docs/twilio.md b/docs/twilio.md new file mode 100644 index 00000000..ad93df65 --- /dev/null +++ b/docs/twilio.md @@ -0,0 +1,30 @@ +

Twilio Agentic Service

+ +## Intro + +[Twilio][twilio] allows software developers to programmatically make and receive phone calls, send and receive text messages, and perform other communication functions using its web service APIs. Agentic provides a simple interface to Twilio's APIs for sending text messages (SMS) and optionally waiting for a reply from the recipient as part of an agentic workflow. + +Twilio offers a free [trial account][twilio-trial] with a small balance that you can use to test out the service. However, you will need to upgrade to a paid plan to use this service in production. Among other [restrictions][twilio-restrictions], the trial requires to verify any non-Twilio phone number before you can send text messages to it. + +## Pre-requisites + +Ensure the following environment variables are set: + +- `TWILIO_ACCOUNT_SID`: Your Twilio account SID +- `TWILIO_AUTH_TOKEN`: Your Twilio auth token +- `TWILIO_PHONE_NUMBER`: Your Twilio phone number + +Otherwise, these can be passed directly to the `TwilioConversationClient` constructor. + +### How to get your Twilio credentials + +1. Open the [Twilio console][twilio-console] and log in or create an account. + +2. In the "Account Info" box, click on the "Copy to clipboard" buttons next to the "Account SID", "Auth Token", and "My Twilio phone number" fields to copy the respective value to your clipboard. + + ![](https://ajeuwbhvhr.cloudimg.io/colony-recorder.s3.amazonaws.com/files/2023-06-09/74c8d823-b6ea-4b75-981a-a54b09044cfd/user_cropped_screenshot.jpeg?tl_px=245,189&br_px=1365,819&sharp=0.8&width=560&wat_scale=50&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-labs-public.s3.us-east-2.amazonaws.com/images/watermarks/watermark_default.png&wat_pad=472,139) + +[twilio]: https://www.twilio.com +[twilio-trial]: https://support.twilio.com/hc/en-us/articles/223136107-How-does-Twilio-s-Free-Trial-work- +[twilio-restrictions]: https://support.twilio.com/hc/en-us/articles/360036052753-Twilio-Free-Trial-Limitations +[twilio-console]: https://www.twilio.com/console From 646398d2aa44bd22545c6b019eea10f747ed12ad Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 17:46:44 -0400 Subject: [PATCH 3/5] docs: Add TSDoc comment --- src/services/twilio-conversation.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/services/twilio-conversation.ts b/src/services/twilio-conversation.ts index 9041be6e..52d4301c 100644 --- a/src/services/twilio-conversation.ts +++ b/src/services/twilio-conversation.ts @@ -118,6 +118,11 @@ export type TwilioSendAndWaitOptions = { validate?: (message: TwilioConversationMessage) => boolean } +/** + * A client for interacting with the Twilio Conversations API to send automated messages and wait for replies. + * + * @see {@link https://www.twilio.com/docs/conversations/api} + */ export class TwilioConversationClient { api: typeof ky phoneNumber: string From f823da15af1dcbb7dd7ccab59f295b44295fb9d8 Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 20:17:19 -0400 Subject: [PATCH 4/5] feat: add support for stop signal and use do-while --- src/services/twilio-conversation.ts | 26 ++++++++++++++++++--- test/twilio-conversation.test.ts | 35 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/services/twilio-conversation.ts b/src/services/twilio-conversation.ts index 52d4301c..ec369142 100644 --- a/src/services/twilio-conversation.ts +++ b/src/services/twilio-conversation.ts @@ -116,6 +116,11 @@ export type TwilioSendAndWaitOptions = { * 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 + + /** + * A stop signal from an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), which can be used to abort retrying. More specifically, when `AbortController.abort(reason)` is called, the function will throw an error with the `reason` argument as the error message. + */ + stopSignal?: AbortSignal } /** @@ -242,14 +247,28 @@ export class TwilioConversationClient { recipientPhoneNumber, timeoutMs = DEFAULT_TWILIO_TIMEOUT_MS, intervalMs = DEFAULT_TWILIO_INTERVAL_MS, - validate = () => true + validate = () => true, + stopSignal }: TwilioSendAndWaitOptions) { + let aborted = false + stopSignal?.addEventListener( + 'abort', + () => { + aborted = true + }, + { once: true } + ) + 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) { + do { + if (aborted) { + await this.deleteConversation(conversationSid) + throw new Error('Aborted waiting for reply') + } const response = await this.fetchMessages(conversationSid) if (response.messages.length > 1) { const candidates = response.messages.filter( @@ -269,7 +288,8 @@ export class TwilioConversationClient { nUserMessages = candidates.length } await sleep(intervalMs) - } + } while (Date.now() - start < timeoutMs) + 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 index 55d0b41f..d22d0842 100644 --- a/test/twilio-conversation.test.ts +++ b/test/twilio-conversation.test.ts @@ -101,3 +101,38 @@ test('TwilioConversationClient.sendAndWaitForReply', async (t) => { } ) }) + +test('TwilioConversationClient.sendAndWaitForReply.stopSignal', 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 () => { + const controller = new AbortController() + const promise = 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 + stopSignal: controller.signal + }) + controller.abort() + return promise + }, + { + instanceOf: Error, + message: 'Aborted waiting for reply' + } + ) +}) From 9d2eef94e569899e20781ceaa6d4f876981fca0c Mon Sep 17 00:00:00 2001 From: Philipp Burckhardt Date: Fri, 9 Jun 2023 20:26:49 -0400 Subject: [PATCH 5/5] fix: handle abort reason correctly --- src/services/twilio-conversation.ts | 7 ++++++- test/twilio-conversation.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/services/twilio-conversation.ts b/src/services/twilio-conversation.ts index ec369142..158b00a5 100644 --- a/src/services/twilio-conversation.ts +++ b/src/services/twilio-conversation.ts @@ -267,7 +267,12 @@ export class TwilioConversationClient { do { if (aborted) { await this.deleteConversation(conversationSid) - throw new Error('Aborted waiting for reply') + const reason = stopSignal?.reason || 'Aborted waiting for reply' + if (reason instanceof Error) { + throw reason + } else { + throw new Error(reason) + } } const response = await this.fetchMessages(conversationSid) if (response.messages.length > 1) { diff --git a/test/twilio-conversation.test.ts b/test/twilio-conversation.test.ts index d22d0842..5b0da74a 100644 --- a/test/twilio-conversation.test.ts +++ b/test/twilio-conversation.test.ts @@ -127,12 +127,12 @@ test('TwilioConversationClient.sendAndWaitForReply.stopSignal', async (t) => { intervalMs: 5000, // 5 seconds stopSignal: controller.signal }) - controller.abort() + controller.abort('Aborted') return promise }, { instanceOf: Error, - message: 'Aborted waiting for reply' + message: 'Aborted' } ) })