First cut

This commit is contained in:
kadil
2026-04-17 16:08:31 -05:00
parent d10105ac00
commit 4ee4cb8e7c
58 changed files with 3243 additions and 1 deletions

View File

@@ -0,0 +1,115 @@
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 { 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;
const phoneHash = hashPhone(from);
logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId }, '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) {
await sendSms(from, to, SMS_TEMPLATES.MMS_NOT_SUPPORTED());
return;
}
// 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());
}
}