Files
dynamic-sites-simple/server/src/routes/webhook-sms.ts
Khalid A 498d873c47 Fix SMS config path resolution and fail-open when config missing
The config loader now searches multiple paths for sms-sites.json
(REPO_ROOT-based, /app/, and CWD-relative) so it works regardless
of deployment environment. When config can't be loaded, the auth
check fails open (allows messages through) rather than blocking
everything. The isOwnNumber check still returns false when config
is unavailable since we can't identify our own numbers without it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:03:15 -05:00

137 lines
4.5 KiB
TypeScript

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());
}
}