diff --git a/legacy/src/services/twilio-conversation.ts b/legacy/src/services/twilio-conversation.ts index 31e65414..f6addd57 100644 --- a/legacy/src/services/twilio-conversation.ts +++ b/legacy/src/services/twilio-conversation.ts @@ -1,7 +1,7 @@ import defaultKy from 'ky' import { DEFAULT_BOT_NAME } from '@/constants' -import { sleep } from '@/utils' +import { chunkString, sleep } from '@/utils' export const TWILIO_CONVERSATION_API_BASE_URL = 'https://conversations.twilio.com/v1' @@ -9,6 +9,14 @@ export const TWILIO_CONVERSATION_API_BASE_URL = export const DEFAULT_TWILIO_TIMEOUT_MS = 120_000 export const DEFAULT_TWILIO_INTERVAL_MS = 5_000 +/** + * 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} + */ +const TWILIO_SMS_LENGTH_SOFT_LIMIT = 320 +const TWILIO_SMS_LENGTH_HARD_LIMIT = 1600 + export interface TwilioConversation { unique_name?: string date_updated: Date @@ -229,6 +237,23 @@ export class TwilioConversationClient { .json() } + /** + * Chunks a long text message into smaller parts and sends them as separate messages. + */ + async sendTextWithChunking({ + conversationSid, + text + }: { + conversationSid: string + text: string + maxChunkLength?: number + }) { + const chunks = chunkString(text, TWILIO_SMS_LENGTH_SOFT_LIMIT) + return Promise.all( + chunks.map((chunk) => this.sendMessage({ conversationSid, text: chunk })) + ) + } + /** * Posts a message to a conversation. */ @@ -239,6 +264,11 @@ export class TwilioConversationClient { 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.substring(0, TWILIO_SMS_LENGTH_HARD_LIMIT - 3) + '...' + } + const params = new URLSearchParams() params.set('Body', text) params.set('Author', this.botName) @@ -291,7 +321,7 @@ export class TwilioConversationClient { const { sid: conversationSid } = await this.createConversation(name) await this.addParticipant({ conversationSid, recipientPhoneNumber }) - await this.sendMessage({ conversationSid, text }) + await this.sendTextWithChunking({ conversationSid, text }) const start = Date.now() let nUserMessages = 0 diff --git a/legacy/src/utils.ts b/legacy/src/utils.ts index d3e8d17b..61fd39f6 100644 --- a/legacy/src/utils.ts +++ b/legacy/src/utils.ts @@ -21,3 +21,36 @@ const taskNameRegex = /^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/ export function isValidTaskIdentifier(id: string): boolean { return !!id && taskNameRegex.test(id) } + +/** + * Chunk a string into an array of strings of a given length + * + * @param text - string to chunk + * @param length - maximum length of each chunk + * @returns array of strings + */ +export const chunkString = (text: string, length: number) => { + const words = text.split(' ') + const chunks: string[] = [] + let chunk = '' + + for (const word of words) { + if (word.length > length) { + // Truncate the word if it's too long and indicate that it was truncated: + chunks.push(word.substring(0, length - 3) + '...') + } + + if ((chunk + word).length > length) { + chunks.push(chunk.trim()) + chunk = word + } else { + chunk += ' ' + word + } + } + + if (chunk) { + chunks.push(chunk.trim()) + } + + return chunks +} diff --git a/legacy/test/services/twilio-conversation.test.ts b/legacy/test/services/twilio-conversation.test.ts index d7633357..571d9d8a 100644 --- a/legacy/test/services/twilio-conversation.test.ts +++ b/legacy/test/services/twilio-conversation.test.ts @@ -58,6 +58,29 @@ test.serial('TwilioConversationClient.sendMessage', async (t) => { await client.deleteConversation(conversationSid) }) +test.serial('TwilioConversationClient.sendTextWithChunking', 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( + 'send-message-test' + ) + + const text = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' + + const messages = await client.sendTextWithChunking({ conversationSid, text }) + + // Text should be sent in two messages: + t.true(text.startsWith(messages[0].body)) + t.true(text.endsWith(messages[1].body)) + + await client.deleteConversation(conversationSid) +}) + test.serial('TwilioConversationClient.fetchMessages', async (t) => { if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { return t.pass()