News post → town crier + Discord announcement pipeline

Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:

  • town crier — sidecar POST /towncrier via uoLinkClient (stable id
    `post-<id>` so a retry replaces rather than duplicates)
  • discord    — bot POST /internal/announce via botInternalClient
    (single source of truth for the #news channel stays in the bot)

An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.

Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.

Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-11 16:25:25 -05:00
parent a2590812e0
commit 986a8d5d86
16 changed files with 790 additions and 35 deletions

View File

@@ -24,12 +24,21 @@ async function call(path, { method = 'GET', body } = {}) {
signal: controller.signal,
})
if (!res.ok) {
return { ok: false, error: `bot responded ${res.status}` }
// Include the numeric status + parsed body (if any) so callers — e.g. the
// announcement worker — can distinguish 503 (bot down, retry) from a config
// error. Non-JSON bodies just leave `data` null.
let data = null
try {
data = await res.json()
} catch {
// ignore — body already reported via status
}
return { ok: false, status: res.status, data, error: `bot responded ${res.status}` }
}
return { ok: true, data: await res.json() }
return { ok: true, status: res.status, data: await res.json() }
} catch (err) {
log.warn('bot internal call failed', { path, message: err.message })
return { ok: false, error: err.message }
return { ok: false, status: 0, error: err.message }
} finally {
clearTimeout(timeout)
}