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>
137 lines
4.5 KiB
TypeScript
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());
|
|
}
|
|
}
|