First cut
This commit is contained in:
115
server/src/routes/webhook-sms.ts
Normal file
115
server/src/routes/webhook-sms.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user