// ── 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, }