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
128 lines
5.6 KiB
JavaScript
128 lines
5.6 KiB
JavaScript
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
|
//
|
|
// No DB, no network — just the decisions the worker and model make, kept here so
|
|
// they are unit-testable in isolation (server/test/announceJobs.test.js):
|
|
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
|
|
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
|
|
// / terminal, so a data problem fails fast and a transient outage retries
|
|
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
|
// • rollupStatus — derive the parent job status from the two legs
|
|
|
|
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
|
|
|
// Sidecar town-crier caps, mirrored from the admin route validation
|
|
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
|
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
|
const MAX_LINES = 8
|
|
const MAX_LINE_LEN = 200
|
|
|
|
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
|
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
|
// the post's admin panel. Shared by both legs.
|
|
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
|
const MAX_ATTEMPTS = BACKOFF_MS.length
|
|
|
|
// Trim to a hard length, appending an ellipsis only when something was cut.
|
|
function clamp(value, max) {
|
|
const s = String(value == null ? '' : value)
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
if (s.length <= max) return s
|
|
return `${s.slice(0, max - 1).trimEnd()}…`
|
|
}
|
|
|
|
// The public link that goes in the announcement. News has no per-post route
|
|
// (App.jsx only has the /site/news list), so we link the list — matches the
|
|
// pre-pipeline Discord announce behavior.
|
|
function articleUrl(baseUrl) {
|
|
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
|
|
}
|
|
|
|
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
|
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
|
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
|
function buildTownCrierText(post, { baseUrl } = {}) {
|
|
const title = clamp(post.title, MAX_LINE_LEN)
|
|
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
|
const lines = [title]
|
|
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
|
if (excerpt) lines.push(excerpt)
|
|
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
|
|
if (url) lines.push(url)
|
|
return lines.filter(Boolean).slice(0, MAX_LINES)
|
|
}
|
|
|
|
// ── Result classification ──────────────────────────────────────────────────
|
|
// Both clients return { ok, status, error }. Map that to one of:
|
|
// done — delivered, mark the leg done
|
|
// retry — transient (shard restarting, bot down, network); back off + retry
|
|
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
|
|
|
|
function classifyTownCrier(result) {
|
|
if (result && result.ok) return { outcome: 'done' }
|
|
const status = result ? result.status : 0
|
|
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
|
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
|
if (status === 400 || status === 401 || status === 409) {
|
|
return { outcome: 'terminal', error: legError(result) }
|
|
}
|
|
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
|
// configured yet), and any other 5xx are transient — retry.
|
|
return { outcome: 'retry', error: legError(result) }
|
|
}
|
|
|
|
function classifyDiscord(result) {
|
|
if (result && result.ok) return { outcome: 'done' }
|
|
// The bot's /internal/announce collapses failures (503 = not connected,
|
|
// 400 = no news channel configured) without surfacing Discord's own
|
|
// retry_after, so there is no reliable terminal signal to key on here. Retry
|
|
// every failure on the shared backoff; a genuine config problem simply
|
|
// exhausts its attempts and lands as `failed` in the admin panel, where the
|
|
// per-leg retry button re-runs it after the channel is set.
|
|
return { outcome: 'retry', error: legError(result) }
|
|
}
|
|
|
|
function legError(result) {
|
|
if (!result) return 'no response'
|
|
if (result.status) {
|
|
return result.data && result.data.message
|
|
? `${result.status}: ${result.data.message}`
|
|
: result.error || `status ${result.status}`
|
|
}
|
|
return result.error || 'request failed'
|
|
}
|
|
|
|
// Given the number of attempts already made (>= 1), how long to wait before the
|
|
// next one — or null when the cap is reached and the leg should be failed.
|
|
function scheduleAfter(attempts) {
|
|
if (attempts >= MAX_ATTEMPTS) return null
|
|
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
|
}
|
|
|
|
// Parent job status derived from the two leg statuses:
|
|
// done — both legs delivered
|
|
// failed — both legs gave up
|
|
// partial — at least one leg reached a terminal state while the other has not
|
|
// matched it (still pending/retrying, or the opposite terminal state)
|
|
// pending — neither leg is terminal yet
|
|
function rollupStatus(towncrierStatus, discordStatus) {
|
|
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
|
|
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
|
|
const terminal = (s) => s === 'done' || s === 'failed'
|
|
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
|
return 'pending'
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_LINES,
|
|
MAX_LINE_LEN,
|
|
MAX_ATTEMPTS,
|
|
BACKOFF_MS,
|
|
buildTownCrierText,
|
|
articleUrl,
|
|
classifyTownCrier,
|
|
classifyDiscord,
|
|
scheduleAfter,
|
|
rollupStatus,
|
|
}
|