// 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 = ''; 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; }