Harden packaging, add error taxonomy, persist pending messages

Packaging
- Drop stale `main: dist/index.js` from package.json (no build workflow)
- Move `prisma` to dependencies and add `postinstall: prisma generate`
  so a fresh `npm ci` on the server produces a runnable client
- Add `prisma:deploy` script for `prisma migrate deploy`
- Rename src/services/Twilioservice.ts -> TwilioService.ts (case fix;
  invisible on macOS APFS, would crash on Linux)

Node version guards
- .nvmrc (22.23.1) so `nvm use` picks the right runtime
- .npmrc `engine-strict=true` so npm respects `engines` on install
- Runtime guard in scripts/navigator-patch.cjs that fails fast with an
  actionable message on Node < 22.18 (native .ts stripping requirement)

Twilio error taxonomy (src/lib/twilioErrors.ts)
- `classifyTwilioError(err)` for send-time exceptions, mapping known
  Twilio REST codes (20003/20429/20500/21211..21614) plus HTTP-status
  and Node errno fallback to `{ kind: retryable|terminal, category }`
- `classifyDeliveryFailure(code)` for the 30xxx delivery-status family
- `sendViaTwilio` persists a `send_error[_retryable]` Message row on
  failure and surfaces the classified message to the user
- `/callback` extracts ErrorCode and passes classified failure info to
  `deliveryStatusCard`, which renders the code + retry-hint inline

Persistent PendingMessageStore
- New PrismaPendingMessageStore (upsert-based) becomes the default
  singleton; InMemoryPendingMessageStore retained for tests
- `startPendingMessageSweeper()` runs hourly, drops entries >24h old,
  unref()s its timer, and is disabled under NODE_ENV=test
- Wired into the shutdown handler in src/index.ts

Tests (39 total, up from 13)
- Integration: tests/integration/twilioRoutes.test.ts hits /sms and
  /callback through the real createApp() with a stub bot, real Twilio
  signatures, plus /healthz
- Unit: twilioErrors.test.ts (14), pendingMessageStore.test.ts (5)
- typecheck clean

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 11:41:18 -04:00
parent d0a0dda2b8
commit 28451daca1
16 changed files with 592 additions and 54 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22.23.1

35
package-lock.json generated
View file

@ -7,6 +7,7 @@
"": {
"name": "sendi",
"version": "2.0.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-better-sqlite3": "^6.19.3",
"@prisma/client": "^6.19.3",
@ -15,6 +16,7 @@
"express": "^5.2.1",
"pino": "^9.6.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.19.3",
"twilio": "^5.13.1",
"webex-node-bot-framework": "^2.5.1",
"zod": "^3.24.2"
@ -24,7 +26,6 @@
"@types/express": "^5.0.6",
"@types/node": "^22.13.10",
"@types/supertest": "^6.0.2",
"prisma": "^6.19.3",
"supertest": "^7.0.0",
"typescript": "^5.8.2",
"vitest": "^3.2.6"
@ -3259,7 +3260,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@ -3287,7 +3287,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -3301,14 +3300,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@ -3320,7 +3317,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@ -4162,7 +4158,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@tokenizer/token": {
@ -6490,7 +6485,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@ -6656,7 +6650,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@ -6731,7 +6724,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@ -6947,7 +6939,6 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/connect": {
@ -6990,7 +6981,6 @@
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@ -7207,7 +7197,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@ -7242,7 +7231,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
@ -7276,7 +7264,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destroy": {
@ -7385,7 +7372,6 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@ -7412,7 +7398,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@ -8077,7 +8062,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/extend": {
@ -8099,7 +8083,6 @@
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@ -8573,7 +8556,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@ -9387,7 +9369,6 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@ -11005,7 +10986,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-forge": {
@ -11155,7 +11135,6 @@
"version": "0.6.7",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz",
"integrity": "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
@ -11173,7 +11152,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/oauth-sign": {
@ -11215,7 +11193,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
@ -11568,7 +11545,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pathval": {
@ -11598,7 +11574,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/performance-now": {
@ -11724,7 +11699,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@ -11886,7 +11860,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -12044,7 +12017,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@ -12172,7 +12144,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@ -12492,7 +12463,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@ -14040,7 +14010,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -3,7 +3,6 @@
"version": "2.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "node --watch --require ./scripts/navigator-patch.cjs src/index.ts",
"start": "node --require ./scripts/navigator-patch.cjs src/index.ts",
@ -12,8 +11,10 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"migrate-old": "node legacy/migrate-old-data.ts"
},
"engines": {
@ -27,6 +28,7 @@
"express": "^5.2.1",
"pino": "^9.6.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.19.3",
"twilio": "^5.13.1",
"webex-node-bot-framework": "^2.5.1",
"zod": "^3.24.2"
@ -36,7 +38,6 @@
"@types/express": "^5.0.6",
"@types/node": "^22.13.10",
"@types/supertest": "^6.0.2",
"prisma": "^6.19.3",
"supertest": "^7.0.0",
"typescript": "^5.8.2",
"vitest": "^3.2.6"

View file

@ -16,6 +16,29 @@
// so anything that reads from it later still works.
'use strict';
// -----------------------------------------------------------------------------
// Node version guard.
//
// Sendi runs .ts files directly via Node's native type-stripping loader,
// which is unflagged from Node 22.18. On older Nodes you get:
// TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"
// Fail loudly with an actionable message instead.
// -----------------------------------------------------------------------------
(function checkNodeVersion() {
const [maj, min] = process.versions.node.split('.').map(Number);
const ok = maj > 22 || (maj === 22 && min >= 18);
if (!ok) {
// eslint-disable-next-line no-console
console.error(
'\n[sendi] Unsupported Node.js version: ' + process.versions.node + '\n' +
' Sendi requires Node >= 22.18 (native .ts stripping).\n' +
' Fix with `nvm use` (a .nvmrc is checked in) or install Node 22.18+.\n' +
' Current node binary: ' + process.execPath + '\n',
);
process.exit(1);
}
})();
try {
const desc = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
if (desc && desc.writable !== true) {

View file

@ -29,7 +29,7 @@ const handlers: Record<string, Handler> = {
}
if (wantsAttachment) {
await pendingMessageStore.set(roomId, { roomId, text: messageText, spaceId: space.id });
await pendingMessageStore.set(roomId, { roomId, text: messageText });
await WebexService.sendCard(roomId, CardBuilder.attachmentPromptCard(), 'Upload Attachment');
return;
}

View file

@ -2,7 +2,8 @@
import type { WebexBot } from 'webex-node-bot-framework';
import prisma from '../services/PrismaService.ts';
import logger from '../services/Logger.ts';
import { TwilioService } from '../services/Twilioservice.ts';
import { TwilioService } from '../services/TwilioService.ts';
import { classifyTwilioError } from '../lib/twilioErrors.ts';
interface SpaceLite {
roomId: string;
@ -39,8 +40,28 @@ export async function sendViaTwilio(
const displayName = space.smsName && space.smsName !== 'Unknown' ? space.smsName : 'the contact';
await bot.say(`Message sent to **${displayName}** (${space.smsPhone})`);
} catch (err: any) {
logger.error({ err, to: space.smsPhone }, 'Twilio send failed');
await bot.say(`Failed to send: ${err?.message ?? err}`);
} catch (err) {
const classified = classifyTwilioError(err);
logger.error(
{ err, to: space.smsPhone, category: classified.category, kind: classified.kind, code: classified.code },
'Twilio send failed',
);
try {
await prisma.message.create({
data: {
roomId: space.roomId,
from: space.wbxTmPhone,
body: text,
status: classified.kind === 'retryable' ? 'send_error_retryable' : 'send_error',
mediaUrls: JSON.stringify(mediaUrls),
},
});
} catch (persistErr) {
logger.warn({ err: persistErr }, 'Failed to persist send failure');
}
const prefix = classified.kind === 'retryable' ? 'Temporary send failure' : 'Send failed';
await bot.say(`${prefix}: ${classified.userMessage}`);
}
}

View file

@ -5,6 +5,7 @@ import logger from './services/Logger.ts';
import prisma from './services/PrismaService.ts';
import { createBot } from './bot/index.ts';
import { createApp } from './app.ts';
import { startPendingMessageSweeper } from './services/PendingMessageStore.ts';
async function main() {
const bot = createBot();
@ -15,11 +16,14 @@ async function main() {
logger.info({ port: env.PORT, publicUrl: env.WEBEX_PUBLIC_URL }, 'Sendi listening');
});
const sweeper = startPendingMessageSweeper();
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'Shutting down');
if (sweeper) clearInterval(sweeper);
try { await new Promise<void>((r) => server.close(() => r())); } catch (e) { logger.warn({ err: e }, 'HTTP close failed'); }
try { await bot.stop(); } catch (e) { logger.warn({ err: e }, 'Bot stop failed'); }
try { await prisma.$disconnect(); } catch (e) { logger.warn({ err: e }, 'Prisma disconnect failed'); }

152
src/lib/twilioErrors.ts Normal file
View file

@ -0,0 +1,152 @@
// src/lib/twilioErrors.ts
//
// Structured classification of Twilio failures — both:
// (a) errors thrown by `messages.create` (RestException), and
// (b) error codes reported on delivered-with-failure status callbacks
// (ErrorCode / ErrorMessage form fields on POST /callback).
//
// Two dimensions:
// - `kind`: 'retryable' | 'terminal' (should the caller retry later?)
// - `category`: coarse bucket for surfacing to users / metrics
//
// References:
// - Twilio REST error codes: https://www.twilio.com/docs/api/errors
// - SMS delivery error codes: https://www.twilio.com/docs/sms/api/message-resource#error-codes
export type ErrorKind = 'retryable' | 'terminal';
export type ErrorCategory =
| 'rate_limit'
| 'server'
| 'network'
| 'authentication'
| 'invalid_number'
| 'unreachable'
| 'blocked'
| 'carrier_violation'
| 'unknown';
export interface ClassifiedError {
kind: ErrorKind;
category: ErrorCategory;
code?: number;
status?: number;
/** Short, user-safe explanation. Free of PII and stack details. */
userMessage: string;
/** Raw message from the underlying error, useful for logs. */
rawMessage?: string;
}
// -----------------------------------------------------------------------------
// Send-time classification (errors thrown from `messages.create`)
// -----------------------------------------------------------------------------
// Twilio error codes we care about. Codes not listed here fall back to the
// HTTP status classification below.
const CODE_CATEGORY: Record<number, { kind: ErrorKind; category: ErrorCategory; userMessage: string }> = {
// Authentication & permissioning — terminal, will not fix on retry.
20003: { kind: 'terminal', category: 'authentication', userMessage: 'Twilio credentials rejected (auth failure).' },
20404: { kind: 'terminal', category: 'authentication', userMessage: 'Twilio resource not found.' },
// Rate limiting — retryable.
20429: { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio rate limit hit; try again shortly.' },
// Server-side issues — retryable.
20500: { kind: 'retryable', category: 'server', userMessage: 'Twilio server error; try again shortly.' },
// Invalid numbers — terminal.
21211: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination phone number is not valid.' },
21212: { kind: 'terminal', category: 'invalid_number', userMessage: 'The sender phone number is not valid.' },
21214: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination number cannot receive SMS.' },
21606: { kind: 'terminal', category: 'invalid_number', userMessage: 'The From number is not SMS-capable on this account.' },
21610: { kind: 'terminal', category: 'blocked', userMessage: 'The recipient has unsubscribed from this number.' },
21611: { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio outbound queue is full; try again shortly.' },
21614: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination number is not mobile.' },
};
// SMS delivery-status error codes (30xxx family). Fired asynchronously via
// the status callback with SmsStatus=failed|undelivered.
const DELIVERY_CATEGORY: Record<number, { kind: ErrorKind; category: ErrorCategory; userMessage: string }> = {
30001: { kind: 'retryable', category: 'rate_limit', userMessage: 'Carrier queue overflow; message could not be delivered.' },
30002: { kind: 'terminal', category: 'blocked', userMessage: 'Account suspended.' },
30003: { kind: 'terminal', category: 'unreachable', userMessage: 'Recipient handset is unreachable (off or out of coverage).' },
30004: { kind: 'terminal', category: 'blocked', userMessage: 'Message blocked by the recipient.' },
30005: { kind: 'terminal', category: 'unreachable', userMessage: 'Unknown destination handset.' },
30006: { kind: 'terminal', category: 'unreachable', userMessage: 'Destination is a landline or unreachable carrier.' },
30007: { kind: 'terminal', category: 'carrier_violation', userMessage: 'Message flagged as spam by the carrier.' },
30008: { kind: 'retryable', category: 'unknown', userMessage: 'Unknown delivery error.' },
30009: { kind: 'terminal', category: 'invalid_number', userMessage: 'Missing SMS segment.' },
};
interface MaybeTwilioError {
status?: number;
code?: number;
message?: string;
errno?: string;
cause?: unknown;
}
/**
* Classify an error thrown by the Twilio SDK during message creation.
* Safe to call with any `unknown` never throws.
*/
export function classifyTwilioError(err: unknown): ClassifiedError {
const e = (err ?? {}) as MaybeTwilioError;
const code = typeof e.code === 'number' ? e.code : undefined;
const status = typeof e.status === 'number' ? e.status : undefined;
const rawMessage = typeof e.message === 'string' ? e.message : String(err);
if (code != null && CODE_CATEGORY[code]) {
return { ...CODE_CATEGORY[code], code, status, rawMessage };
}
// Network-level errors surface via Node's `errno` (ETIMEDOUT, ECONNRESET, etc.)
const errno = typeof e.errno === 'string' ? e.errno : undefined;
if (errno && ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND'].includes(errno)) {
return {
kind: 'retryable',
category: 'network',
userMessage: 'Network error reaching Twilio; try again shortly.',
status,
code,
rawMessage,
};
}
// Fall back to HTTP status.
if (status != null) {
if (status === 401 || status === 403) {
return { kind: 'terminal', category: 'authentication', userMessage: 'Twilio rejected our credentials.', status, code, rawMessage };
}
if (status === 429) {
return { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio rate limit hit; try again shortly.', status, code, rawMessage };
}
if (status >= 500) {
return { kind: 'retryable', category: 'server', userMessage: 'Twilio server error; try again shortly.', status, code, rawMessage };
}
if (status >= 400) {
return { kind: 'terminal', category: 'unknown', userMessage: 'Twilio rejected the request.', status, code, rawMessage };
}
}
return { kind: 'terminal', category: 'unknown', userMessage: 'Message send failed.', status, code, rawMessage };
}
/**
* Classify a delivery-status error code reported via the Twilio status callback.
* `code` may arrive as string ("30003") or number; returns undefined if there
* is no recognized failure (e.g. status='delivered' with no error code).
*/
export function classifyDeliveryFailure(code: string | number | undefined | null): ClassifiedError | undefined {
if (code == null || code === '') return undefined;
const n = typeof code === 'number' ? code : parseInt(code, 10);
if (!Number.isFinite(n)) return undefined;
if (DELIVERY_CATEGORY[n]) {
return { ...DELIVERY_CATEGORY[n], code: n };
}
// Unknown but present: treat 30xxx as delivery-terminal, others as unknown terminal.
return {
kind: 'terminal',
category: 'unknown',
code: n,
userMessage: `Delivery failed (Twilio error ${n}).`,
};
}

View file

@ -7,6 +7,7 @@ import { CardBuilder } from '../services/CardBuilder.ts';
import { downloadTwilioMedia, publicMediaUrl } from '../services/MediaService.ts';
import { asyncHandler } from '../lib/asyncHandler.ts';
import { twilioSignature } from '../middleware/twilioSignature.ts';
import { classifyDeliveryFailure } from '../lib/twilioErrors.ts';
import type { WebexBot } from '../bot/index.ts';
const TWIML_EMPTY = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
@ -21,7 +22,12 @@ export function buildTwilioRouter(bot: WebexBot): Router {
asyncHandler(async (req, res) => {
const sid = req.body.SmsSid as string | undefined;
const status = (req.body.SmsStatus as string | undefined) ?? 'unknown';
logger.info({ sid, status }, 'Delivery callback');
const errorCode = (req.body.ErrorCode as string | undefined) ?? undefined;
const failure = classifyDeliveryFailure(errorCode);
logger.info(
{ sid, status, errorCode, failureCategory: failure?.category, failureKind: failure?.kind },
'Delivery callback',
);
res.type('application/xml').status(200).send(TWIML_EMPTY);
if (!sid) return;
@ -32,14 +38,13 @@ export function buildTwilioRouter(bot: WebexBot): Router {
});
if (!messageLog?.space) return;
// Persist the latest delivery status
try {
await prisma.message.update({ where: { sid }, data: { status } });
} catch (err) {
logger.warn({ err, sid }, 'Failed to persist delivery status');
}
const card = CardBuilder.deliveryStatusCard(status, messageLog.body);
const card = CardBuilder.deliveryStatusCard(status, messageLog.body, failure);
try {
await WebexService.sendCard(messageLog.space.roomId, card, `Delivery: ${status}`);
} catch (err) {

View file

@ -128,15 +128,41 @@ export const CardBuilder = {
return card(body, [{ type: 'Action.Submit', title: 'Reply', data: { cardType: 'replyMessage' } }]);
},
deliveryStatusCard(status: string, body: string): AdaptiveCard {
deliveryStatusCard(
status: string,
body: string,
failure?: { userMessage: string; kind: 'retryable' | 'terminal'; code?: number },
): AdaptiveCard {
let title = 'Message Sent';
let color = 'Default';
if (status === 'delivered') { title = 'Message Delivered'; color = 'Good'; }
else if (status === 'failed' || status === 'undelivered') { title = 'Message Failed'; color = 'Attention'; }
return card([
const blocks: AdaptiveCard['body'] = [
{ type: 'TextBlock', text: title, size: 'Medium', weight: 'Bolder', color },
{ type: 'TextBlock', text: body || '(message content not stored)', wrap: true, separator: true },
{ type: 'TextBlock', text: `Status: ${status}`, size: 'Small', color: 'Accent' },
]);
];
if (failure) {
blocks.push({
type: 'TextBlock',
text: failure.userMessage,
wrap: true,
size: 'Small',
color: 'Attention',
separator: true,
});
const details: string[] = [];
if (failure.code != null) details.push(`Twilio code ${failure.code}`);
details.push(failure.kind === 'retryable' ? 'retryable' : 'terminal');
blocks.push({
type: 'TextBlock',
text: details.join(' · '),
size: 'Small',
isSubtle: true,
spacing: 'None',
});
}
return card(blocks);
},
};

View file

@ -1,13 +1,16 @@
// src/services/PendingMessageStore.ts
//
// Tracks SMS sends that are waiting on the user to upload an attachment.
// Behind an interface so the storage backend can be swapped (DB, Redis, etc.)
// without touching call sites.
// Behind an interface so the storage backend can be swapped without touching
// call sites. Default backend is Prisma-backed so pending messages survive
// process restarts.
import prisma from './PrismaService.ts';
import logger from './Logger.ts';
export interface PendingMessage {
roomId: string;
text: string;
spaceId: string;
createdAt: Date;
}
@ -20,9 +23,8 @@ export interface PendingMessageStore {
}
/**
* In-memory implementation. Loses state on process restart. Suitable for
* single-instance deployments. Swap for a DB-backed impl when horizontal
* scaling or restart-resilience matter.
* In-memory implementation. Loses state on process restart. Useful for
* tests and quick local runs. Not the default in production.
*/
export class InMemoryPendingMessageStore implements PendingMessageStore {
private readonly store = new Map<string, PendingMessage>();
@ -52,5 +54,74 @@ export class InMemoryPendingMessageStore implements PendingMessageStore {
}
}
// Default singleton. Swap the assignment to use a different backend.
export const pendingMessageStore: PendingMessageStore = new InMemoryPendingMessageStore();
/**
* Prisma-backed implementation. Survives process restarts. `PendingMessage`
* has `roomId` as its primary key, so `set` is an upsert a fresh send
* cleanly overwrites any prior stale pending entry for the same room.
* FK on Space ensures orphaned rows are cascaded away when a space is deleted.
*/
export class PrismaPendingMessageStore implements PendingMessageStore {
async set(roomId: string, msg: Omit<PendingMessage, 'createdAt'>): Promise<void> {
await prisma.pendingMessage.upsert({
where: { roomId },
create: { roomId, text: msg.text },
update: { text: msg.text, createdAt: new Date() },
});
}
async get(roomId: string): Promise<PendingMessage | undefined> {
const row = await prisma.pendingMessage.findUnique({ where: { roomId } });
if (!row) return undefined;
return { roomId: row.roomId, text: row.text, createdAt: row.createdAt };
}
async delete(roomId: string): Promise<void> {
try {
await prisma.pendingMessage.delete({ where: { roomId } });
} catch (err: any) {
// P2025 = record not found — safe to swallow, matches Map#delete semantics.
if (err?.code !== 'P2025') {
logger.warn({ err, roomId }, 'Failed to delete pending message');
throw err;
}
}
}
async sweep(maxAgeMs: number): Promise<number> {
const cutoff = new Date(Date.now() - maxAgeMs);
const { count } = await prisma.pendingMessage.deleteMany({
where: { createdAt: { lt: cutoff } },
});
return count;
}
}
/**
* Default singleton. Prisma-backed for durability. Reassign in tests or swap
* to in-memory via env if you ever need to.
*/
export const pendingMessageStore: PendingMessageStore = new PrismaPendingMessageStore();
/**
* Kick off a periodic sweep of stale pending messages. Returns the timer so
* callers (e.g. the server bootstrap) can `clearInterval` on shutdown.
* No-op in test env to avoid open-handle leaks.
*/
export function startPendingMessageSweeper(
store: PendingMessageStore = pendingMessageStore,
maxAgeMs: number = 24 * 60 * 60 * 1000,
intervalMs: number = 60 * 60 * 1000,
): NodeJS.Timeout | undefined {
if (process.env.NODE_ENV === 'test') return undefined;
const timer = setInterval(async () => {
try {
const removed = await store.sweep(maxAgeMs);
if (removed > 0) logger.info({ removed }, 'Swept stale pending messages');
} catch (err) {
logger.warn({ err }, 'Pending message sweep failed');
}
}, intervalMs);
// Don't keep the process alive just for the sweeper.
timer.unref?.();
return timer;
}

View file

@ -0,0 +1,123 @@
// tests/integration/twilioRoutes.test.ts
//
// End-to-end coverage of the Twilio-facing routes: signature validation,
// TwiML response, and error-path resilience. The bot is stubbed — we only
// care about the HTTP surface here.
//
// The Prisma-backed lookups fire after `res.send()`, so the response is
// deterministic even without a populated database. The setup file points
// DATABASE_URL at `file:./prisma/dev.db`, which exists and has the schema
// applied from the initial migration.
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import twilio from 'twilio';
import type { Express } from 'express';
import { createApp } from '../../src/app.ts';
import prisma from '../../src/services/PrismaService.ts';
const AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN!;
const PUBLIC_BASE = process.env.WEBEX_PUBLIC_URL!.replace(/\/$/, '');
/** Minimal shape createApp/buildTwilioRouter actually reaches into. */
function makeStubBot() {
return {
webex: {
messages: {
create: async () => ({ id: 'stub' }),
},
},
} as any;
}
function sign(path: string, params: Record<string, string>): string {
return twilio.getExpectedTwilioSignature(AUTH_TOKEN, `${PUBLIC_BASE}${path}`, params);
}
describe('Twilio routes (integration)', () => {
let app: Express;
beforeAll(() => {
app = createApp(makeStubBot());
});
afterAll(async () => {
try { await prisma.$disconnect(); } catch { /* ignore */ }
});
describe('POST /sms', () => {
it('rejects unsigned requests with 403', async () => {
const res = await request(app)
.post('/sms')
.type('form')
.send({ From: '+15551234567', To: '+15557654321', Body: 'hi', NumMedia: '0' });
expect(res.status).toBe(403);
});
it('rejects requests with a bad signature', async () => {
const res = await request(app)
.post('/sms')
.set('X-Twilio-Signature', 'nope')
.type('form')
.send({ From: '+15551234567', To: '+15557654321', Body: 'hi', NumMedia: '0' });
expect(res.status).toBe(403);
});
it('accepts a signed inbound SMS and returns empty TwiML', async () => {
const params = { From: '+15551234567', To: '+15557654321', Body: 'test body', NumMedia: '0' };
const res = await request(app)
.post('/sms')
.set('X-Twilio-Signature', sign('/sms', params))
.type('form')
.send(params);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/xml/);
expect(res.text).toContain('<Response>');
expect(res.text).toContain('</Response>');
});
});
describe('POST /callback', () => {
it('rejects unsigned requests with 403', async () => {
const res = await request(app)
.post('/callback')
.type('form')
.send({ SmsSid: 'SM123', SmsStatus: 'delivered' });
expect(res.status).toBe(403);
});
it('accepts a signed callback and returns TwiML', async () => {
const params = { SmsSid: 'SM_test_' + Date.now(), SmsStatus: 'delivered' };
const res = await request(app)
.post('/callback')
.set('X-Twilio-Signature', sign('/callback', params))
.type('form')
.send(params);
expect(res.status).toBe(200);
expect(res.text).toContain('<Response>');
});
it('accepts a signed failure callback with error code', async () => {
const params = {
SmsSid: 'SM_fail_' + Date.now(),
SmsStatus: 'failed',
ErrorCode: '30003',
ErrorMessage: 'Unreachable',
};
const res = await request(app)
.post('/callback')
.set('X-Twilio-Signature', sign('/callback', params))
.type('form')
.send(params);
expect(res.status).toBe(200);
});
});
describe('GET /healthz', () => {
it('returns 200 without a signature', async () => {
const res = await request(app).get('/healthz');
expect(res.status).toBe(200);
});
});
});

View file

@ -0,0 +1,49 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { InMemoryPendingMessageStore } from '../../src/services/PendingMessageStore.ts';
// The Prisma-backed impl is exercised by integration tests. Unit tests here
// pin down the semantics of the interface using the in-memory reference impl.
describe('InMemoryPendingMessageStore', () => {
let store: InMemoryPendingMessageStore;
beforeEach(() => { store = new InMemoryPendingMessageStore(); });
it('round-trips set → get', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'hello' });
const got = await store.get('room-1');
expect(got?.text).toBe('hello');
expect(got?.roomId).toBe('room-1');
expect(got?.createdAt).toBeInstanceOf(Date);
});
it('returns undefined for unknown rooms', async () => {
expect(await store.get('nope')).toBeUndefined();
});
it('overwrites on repeated set (same roomId)', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'first' });
await store.set('room-1', { roomId: 'room-1', text: 'second' });
expect((await store.get('room-1'))?.text).toBe('second');
});
it('delete is idempotent', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'x' });
await store.delete('room-1');
await expect(store.delete('room-1')).resolves.not.toThrow();
expect(await store.get('room-1')).toBeUndefined();
});
it('sweep removes only entries older than maxAge', async () => {
await store.set('young', { roomId: 'young', text: 'y' });
await store.set('old', { roomId: 'old', text: 'o' });
// Backdate the 'old' entry beyond the sweep cutoff.
const oldEntry = await store.get('old');
if (oldEntry) oldEntry.createdAt = new Date(Date.now() - 1000 * 60 * 60 * 24);
const removed = await store.sweep(1000 * 60 * 60); // 1 hour
expect(removed).toBe(1);
expect(await store.get('young')).toBeDefined();
expect(await store.get('old')).toBeUndefined();
});
});

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { classifyTwilioError, classifyDeliveryFailure } from '../../src/lib/twilioErrors.ts';
describe('classifyTwilioError', () => {
it('classifies known code 20429 as retryable rate_limit', () => {
const c = classifyTwilioError({ code: 20429, status: 429, message: 'Too many requests' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('rate_limit');
expect(c.code).toBe(20429);
});
it('classifies known code 21211 as terminal invalid_number', () => {
const c = classifyTwilioError({ code: 21211, status: 400, message: 'Invalid To' });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('invalid_number');
});
it('classifies known code 20003 as terminal authentication', () => {
const c = classifyTwilioError({ code: 20003, status: 401 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('authentication');
});
it('falls back to HTTP 500 → retryable server', () => {
const c = classifyTwilioError({ status: 502, message: 'Bad gateway' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('server');
});
it('falls back to HTTP 401 → terminal authentication', () => {
const c = classifyTwilioError({ status: 401 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('authentication');
});
it('falls back to HTTP 4xx → terminal unknown', () => {
const c = classifyTwilioError({ status: 418 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('unknown');
});
it('classifies ETIMEDOUT as retryable network', () => {
const c = classifyTwilioError({ errno: 'ETIMEDOUT', message: 'connect ETIMEDOUT' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('network');
});
it('never throws on odd input shapes', () => {
expect(() => classifyTwilioError(null)).not.toThrow();
expect(() => classifyTwilioError('string error')).not.toThrow();
expect(() => classifyTwilioError(undefined)).not.toThrow();
expect(() => classifyTwilioError(new Error('boom'))).not.toThrow();
});
it('produces a user-safe message with no stack trace', () => {
const c = classifyTwilioError({ code: 21211, message: 'x'.repeat(500) });
expect(c.userMessage).not.toMatch(/xxxxx/);
expect(c.userMessage.length).toBeLessThan(200);
});
});
describe('classifyDeliveryFailure', () => {
it('returns undefined when there is no error code', () => {
expect(classifyDeliveryFailure(undefined)).toBeUndefined();
expect(classifyDeliveryFailure('')).toBeUndefined();
expect(classifyDeliveryFailure(null)).toBeUndefined();
});
it('classifies 30003 → terminal unreachable', () => {
const c = classifyDeliveryFailure('30003');
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('unreachable');
expect(c?.code).toBe(30003);
});
it('classifies 30007 → terminal carrier_violation (spam)', () => {
const c = classifyDeliveryFailure(30007);
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('carrier_violation');
});
it('classifies unknown numeric codes as terminal unknown', () => {
const c = classifyDeliveryFailure(99999);
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('unknown');
expect(c?.code).toBe(99999);
});
it('returns undefined on non-numeric strings', () => {
expect(classifyDeliveryFailure('nope')).toBeUndefined();
});
});