import { Router, type Request, type Response } from 'express'; import crypto from 'node:crypto'; import type { EditQueue } from '../queue/edit-queue.js'; import { parseTelnyxInboundMessage } from '../sms/parse.js'; import { sendSms } from '../sms/reply.js'; import { SMS_TEMPLATES } from '../sms/templates.js'; import { isOwnNumber, findAuthorizedSite } from '../sms/config.js'; import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js'; import { logger, maskPhone } from '../logger.js'; export interface WebhookSmsRouterDeps { queue: EditQueue; } export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router { const router = Router(); router.post('/telnyx', (req: Request, res: Response) => { // Respond quickly res.status(200).json({ status: 'received' }); // Process async handleInbound(req.body, deps).catch(err => { logger.error({ event: 'sms.handler_error', error: (err as Error).message }, 'SMS handler error'); }); }); return router; } async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) { const parsed = parseTelnyxInboundMessage(body); if (!parsed) { logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS'); return; } const { messageId, from, to, text, hasMedia } = parsed; // Skip messages from our own numbers (prevents echo/loop from delivery receipts) if (isOwnNumber(from)) { logger.info({ event: 'sms.own_number_skipped', from: maskPhone(from), messageId }, 'Skipped message from own number'); return; } // Check if sender is authorized for this site // - null: config loaded, sender NOT authorized → block // - undefined: config unavailable → allow through (fail open) // - object: config loaded, sender authorized → allow const site = findAuthorizedSite(from, to); if (site === null) { logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender'); return; } const phoneHash = hashPhone(from); logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId, siteId: site?.siteId }, 'Inbound SMS'); // Idempotency check if (messageId && !claimOnce(`sms:${messageId}`, 3600)) { logger.info({ event: 'sms.idempotent_skip', messageId }, 'Duplicate SMS skipped'); return; } // MMS check if (hasMedia) { logger.info({ event: 'sms.received_image', from: maskPhone(from), messageId }, 'Received image'); await sendSms(from, to, SMS_TEMPLATES.MMS_NOT_SUPPORTED()); return; } logger.info({ event: 'sms.received_text', from: maskPhone(from), text, messageId }, 'Received text'); // Rate limit const maxPerHour = parseInt(process.env.SMS_RATE_LIMIT_PER_HOUR || '10', 10); if (!checkSmsRateLimit(phoneHash, maxPerHour)) { logger.info({ event: 'sms.rate_limited', phone: maskPhone(from) }, 'SMS rate limited'); await sendSms(from, to, SMS_TEMPLATES.RATE_LIMITED()); return; } const upperText = text.toUpperCase().trim(); // Check for YES/NO confirmation if (upperText === 'YES' || upperText === 'Y') { const pending = getPendingProposalByPhone(phoneHash); if (!pending) { await sendSms(from, to, SMS_TEMPLATES.PROPOSAL_EXPIRED()); return; } try { deps.queue.enqueue({ kind: 'apply', id: crypto.randomUUID(), proposal_id: pending.proposal_id, source: 'sms', smsReplyMeta: { from, to }, }); } catch { await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE()); } return; } if (upperText === 'NO' || upperText === 'N') { const pending = getPendingProposalByPhone(phoneHash); if (pending) { updateProposalStatus(pending.proposal_id, 'rejected'); logger.info({ event: 'proposal.rejected', proposalId: pending.proposal_id }, 'Proposal rejected via SMS'); } await sendSms(from, to, SMS_TEMPLATES.REJECTED()); return; } // Check if there's a pending proposal — if user sends something other than YES/NO const pending = getPendingProposalByPhone(phoneHash); if (pending) { // New message while proposal pending — could be a new edit or invalid confirm // Expire old proposal and start fresh updateProposalStatus(pending.proposal_id, 'expired'); } // New propose job try { deps.queue.enqueue({ kind: 'propose', id: crypto.randomUUID(), message: text, source: 'sms', smsReplyMeta: { from, to }, }); } catch { await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE()); } }