// The notifier (lead decision 70): every poll it reads the human inbox // through a reader capability, DMs each open blocking decision once, and // sends one digest a day at 08:00 America/Chicago. It never writes to the // bus; its memory is the journal `/notify//sent.jsonl` // (0600, in a 0700 directory), one line per send attempt: // // {at, kind: "dm"|"digest", decision, day?, outcome: "confirmed"|"refused"|"unknown"|"gave-up", messageId, status?} // // A decision counts as sent once it has a confirmed line; a day's digest // likewise. A refused or unknown send is retried with backoff (30 s // doubling to 30 min): a duplicate costs less than a miss, and Discord's // nonce folds a retry inside its dedupe window into the first message. // The exception is a definite refusal, an HTTP 4xx other than 429 (lead // decision 72): the next try waits the full 30 min, counted from the // refusal's line so a restart cannot shorten it (lead decision 73), and // after five for one decision, counted from the journal, the notifier // appends one `gave-up` line, logs once and stops sending that DM. Five // tries span two hours, long enough to fix a binding or token typo. The // digest marks it "DM refused, not retried"; recovery is the operator // deciding it. Unknown outcomes (network, 5xx, 429) retry without a limit. // No Discord channel or user id goes in the journal, a log line or an // error; the Discord side (packages/discord/src/notify.mjs) keeps them. import { createHash } from "node:crypto"; import { accessSync, closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; import { dirname, join } from "node:path"; import { CliError } from "./errors.mjs"; import { authorizationLines, optionsLine, shortId } from "./format.mjs"; export const ZONE = "America/Chicago"; export const DIGEST_HOUR = 8; export const POLL_MS = 30000; const BACKOFF_MS = 30000; const BACKOFF_MAX_MS = 30 * 60 * 1000; const LIMIT = 2000; export const REFUSAL_LIMIT = 5; export const journalPath = (dataRoot, business) => join(dataRoot, "notify", business, "sent.jsonl"); // Local date and hour in the IANA zone. An unknown zone throws RangeError, // so a broken time-zone database refuses rather than guessing. export function zoned(date, zone = ZONE) { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" }) .formatToParts(date) .map((p) => [p.type, p.value]), ); return { day: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) }; } // Discord nonces are at most 25 characters. The digest nonce carries the // business, so two businesses sharing a bot and a recipient never send the // same nonce on the same day. const hash23 = (text) => createHash("sha256").update(text).digest("hex").slice(0, 23); export const dmNonce = (id) => `dm${hash23(String(id))}`; export const digestNonce = (business, day) => `dg${hash23(`${business}\n${day}`)}`; function clip(text, max) { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } export function dmContent(business, d) { const lines = [ `Mosaic (${business}): a blocking decision needs you.`, clip(d.question, 1000), `options: ${optionsLine(d)} (recommended: ${d.recommendation})`, ...authorizationLines(d), `raised by ${d.raised_by_role}${d.task_ref ? `, task ${d.task_ref}` : ""}`, `decide: mosaic decide ${shortId(d.id)}