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:
132
server/src/utils/announceWorker.js
Normal file
132
server/src/utils/announceWorker.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ── Announcement dispatcher worker ──────────────────────────────────────────
|
||||
//
|
||||
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
|
||||
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
|
||||
// enqueued or past their backoff — and dispatches each one:
|
||||
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game)
|
||||
// • discord → botInternalClient.announce (bot → #news channel)
|
||||
// Both clients never throw (they return { ok, status, error }); the model turns
|
||||
// each result into done / retry / terminal and owns the backoff + rollup. One
|
||||
// leg failing never touches the other. Same setInterval + unref + stop() shape
|
||||
// as middleware/botScore's sweeper, wired into server.js start/shutdown.
|
||||
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
|
||||
const logic = require('../model/announceJobs/announceJobs.logic')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
const log = require('./logger')('announce-worker')
|
||||
|
||||
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
|
||||
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// ── Leg dispatchers ─────────────────────────────────────────────────────────
|
||||
// Return the raw client result ({ ok, status, data, error }); classification is
|
||||
// the model/logic's job.
|
||||
|
||||
async function dispatchTownCrier(post) {
|
||||
const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
|
||||
// than stacking a duplicate, so a retry after a partial failure is safe.
|
||||
return uoLinkClient.postTownCrier({
|
||||
id: `post-${post.id}`,
|
||||
lines,
|
||||
durationSec: TOWNCRIER_DURATION_SEC,
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchDiscord(post) {
|
||||
const base = baseUrl()
|
||||
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
|
||||
// absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
return botInternalClient.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${base}/site/news`,
|
||||
imageUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// Process a single due leg of a job: fetch the post, dispatch, classify, record.
|
||||
async function processLeg(job, leg) {
|
||||
const post = await posts.getById(job.post_id)
|
||||
if (!post) {
|
||||
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps
|
||||
// the job first, but guard anyway). Nothing to announce — fail the leg.
|
||||
await announceJobs.recordOutcome(job, leg, { outcome: 'terminal', error: 'post no longer exists' })
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
let classification
|
||||
try {
|
||||
if (leg === 'towncrier') {
|
||||
result = await dispatchTownCrier(post)
|
||||
classification = logic.classifyTownCrier(result)
|
||||
} else {
|
||||
result = await dispatchDiscord(post)
|
||||
classification = logic.classifyDiscord(result)
|
||||
}
|
||||
} catch (err) {
|
||||
// Clients shouldn't throw, but if one does, treat it as a transient failure
|
||||
// rather than crashing the tick.
|
||||
log.error('dispatch threw', { jobId: job.id, leg, message: err.message })
|
||||
classification = { outcome: 'retry', error: err.message }
|
||||
}
|
||||
|
||||
await announceJobs.recordOutcome(job, leg, classification)
|
||||
}
|
||||
|
||||
// One sweep: find due jobs and process each due leg. A job may have both legs due
|
||||
// (a fresh enqueue) — process the ones that are actually pending. `job` is a
|
||||
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing
|
||||
// the two legs sequentially off the same snapshot is fine (each leg only writes
|
||||
// its own columns).
|
||||
async function tick(now = new Date()) {
|
||||
let jobs
|
||||
try {
|
||||
jobs = await announceJobsDb.findDue(now)
|
||||
} catch (err) {
|
||||
log.error('failed to load due jobs', { message: err.message })
|
||||
return
|
||||
}
|
||||
if (!jobs || jobs.length === 0) return
|
||||
|
||||
for (const job of jobs) {
|
||||
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
|
||||
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
|
||||
}
|
||||
}
|
||||
|
||||
function isLegDue(job, leg, now) {
|
||||
if (job[`${leg}_status`] !== 'pending') return false
|
||||
const next = job[`${leg}_next_attempt_at`]
|
||||
return next == null || new Date(next) <= now
|
||||
}
|
||||
|
||||
let timer = null
|
||||
|
||||
function start() {
|
||||
if (timer) return timer
|
||||
timer = setInterval(() => {
|
||||
tick().catch((err) => log.error('announce tick failed', { message: err.message }))
|
||||
}, POLL_MS)
|
||||
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user