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

@@ -3,37 +3,25 @@ const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const botInternalClient = require('../../../utils/botInternalClient')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const log = require('../../../utils/logger')('admin')
// Public base URL for links back to the site — same fallback pattern as
// sso.controller.js's redirect_uri builder.
function appBaseUrl(req) {
const configured = process.env.APP_BASE_URL
if (configured) return configured.replace(/\/+$/, '')
return `${req.protocol}://${req.get('host')}`
}
// Fire-and-forget: announce a news post to Discord the moment it actually
// transitions from unpublished to published — not on every save or on a
// no-op re-publish of an already-live post. Never throws (botInternalClient
// itself never rejects); a bot outage must never break publishing a post.
function announceIfNewlyPublished(req, post, wasPublished) {
if (!post || post.category !== 'news' || !post.published || wasPublished) return
const base = appBaseUrl(req)
// image_url is stored relative (e.g. "/uploads/xyz.png") — Discord embeds
// require an absolute URL.
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
botInternalClient
.announce({
title: post.title,
excerpt: post.excerpt,
url: `${base}/site/news`,
imageUrl,
})
.catch(() => {})
// Fire the announcement pipeline the moment a post transitions INTO
// "published news" — a false→true publish while in news, or a category change
// into news while already published. Enqueues one announce_jobs row whose two
// legs (in-game town crier + Discord #news) are then delivered with independent
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
// self-guarding (enqueueIfNeeded never throws and de-dupes via the post's
// existing announce_job_id) so a pipeline hiccup never breaks saving a post.
// Awaited (not fire-and-forget) because enqueue is purely local DB work — one
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
async function announceIfNewlyPublished(post, transition) {
await announceJobs.enqueueIfNeeded(post, transition)
}
// ── Dashboard & site mode ─────────────────────────────────────────────
@@ -120,7 +108,7 @@ async function createPost(req, res) {
author_id: req.user.id,
})
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
announceIfNewlyPublished(req, created, false)
await announceIfNewlyPublished(created, { wasPublished: false, wasNews: false })
return res.status(201).json(created)
} catch (err) {
log.error('createPost', err)
@@ -150,7 +138,10 @@ async function updatePost(req, res) {
const updated = await posts.update(id, fields)
await activity.log({ req, action: 'post.update', detail: { id } })
announceIfNewlyPublished(req, updated, Boolean(current.published))
await announceIfNewlyPublished(updated, {
wasPublished: Boolean(current.published),
wasNews: current.category === 'news',
})
return res.json(updated)
} catch (err) {
log.error('updatePost', err)
@@ -169,7 +160,10 @@ async function publishPost(req, res) {
action: 'post.publish',
detail: { id, published: Boolean(req.body.published) },
})
announceIfNewlyPublished(req, updated, Boolean(current.published))
await announceIfNewlyPublished(updated, {
wasPublished: Boolean(current.published),
wasNews: current.category === 'news',
})
return res.json(updated)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
@@ -187,6 +181,35 @@ async function deletePost(req, res) {
}
}
// GET /admin/posts/:id/announce — the announcement job for a post (or null if it
// was never announced), for the status panel on the post editor.
async function getAnnounceStatus(req, res) {
const id = Number(req.params.id)
try {
const job = await announceJobs.getByPostId(id)
return res.json(job || null)
} catch (err) {
log.error('getAnnounceStatus', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/posts/:id/announce/retry — reset one delivery leg to pending so
// the dispatcher re-attempts it (e.g. after fixing the news channel / sidecar).
async function retryAnnounceLeg(req, res) {
const id = Number(req.params.id)
const leg = req.body.leg
try {
const job = await announceJobs.resetLeg(id, leg)
if (!job) return res.status(404).json({ message: 'No announcement job for this post' })
await activity.log({ req, action: 'post.announce.retry', detail: { id, leg } })
return res.json(job)
} catch (err) {
log.error('retryAnnounceLeg', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function uploadImage(req, res) {
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
const imageUrl = `/uploads/${req.file.filename}`
@@ -601,6 +624,8 @@ module.exports = {
updatePost,
publishPost,
deletePost,
getAnnounceStatus,
retryAnnounceLeg,
uploadImage,
uploadFile,
listWiki,