kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: add Twilio Conversation SMS service
commit
eba1060ba5
|
@ -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.
|
||||
|
||||

|
||||
|
||||
[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
|
|
@ -1,2 +1,3 @@
|
|||
export const defaultOpenAIModel = 'gpt-3.5-turbo'
|
||||
export const defaultAnthropicModel = 'claude-instant-v1'
|
||||
export const BOT_NAME = 'Agentic Bot'
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
})
|
Ładowanie…
Reference in New Issue