/** * Inbound replies — turning a message from a phone into an acknowledgement. * * Authentication is two factors, both cheap: * * 1. the token must be valid, unexpired, and unconsumed * 2. the SENDER must be the address that token was issued to * * Either alone is insufficient. A token seen over someone's shoulder is * useless from another number; a known number is useless without a live token. * * A message from an address matching no route is discarded and logged WITHOUT * a reply. Replying would confirm the number is live and that this is a celilo * instance, which is free reconnaissance for anyone probing. * * ── ORDER MATTERS: RESOLVE THE TOKEN, THEN PICK THE GRAMMAR ──────────────── * * The same table carries alert pages and deploy questions, and they want * OPPOSITE parsing. An alert reply is a token and at most a verb; anything * wordier is refused, because ` resolve` silently acknowledging an * alert the operator meant to escalate is the worst outcome here. An interview * answer is a token followed by arbitrary text, because the text IS the answer. * * Parsing before resolving cannot serve both, and #533 is what that cost: the * alert grammar ran first, read every real answer as a sentence, and rejected * it before the poller could ever see it was a question. The feature was dead * for months and the operator was told `unrecognised`, which reads as their * typing being wrong. * * A delivery knows its own kind, so the token is resolved FIRST and the * grammar chosen from what it named. Which rule applies was never a property * of the text. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { type NotificationDelivery, type Route, routes } from '../../db/schema'; import { findLiveDeliveriesByPrefix, normaliseToken } from './tokens'; /** * v1 grammar: a token acknowledges. `snooze` and `resolve` verbs are deferred — * ack is what an operator does at 3am, and every extra verb is another thing to * mistype under stress. * * Deliberately forgiving about everything EXCEPT what the message means. An * earlier cut accepted ` ack` but not `ack `, so an operator who * read "reply BPRJEH" and typed the natural "ack BPRJEH" was refused — the verb * was parsed as the token, failed the length check, and the reply was rejected. * That is a real page going unacknowledged over word order, so: * * - the ack verb may come BEFORE the token, AFTER it, or not at all * - case never matters, for the verb or the token * - the token may be shortened to any unambiguous prefix (see below) * * `token` here is a CANDIDATE, not necessarily a whole token: resolution * against what is actually outstanding happens in `interpretInbound`, because * only the database knows which prefixes are unique right now. * * A bare ack with no token at all is accepted when the sender has exactly one * outstanding delivery, since a token adds nothing when there is no ambiguity. */ export type InboundIntent = | { kind: 'ack'; token: string } | { kind: 'bare_ack' } | { kind: 'unrecognised' }; const ACK_VERB = /^(ack|ok|k|👍)$/iu; /** * A verb that may only LEAD, and does not itself acknowledge. * * Every page ends with the literal line `reply ` (`composeBody` in * modules/signal/scripts/notification.ts), and an operator who did exactly that * was told `unrecognised` while the alert kept firing — the instruction the * system gives was the one input it refused (#500). Two consecutive real * operator replies were lost to it. * * Distinct from `ACK_VERB` on purpose: an ack synonym standing alone IS an * acknowledgement (`bare_ack`), whereas `reply` alone is someone echoing the * instruction without the token, which names nothing and stays unrecognised. */ const LEADING_VERB = /^reply$/i; /** * Punctuation a phone added that the operator did not type. * * iOS turns a double space into a full stop by default, and a one-word reply is * exactly where that fires. Stripping it costs nothing: authentication here is * the token plus the sender check (see the header), never punctuation * strictness. */ function stripTrailingPunctuation(word: string): string { return word.replace(/[.,!?;:]+$/u, ''); } /** * One to six characters of the token alphabet. Not anchored to the full * length: a prefix is legal input, and whether it identifies something is a * question for the delivery table, not for a regex. */ const TOKEN_SHAPE = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{1,6}$/; export function parseInbound(body: string): InboundIntent { const words = body.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return { kind: 'unrecognised' }; // Drop a leading `reply` — instruction-echo, not content. Removed before // anything else so the rest of the grammar is entirely unaffected by whether // the operator included it. if (LEADING_VERB.test(words[0])) words.shift(); if (words.length === 0) return { kind: 'unrecognised' }; // Strip ack synonyms wherever they appear. What remains must be the token, // or nothing at all. const remainder = words.filter((word) => !ACK_VERB.test(stripTrailingPunctuation(word))); if (remainder.length === 0) return { kind: 'bare_ack' }; // More than one non-verb word is not a token with politeness around it, it // is a sentence — and a sentence may be a request celilo does not implement. // Guessing at it risks acknowledging an alert the operator meant to escalate: // an earlier cut let ` resolve` silently ACK, so the operator believed // they had cleared an alert that was still firing. Doing nothing and saying // so is strictly better than doing the wrong thing quietly. if (remainder.length > 1) return { kind: 'unrecognised' }; const token = normaliseToken(stripTrailingPunctuation(remainder[0])); if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' }; return { kind: 'ack', token }; } /** A message that leads with a token, and whatever text followed it. */ export interface TokenLedMessage { token: string; /** Everything after the token, trimmed. Empty when the token stood alone. */ rest: string; } /** * Split a message that LEADS with a token, or null if it does not. * * This is the interview grammar and it is deliberately not the alert one: the * page says `reply `, and the value is whatever follows, * verbatim. It may contain spaces, punctuation, or a word that happens to look * like a verb — second-guessing it would corrupt exactly the values an operator * cannot easily retype. * * `parseInbound` cannot serve both. Its "more than one non-verb word is a * sentence" rule is what protects an ALERT from ` resolve`, and it is * also what made every real interview answer unrecognised (#533): a value is * a sentence by construction. Which rule applies is not a property of the text * — it is a property of what the token names, and only the delivery table * knows that. So both readings are produced here and `interpretInbound` * chooses between them AFTER resolving the token. */ export function parseTokenLed(body: string): TokenLedMessage | null { const trimmed = body.trim(); if (!trimmed) return null; const boundary = trimmed.search(/\s/); const head = boundary === -1 ? trimmed : trimmed.slice(0, boundary); const token = normaliseToken(head); if (!TOKEN_SHAPE.test(token)) return null; return { token, rest: boundary === -1 ? '' : trimmed.slice(boundary).trim() }; } export type InboundOutcome = | { action: 'ack'; delivery: NotificationDelivery; route: Route } /** An answer to a deploy question. `value` is the operator's text, verbatim. */ | { action: 'answer'; delivery: NotificationDelivery; route: Route; value: string } | { action: 'ignored'; reason: 'unknown_sender' } | { action: 'rejected'; reason: | 'unknown_token' | 'wrong_sender' | 'ambiguous' | 'unrecognised' /** A question was named, but no value was supplied to answer it with. */ | 'needs_value'; }; function routeForAddress(db: DbClient, senderAddress: string): Route | undefined { return db.select().from(routes).where(eq(routes.address, senderAddress)).get(); } export interface InboundContext { senderAddress: string; body: string; now: Date; /** Live alert-kind deliveries outstanding for this route, newest first. */ outstandingForRoute(routeId: string): NotificationDelivery[]; } /** * Decide what an inbound message means. * * Returns the action to take; the caller performs it. Keeping the decision * separate is what lets every rejection path be asserted without a transport. */ export function interpretInbound(db: DbClient, context: InboundContext): InboundOutcome { const route = routeForAddress(db, context.senderAddress); // Unknown sender: drop silently. No reply, by design. if (!route) return { action: 'ignored', reason: 'unknown_sender' }; const intent = parseInbound(context.body); // A bare ack names no token, so there is nothing to resolve and nothing a // question could be answered with. It is an ALERT reply by construction — // `outstandingForRoute` supplies alert deliveries only. if (intent.kind === 'bare_ack') { const outstanding = context.outstandingForRoute(route.id); if (outstanding.length === 0) return { action: 'rejected', reason: 'unknown_token' }; if (outstanding.length > 1) return { action: 'rejected', reason: 'ambiguous' }; return { action: 'ack', delivery: outstanding[0], route }; } // Both readings of the text, neither yet privileged. The alert reading is // the more constrained one, so it names the token when it applies; otherwise // a leading token does. const led = parseTokenLed(context.body); const namesAToken = intent.kind === 'ack'; const candidate = namesAToken ? intent.token : led?.token; // Nothing token-shaped anywhere: not a reply celilo can act on. if (!candidate) return { action: 'rejected', reason: 'unrecognised' }; // A prefix is resolved against what is LIVE right now, so how much of the // token an operator must type depends on what is actually outstanding — one // alert, one character. Matching is deliberately global rather than scoped to // this route: it keeps `wrong_sender` a distinguishable answer below, and a // globally-unique prefix is a stricter bar than a per-route one. const matches = findLiveDeliveriesByPrefix(db, candidate, context.now); if (matches.length === 0) { // Ordinary words are token-shaped surprisingly often — the alphabet is // most of the Latin one, so `what is going on` leads with a perfectly // well-formed `WHAT`. Only call it a token the operator got WRONG when the // alert grammar agreed it was one; otherwise this is a sentence, and // saying `unknown_token` would send them hunting for a typo they did not // make. return { action: 'rejected', reason: namesAToken ? 'unknown_token' : 'unrecognised' }; } // Ambiguous is its own answer, never a guess. Taking the first match would // acknowledge an alert the operator did not name. if (matches.length > 1) return { action: 'rejected', reason: 'ambiguous' }; const delivery = matches[0]; // Factor two: a valid token replayed from a different number is refused. // Checked before the grammar so a stranger learns nothing about which of the // two a token names. if (delivery.routeId !== route.id) return { action: 'rejected', reason: 'wrong_sender' }; // NOW the kind is known, so the right grammar can be applied to the body. if (delivery.kind === 'interview') { // The value must follow the token the question was asked with; a verb-led // message (`ack `) names no value and is not an answer. if (!led || led.token !== candidate) return { action: 'rejected', reason: 'unrecognised' }; // Named the question but supplied nothing to answer it with. Distinct from // `unrecognised`: the operator got the token right and stopped too soon, // and telling them that is the difference between retyping six characters // and giving up. if (!led.rest) return { action: 'rejected', reason: 'needs_value' }; return { action: 'answer', delivery, route, value: led.rest }; } // An alert. The strict grammar applies, which is what keeps ` resolve` // from silently acknowledging something the operator meant to escalate. if (intent.kind !== 'ack') return { action: 'rejected', reason: 'unrecognised' }; return { action: 'ack', delivery, route }; }