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>
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
// src/routes/twilio.ts
|
|
import { Router } from 'express';
|
|
import prisma from '../services/PrismaService.ts';
|
|
import logger from '../services/Logger.ts';
|
|
import { WebexService } from '../services/WebexService.ts';
|
|
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>';
|
|
|
|
export function buildTwilioRouter(bot: WebexBot): Router {
|
|
const router = Router();
|
|
|
|
// -------- Status callback (delivered / failed) --------
|
|
router.post(
|
|
'/callback',
|
|
twilioSignature,
|
|
asyncHandler(async (req, res) => {
|
|
const sid = req.body.SmsSid as string | undefined;
|
|
const status = (req.body.SmsStatus as string | undefined) ?? 'unknown';
|
|
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;
|
|
|
|
const messageLog = await prisma.message.findUnique({
|
|
where: { sid },
|
|
include: { space: true },
|
|
});
|
|
if (!messageLog?.space) return;
|
|
|
|
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, failure);
|
|
try {
|
|
await WebexService.sendCard(messageLog.space.roomId, card, `Delivery: ${status}`);
|
|
} catch (err) {
|
|
logger.error({ err, roomId: messageLog.space.roomId }, 'Failed to post delivery card');
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------- Inbound SMS / MMS --------
|
|
router.post(
|
|
'/sms',
|
|
twilioSignature,
|
|
asyncHandler(async (req, res) => {
|
|
const smsPhone = req.body.From as string;
|
|
const wbxTmPhone = req.body.To as string;
|
|
const smsText = (req.body.Body as string | undefined) ?? '';
|
|
const numMedia = parseInt((req.body.NumMedia as string | undefined) ?? '0', 10);
|
|
|
|
logger.info({ from: smsPhone, to: wbxTmPhone, numMedia }, 'Inbound SMS');
|
|
|
|
res.type('application/xml').status(200).send(TWIML_EMPTY);
|
|
|
|
const space = await prisma.space.findFirst({
|
|
where: { smsPhone, wbxTmPhone },
|
|
});
|
|
if (!space) {
|
|
logger.info({ smsPhone, wbxTmPhone }, 'No matching space for inbound SMS');
|
|
return;
|
|
}
|
|
|
|
// Persist the inbound message
|
|
try {
|
|
await prisma.message.create({
|
|
data: {
|
|
roomId: space.roomId,
|
|
from: smsPhone,
|
|
body: smsText,
|
|
mediaUrls: '[]',
|
|
status: 'received',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err, roomId: space.roomId }, 'Failed to persist inbound SMS');
|
|
}
|
|
|
|
// Forward attachments to Webex
|
|
for (let i = 0; i < numMedia; i++) {
|
|
const mediaUrl = req.body[`MediaUrl${i}`] as string | undefined;
|
|
if (!mediaUrl) continue;
|
|
try {
|
|
const filename = await downloadTwilioMedia(mediaUrl);
|
|
await bot.webex.messages.create({
|
|
roomId: space.roomId,
|
|
files: [publicMediaUrl(filename)],
|
|
});
|
|
} catch (err) {
|
|
logger.error({ err, mediaUrl }, 'Failed to forward inbound media to Webex');
|
|
}
|
|
}
|
|
|
|
const card = CardBuilder.incomingMessageCard({
|
|
from: space.smsName,
|
|
text: smsText,
|
|
numMedia,
|
|
});
|
|
|
|
try {
|
|
await WebexService.sendCard(space.roomId, card, `Incoming from ${smsPhone}`);
|
|
} catch (err) {
|
|
logger.error({ err, roomId: space.roomId }, 'Failed to post inbound card');
|
|
}
|
|
}),
|
|
);
|
|
|
|
return router;
|
|
}
|