/** * `message` — deliver one direct Telegram message to the project's own chat and forum topic, applying the same availability policy as a tick. * * A question-shaped message — text that begins with the floor's `QUESTION:` * marker, or an explicitly declared escalation `--category` that is not * `material` — is an ask, not a send: the row is recorded before anything is * delivered (#520), so "asked once, no reply" is a durable open decision row * re-surfaced in every tick until answered or the seven-day expiry, and the * caller never has to remember a separate `decision open`. * * Moved out of cli.ts's switch by the per-verb module split (#462); * only the case wrapper, the injected `ctx` lookups and the imports * changed from the original bodies. */ import type { CommandContext } from "./context.ts"; import { randomUUID } from "node:crypto"; import { availabilityState, formatNextWindowOpening } from "../availability.ts"; import { findProject, loadConfig } from "../config.ts"; import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts"; import { dbPath, openStore } from "../store.ts"; import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts"; import { validateQuestionShape } from "../ask.ts"; /** The floor's "this needs an answer" marker, as every other classifier reads it. */ const QUESTION_MARKER = /^\s*QUESTION:\s/i; export async function messageCommand(ctx: CommandContext): Promise { const rawText = ctx.flag("text"); const body = rawText?.trim(); if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) { process.stderr.write("omp-conductor: message needs --text with the message to deliver\n"); process.exit(2); } const categoryRaw = ctx.flag("category"); if ( categoryRaw !== undefined && !(INTERRUPT_CATEGORIES as readonly string[]).includes(categoryRaw) ) { process.stderr.write( `omp-conductor: message category must be one of ${INTERRUPT_CATEGORIES.join(", ")} — a question's category is part of the ask, never a text prefix\n`, ); process.exit(2); } const category = categoryRaw as InterruptCategory | undefined; const blocks = ctx.flag("blocks")?.trim(); const project = findProject(loadConfig(), ctx.projectFlag); const store = openStore(dbPath()); // The durable row exists before the delivery attempt, exactly like the // `conductor_ask` tool: a crash at any point leaves a recorded question, so // the "asked, awaiting the operator" half of the ask cannot be lost. Silence // means the row stays open — re-surfaced in every tick until answered or the // seven-day expiry; it never records an approval. const effectiveCategory = category ?? operatorMessageCategory(body); const isAsk = QUESTION_MARKER.test(body) || effectiveCategory !== "material"; let row: DecisionRecord | undefined; let outcome: OperatorMessageOutcome; try { if (isAsk) { const question = body.replace(QUESTION_MARKER, "").trim(); const shape = validateQuestionShape(question); if (!shape.ok) { process.stderr.write(`omp-conductor: message: ${shape.problem}\n`); process.exit(2); } row = store.createDecision({ project: project.name, // The row archives the question, not the delivery prefix. question, ...(blocks === undefined ? {} : { blocks }), at: Date.now(), }); } outcome = await deliverOperatorMessage(project, body, { store, at: Date.now(), noticeId: randomUUID(), ...(category === undefined ? {} : { category }), }); } catch (err) { process.stderr.write( `omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`, ); process.exit(2); } finally { store.close(); } if (row !== undefined) { process.stdout.write( `decision ${row.id} recorded — parked on silence: the question stays open until answered or the seven-day expiry. ` + `Resolve it later with: omp-conductor decision resolve ${row.id} --answer "..."\n`, ); } process.stdout.write( outcome.kind === "sent" ? // Not "into the topic": a stale topic degrades to the flat chat with // its own warning on stderr, and this line must not contradict it. `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n` : heldLine(project, outcome), ); } /** * A hold reaches nobody now, so it is the wrong path for an answer somebody is * waiting for (#882). * * 2026-08-21T20:48Z: a tick was interrupted by an operator message, the mandated * targetless `telegram_send` failed, and the session fell back to this command. * It behaved correctly — the category was non-bypass, so the answer was held for * the 23:30 digest — and the operator sat waiting in the topic they had written * in. "nothing was sent" was already printed and was already true; what was * missing is that the caller is *mid-conversation* and a live path exists. * * So every hold names it. Conditionally, because this process cannot know * whether an inbound message is waiting: it is a separate process from the * session, and inventing a guess here would either nag every legitimate * digest-only note or, worse, quietly bypass availability policy on a hunch. * The caller knows, and one sentence is enough to make holding a choice rather * than a default. */ const LIVE_REPLY_HINT = "if you are answering an operator who is waiting now, this is not the path: " + "reply in their own topic with telegram_send (telegram_ask for a choice) — a held notice reaches nobody until then\n"; /** The held-notice line names the future that releases it, in words an * operator can act on: the working-hours window opening, or the daily digest. * The earlier spelling — "the next digest or working-hours catch-up" — read * the same for both and hid exactly the 23-hour hold that #596 is about. */ function heldLine( project: ProjectConfig, outcome: Extract, ): string { if (outcome.reason === "availability") { const state = availabilityState(project.reporting, Date.now()); const opening = state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined ? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}` : ""; return ( `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` + `held until the working-hours window opens${opening})\n` + "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n" + LIVE_REPLY_HINT ); } const digestAt = project.reporting?.digest.at; const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`; return ( `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` + `digest-only — held until ${when})\n` + `nothing was sent; the daemon releases it with ${when}\n` + LIVE_REPLY_HINT ); }