feat: add Twilio Conversation service

old-agentic-v1^2
Philipp Burckhardt 2023-06-09 14:26:37 -04:00
rodzic 0f17f53fcf
commit 3171875e50
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: A2C3BCA4F31D1DDD
3 zmienionych plików z 375 dodań i 0 usunięć

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,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')
}
}

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

@ -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'
}
)
})