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
133 lines
5.1 KiB
JavaScript
133 lines
5.1 KiB
JavaScript
// ── 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 }
|