2024-08-04 06:07:53 +00:00
import {
aiFunction ,
AIFunctionsProvider ,
assert ,
delay ,
getEnv ,
TimeoutError
} from '@agentic/core'
2024-06-06 05:27:30 +00:00
import defaultKy , { type KyInstance } from 'ky'
import { z } from 'zod'
export namespace twilio {
export const CONVERSATION_API_BASE_URL = 'https://conversations.twilio.com/v1'
export const DEFAULT_TIMEOUT_MS = 1 _800_000
export const DEFAULT_INTERVAL_MS = 5000
export const DEFAULT_BOT_NAME = 'agentic'
/ * *
* Twilio recommends keeping SMS messages to a length of 320 characters or less , so we ' ll use that as the maximum .
*
* @see { @link https : //support.twilio.com/hc/en-us/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging}
* /
export const SMS_LENGTH_SOFT_LIMIT = 320
export const SMS_LENGTH_HARD_LIMIT = 1600
export interface Conversation {
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 : ConversationLinks
}
export interface ConversationLinks {
participants : string
messages : string
webhooks : string
}
export interface ConversationMessage {
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 ConversationParticipant {
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 : ConversationMessagingBinding
}
export interface ConversationMessagingBinding {
proxy_address : string
type : string
address : string
}
export interface ConversationMessages {
messages : ConversationMessage [ ]
meta : {
page : number
page_size : number
first_page_url : string
previous_page_url : string | null
url : string
next_page_url : string | null
key : string
}
}
/ * *
* Participant Conversation Resource .
*
* This interface represents a participant in a conversation , along with the conversation details .
* /
export interface ParticipantConversation {
/** The unique ID of the Account responsible for this conversation. */
account_sid : string
/** The unique ID of the Conversation Service this conversation belongs to. */
chat_service_sid : string
/** The unique ID of the Participant. */
participant_sid : string
/** The unique string that identifies the conversation participant as Conversation User. */
participant_user_sid : string
/ * *
* A unique string identifier for the conversation participant as Conversation User .
* This parameter is non - null if ( and only if ) the participant is using the Conversations SDK to communicate .
* /
participant_identity : string
/ * *
* Information about how this participant exchanges messages with the conversation .
* A JSON parameter consisting of type and address fields of the participant .
* /
participant_messaging_binding : object
/** The unique ID of the Conversation this Participant belongs to. */
conversation_sid : string
/** An application-defined string that uniquely identifies the Conversation resource. */
conversation_unique_name : string
/** The human-readable name of this conversation, limited to 256 characters. */
conversation_friendly_name : string
/ * *
* An optional string metadata field you can use to store any data you wish .
* The string value must contain structurally valid JSON if specified .
* /
conversation_attributes : string
/** The date that this conversation was created, given in ISO 8601 format. */
conversation_date_created : string
/** The date that this conversation was last updated, given in ISO 8601 format. */
conversation_date_updated : string
/** Identity of the creator of this Conversation. */
conversation_created_by : string
/** The current state of this User Conversation. One of inactive, active or closed. */
conversation_state : 'inactive' | 'active' | 'closed'
/** Timer date values representing state update for this conversation. */
conversation_timers : object
/** Contains absolute URLs to access the participant and conversation of this conversation. */
links : { participant : string ; conversation : string }
}
export type SendAndWaitOptions = {
/ * *
* The recipient ' s phone number in E . 164 format ( e . g . + 14565551234 ) .
* /
recipientPhoneNumber? : string
/ * *
* The text of the message to send ( or an array of strings to send as separate messages ) .
* /
text : string | 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 : ConversationMessage ) = > 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
}
/ * *
* Chunks a string into an array of chunks .
*
* @param text - string to chunk
* @param maxLength - maximum length of each chunk
*
* @returns array of chunks
* /
export function chunkString ( text : string , maxLength : number ) : string [ ] {
const words = text . split ( ' ' )
const chunks : string [ ] = [ ]
let chunk = ''
for ( const word of words ) {
if ( word . length > maxLength ) {
// Truncate the word if it's too long and indicate that it was truncated:
chunks . push ( word . slice ( 0 , Math . max ( 0 , maxLength - 3 ) ) + '...' )
} else if ( ( chunk + ' ' + word ) . length > maxLength ) {
chunks . push ( chunk . trim ( ) )
chunk = word
} else {
chunk += ( chunk ? ' ' : '' ) + word
}
}
if ( chunk ) {
chunks . push ( chunk . trim ( ) )
}
return chunks
}
/ * *
* Chunks an array of strings into an array of chunks while preserving
* existing sections .
*
* @param textSections - array of strings to chunk
* @param maxLength - maximum length of each chunk
*
* @returns array of chunks
* /
export function chunkMultipleStrings (
textSections : string [ ] ,
maxLength : number
) : string [ ] {
return textSections . flatMap ( ( section ) = > chunkString ( section , maxLength ) )
}
}
/ * *
* 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 TwilioClient extends AIFunctionsProvider {
protected readonly ky : KyInstance
protected readonly phoneNumber : string
protected readonly botName : string
protected readonly defaultRecipientPhoneNumber? : string
constructor ( {
accountSid = getEnv ( 'TWILIO_ACCOUNT_SID' ) ,
authToken = getEnv ( 'TWILIO_AUTH_TOKEN' ) ,
phoneNumber = getEnv ( 'TWILIO_PHONE_NUMBER' ) ,
defaultRecipientPhoneNumber = getEnv (
'TWILIO_DEFAULT_RECIPIENT_PHONE_NUMBER'
) ,
apiBaseUrl = twilio . CONVERSATION_API_BASE_URL ,
botName = twilio . DEFAULT_BOT_NAME ,
ky = defaultKy
} : {
accountSid? : string
authToken? : string
phoneNumber? : string
defaultRecipientPhoneNumber? : string
apiBaseUrl? : string
botName? : string
ky? : KyInstance
} = { } ) {
assert (
accountSid ,
'TwilioClient missing required "accountSid" (defaults to "TWILIO_ACCOUNT_SID")'
)
assert (
authToken ,
'TwilioClient missing required "authToken" (defaults to "TWILIO_AUTH_TOKEN")'
)
assert (
phoneNumber ,
'TwilioClient missing required "phoneNumber" (defaults to "TWILIO_PHONE_NUMBER")'
)
super ( )
if ( defaultRecipientPhoneNumber ) {
this . defaultRecipientPhoneNumber = defaultRecipientPhoneNumber
}
this . botName = botName
this . phoneNumber = phoneNumber
this . ky = ky . extend ( {
prefixUrl : apiBaseUrl ,
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 ) {
return this . ky . delete ( ` Conversations/ ${ conversationSid } ` )
}
/ * *
* Removes a participant from a conversation .
* /
async removeParticipant ( {
conversationSid ,
participantSid
} : {
conversationSid : string
participantSid : string
} ) {
return this . ky . delete (
` Conversations/ ${ conversationSid } /Participants/ ${ participantSid } `
)
}
/ * *
* Fetches all conversations a participant as identified by their phone number is a part of .
* /
async findParticipantConversations ( participantPhoneNumber : string ) {
const encodedPhoneNumber = encodeURIComponent ( participantPhoneNumber )
return this . ky
. get ( ` ParticipantConversations?Address= ${ encodedPhoneNumber } ` )
. json < { conversations : twilio.ParticipantConversation [ ] } > ( )
}
/ * *
* Creates a new conversation .
* /
async createConversation ( friendlyName : string ) {
const params = new URLSearchParams ( )
params . set ( 'FriendlyName' , friendlyName )
return this . ky
. post ( 'Conversations' , {
body : params
} )
. json < twilio.Conversation > ( )
}
/ * *
* 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 . ky
. post ( ` Conversations/ ${ conversationSid } /Participants ` , {
body : params
} )
. json < twilio.ConversationParticipant > ( )
}
/ * *
* Chunks a long text message into smaller parts and sends them as separate messages .
* /
async sendTextWithChunking ( {
conversationSid ,
text
} : {
conversationSid : string
text : string | string [ ]
maxChunkLength? : number
} ) {
let chunks
if ( Array . isArray ( text ) ) {
chunks = twilio . chunkMultipleStrings ( text , twilio . SMS_LENGTH_SOFT_LIMIT )
} else {
chunks = twilio . chunkString ( text , twilio . SMS_LENGTH_SOFT_LIMIT )
}
const out : twilio.ConversationMessage [ ] = [ ]
for ( const chunk of chunks ) {
const sent = await this . sendMessage ( {
conversationSid ,
text : chunk
} )
out . push ( sent )
}
return out
}
/ * *
* Posts a message to a conversation .
* /
@aiFunction ( {
name : 'twilio_send_message' ,
description :
'Sends an text SMS message via the Twilio Conversation API to a specific conversation.' ,
inputSchema : z.object ( {
text : z
. string ( )
. describe (
'Text of the SMS content to sent. Must be brief as SMS has strict character limits.'
) ,
conversationSid : z
. string ( )
. describe ( 'ID of the Twilio Conversation to the send the emssage to.' )
} )
} )
async sendMessage ( {
conversationSid ,
text
} : {
conversationSid : string
text : string
} ) {
// Truncate the text if it exceeds the hard limit and add an ellipsis:
if ( text . length > twilio . SMS_LENGTH_HARD_LIMIT ) {
text =
text . slice ( 0 , Math . max ( 0 , twilio . SMS_LENGTH_HARD_LIMIT - 3 ) ) + '...'
}
const params = new URLSearchParams ( )
params . set ( 'Body' , text )
params . set ( 'Author' , this . botName )
return this . ky
. post ( ` Conversations/ ${ conversationSid } /Messages ` , {
body : params
} )
. json < twilio.ConversationMessage > ( )
}
/ * *
* Fetches all messages in a conversation .
* /
@aiFunction ( {
name : 'twilio_get_messages' ,
description :
'Retrieves all SMS messages contained within a specific Twilio Conversation.' ,
inputSchema : z.object ( {
conversationSid : z
. string ( )
. describe (
'ID of the Twilio Conversation to the retrieve the messages for.'
)
} )
} )
async fetchMessages (
conversationSidOrOptions : string | { conversationSid : string }
) {
const conversationSid =
typeof conversationSidOrOptions === 'string'
? conversationSidOrOptions
: conversationSidOrOptions . conversationSid
return this . ky
. get ( ` Conversations/ ${ conversationSid } /Messages ` )
. json < twilio.ConversationMessages > ( )
}
/ * *
* Sends a SMS to a recipient and waits for a reply to the message , which is returned if it passes validation .
* /
public async sendAndWaitForReply ( {
text ,
name ,
recipientPhoneNumber = this . defaultRecipientPhoneNumber ,
timeoutMs = twilio . DEFAULT_TIMEOUT_MS ,
intervalMs = twilio . DEFAULT_INTERVAL_MS ,
validate = ( ) = > true ,
stopSignal
} : twilio . SendAndWaitOptions ) {
if ( ! recipientPhoneNumber ) {
throw new Error (
'TwilioClient error missing required "recipientPhoneNumber"'
)
}
let aborted = false
stopSignal ? . addEventListener (
'abort' ,
( ) = > {
aborted = true
} ,
{ once : true }
)
const { sid : conversationSid } = await this . createConversation ( name )
// Find and remove participant from conversation they are currently in, if any:
const { conversations } =
await this . findParticipantConversations ( recipientPhoneNumber )
for ( const conversation of conversations ) {
await this . removeParticipant ( {
conversationSid : conversation.conversation_sid ,
participantSid : conversation.participant_sid
} )
}
const { sid : participantSid } = await this . addParticipant ( {
conversationSid ,
recipientPhoneNumber
} )
await this . sendTextWithChunking ( { conversationSid , text } )
const start = Date . now ( )
let nUserMessages = 0
do {
if ( aborted ) {
await this . removeParticipant ( { conversationSid , participantSid } )
const reason = stopSignal ? . reason || 'Aborted waiting for reply'
if ( reason instanceof Error ) {
throw reason
} else {
throw new TypeError ( reason )
}
}
const response = await this . fetchMessages ( conversationSid )
const candidates = response . messages . filter (
( message ) = > message . author !== this . botName
)
if ( candidates . length > 0 ) {
const candidate = candidates . at ( - 1 ) !
if ( candidate && validate ( candidate ) ) {
await this . removeParticipant ( { conversationSid , participantSid } )
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 delay ( intervalMs )
} while ( Date . now ( ) - start < timeoutMs )
await this . removeParticipant ( { conversationSid , participantSid } )
throw new TimeoutError ( 'Twilio timeout waiting for reply' )
}
}