feat: add Twilio Conversation SMS service

Philipp Burckhardt 2023-06-09 22:02:49 -04:00 zatwierdzone przez GitHub
commit eba1060ba5
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
4 zmienionych plików z 470 dodań i 0 usunięć

30
docs/twilio.md 100644
Wyświetl plik

@ -0,0 +1,30 @@
<h1 align="center">Twilio Agentic Service</h1>
## 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

Wyświetl plik

@ -1,2 +1,3 @@
export const defaultOpenAIModel = 'gpt-3.5-turbo'
export const defaultAnthropicModel = 'claude-instant-v1'
export const BOT_NAME = 'Agentic Bot'

Wyświetl plik

@ -0,0 +1,301 @@
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
/**
* 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
}
/**
* 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
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,
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
do {
if (aborted) {
await this.deleteConversation(conversationSid)
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) {
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)
} while (Date.now() - start < timeoutMs)
await this.deleteConversation(conversationSid)
throw new Error('Reached timeout waiting for reply')
}
}

138
test/twilio-conversation.test.ts vendored 100644
Wyświetl plik

@ -0,0 +1,138 @@
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'
}
)
})
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('Aborted')
return promise
},
{
instanceOf: Error,
message: 'Aborted'
}
)
})