diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9d3f16f..6a781360d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### A long text message is cut between characters + +A message too long for one SMS is cut and ends with "Open OpenBot to read the full message.". When +the cut landed inside an emoji, the person received a "�" before the "…". The cut now drops the +half character instead. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/server/src/delivery/twilio.ts b/server/src/delivery/twilio.ts index fac14f904..b0de5c22f 100644 --- a/server/src/delivery/twilio.ts +++ b/server/src/delivery/twilio.ts @@ -1,4 +1,5 @@ import { validateRequest } from "twilio"; +import { cutAtCodeUnits } from "../channels/text"; import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; import { DeliveryProviderError, @@ -119,7 +120,7 @@ export function createTwilioTransport(config: TwilioConfig) { Body: input.text.length <= 1500 ? input.text - : `${input.text.slice(0, 1400)}…\nOpen OpenBot to read the full message.`, + : `${cutAtCodeUnits(input.text, 1400)}…\nOpen OpenBot to read the full message.`, ...(config.statusUrl ? { StatusCallback: config.statusUrl } : {}), }, ); diff --git a/server/tests/delivery-providers.test.ts b/server/tests/delivery-providers.test.ts index f46b36beb..f40557b5d 100644 --- a/server/tests/delivery-providers.test.ts +++ b/server/tests/delivery-providers.test.ts @@ -182,6 +182,27 @@ describe("real HTTP provider adapters", () => { new URLSearchParams(String(wire.requests[2]?.init?.body)).get("To"), ).toBe("+15551234567"); }); + test("Twilio cuts a long SMS between characters, not inside an emoji", async () => { + const wire = http([{ sid: "SM1", status: "queued" }]); + const twilio = createTwilioTransport({ + accountSid: "AC1", + authToken: "secret", + verifyServiceSid: "VA1", + from: "+15550000000", + webhookUrl: "https://o.test/sms", + fetch: wire.fetch, + }); + // 1,399 letters and then an emoji: the 1,400-unit cut lands between its two halves. + const text = `${"a".repeat(1399)}😀${"b".repeat(200)}`; + await twilio.send({ address: "+15551234567", text, id: "out1" }); + const body = + new URLSearchParams(String(wire.requests[0]?.init?.body)).get("Body") ?? + ""; + expect(body).not.toContain("\uFFFD"); + expect(body).toBe( + `${"a".repeat(1399)}…\nOpen OpenBot to read the full message.`, + ); + }); test("Expo sends canonical deep links and reads receipts including invalid devices", async () => { const wire = http([ { data: { status: "ok", id: "ticket1" } },