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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user