Merge pull request 'News post → town crier + Discord announcement pipeline' (#52) from feature/news-announce-pipeline into main
Reviewed-on: UOM/website#52 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -353,6 +353,8 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||||
|
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
|
||||||
|
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ export const api = {
|
|||||||
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
publishPost: (id, published) =>
|
publishPost: (id, published) =>
|
||||||
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
||||||
|
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
||||||
|
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
||||||
|
retryAnnounceLeg: (id, leg) =>
|
||||||
|
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
||||||
uploadImage: (file) => {
|
uploadImage: (file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useState } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import Modal from '../../../components/Modal.jsx'
|
import Modal from '../../../components/Modal.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
@@ -105,6 +105,8 @@ export default function PostEditor({ post, onClose, onSaved }) {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
{isEdit && post.category === 'news' && <AnnouncePanel postId={post.id} />}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||||
<label style={{ flex: '1 1 200px' }}>
|
<label style={{ flex: '1 1 200px' }}>
|
||||||
<span className="field-label">Category</span>
|
<span className="field-label">Category</span>
|
||||||
@@ -173,3 +175,94 @@ const delStyle = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Announcement status panel ────────────────────────────────────────────────
|
||||||
|
// Shows the town-crier + Discord delivery state for a published news post and
|
||||||
|
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||||
|
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||||
|
// until the post has actually been announced (no job row yet → nothing to show).
|
||||||
|
const LEG_META = {
|
||||||
|
towncrier: { label: 'In-game town crier' },
|
||||||
|
discord: { label: 'Discord #news' },
|
||||||
|
}
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
done: { color: '#7bbf8f', label: 'delivered' },
|
||||||
|
pending: { color: '#d9b84a', label: 'pending' },
|
||||||
|
failed: { color: '#d98b84', label: 'failed' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnnouncePanel({ postId }) {
|
||||||
|
const [job, setJob] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [retrying, setRetrying] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setJob(await api.admin.getAnnounce(postId))
|
||||||
|
} catch {
|
||||||
|
setJob(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [postId])
|
||||||
|
|
||||||
|
async function retry(leg) {
|
||||||
|
setRetrying(leg)
|
||||||
|
try {
|
||||||
|
setJob(await api.admin.retryAnnounceLeg(postId, leg))
|
||||||
|
} catch {
|
||||||
|
// leave the current state; the row simply didn't change
|
||||||
|
} finally {
|
||||||
|
setRetrying('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !job) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={panelStyle}>
|
||||||
|
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||||
|
{['towncrier', 'discord'].map((leg) => {
|
||||||
|
const status = job[`${leg}_status`]
|
||||||
|
const err = job[`${leg}_last_error`]
|
||||||
|
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||||
|
return (
|
||||||
|
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||||
|
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||||
|
{status !== 'done' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retry(leg)}
|
||||||
|
disabled={Boolean(retrying)}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '3px 12px' }}
|
||||||
|
>
|
||||||
|
{retrying === leg ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status === 'failed' && err && (
|
||||||
|
<span className="sans" style={{ fontSize: '0.75rem', color: '#d98b84', paddingLeft: 148 }}>{err}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,3 +88,11 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
|
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
|
||||||
BOT_INTERNAL_URL=http://localhost:4100
|
BOT_INTERNAL_URL=http://localhost:4100
|
||||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||||
|
|
||||||
|
# News announcement pipeline (published news post -> in-game town crier + Discord
|
||||||
|
# #news). The dispatcher is an in-process poller; these tune it. Links in the
|
||||||
|
# announcements use APP_BASE_URL (set above), so set that in production too.
|
||||||
|
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs
|
||||||
|
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
||||||
|
ANNOUNCE_POLL_MS=15000
|
||||||
|
TOWNCRIER_DURATION_SEC=3600
|
||||||
|
|||||||
@@ -647,6 +647,37 @@ CREATE TABLE IF NOT EXISTS pages (
|
|||||||
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
|
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Announcement pipeline. One row per publish event of a news post; the table
|
||||||
|
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
|
||||||
|
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
|
||||||
|
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
|
||||||
|
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
|
||||||
|
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
|
||||||
|
-- own attempt count, last error, and next-due time for exponential backoff.
|
||||||
|
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
|
||||||
|
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
|
||||||
|
CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
post_id INT NOT NULL,
|
||||||
|
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
towncrier_last_error TEXT NULL,
|
||||||
|
towncrier_next_attempt_at DATETIME NULL,
|
||||||
|
|
||||||
|
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
discord_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
discord_last_error TEXT NULL,
|
||||||
|
discord_next_attempt_at DATETIME NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
|
||||||
|
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -683,3 +714,13 @@ ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DE
|
|||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
|
||||||
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);
|
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);
|
||||||
|
|
||||||
|
-- News → town-crier + Discord announcement pipeline. Add the announcement-state
|
||||||
|
-- columns to posts on databases created before the pipeline landed. announced_at
|
||||||
|
-- is stamped once both legs deliver; announce_job_id points at the announce_jobs
|
||||||
|
-- row for the post's admin status panel. Kept as a plain column (not a hard FK)
|
||||||
|
-- so the idempotent boot migration never trips over a re-added constraint — the
|
||||||
|
-- pointer is resolved in application code and the CASCADE on announce_jobs.post_id
|
||||||
|
-- already keeps the two tables consistent.
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
|
||||||
|
|||||||
68
server/src/model/announceJobs/announceJobs.db.js
Normal file
68
server/src/model/announceJobs/announceJobs.db.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS =
|
||||||
|
'id, post_id, status, ' +
|
||||||
|
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
|
||||||
|
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
|
||||||
|
'created_at, updated_at'
|
||||||
|
|
||||||
|
// Whitelist so a `leg` value can be interpolated into a column name safely — it
|
||||||
|
// never comes from raw user input, but keep the guard explicit.
|
||||||
|
const LEGS = ['towncrier', 'discord']
|
||||||
|
function assertLeg(leg) {
|
||||||
|
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(postId) {
|
||||||
|
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findById(id) {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByPostId(postId) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||||
|
[postId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs with at least one leg that is due now: pending and either never scheduled
|
||||||
|
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||||
|
async function findDue(now = new Date(), limit = 25) {
|
||||||
|
return query(
|
||||||
|
`SELECT ${COLS} FROM announce_jobs
|
||||||
|
WHERE (towncrier_status = 'pending'
|
||||||
|
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
|
||||||
|
OR (discord_status = 'pending'
|
||||||
|
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
[now, now, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
|
||||||
|
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
|
||||||
|
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||||
|
assertLeg(leg)
|
||||||
|
await query(
|
||||||
|
`UPDATE announce_jobs SET
|
||||||
|
${leg}_status = ?,
|
||||||
|
${leg}_attempts = ?,
|
||||||
|
${leg}_last_error = ?,
|
||||||
|
${leg}_next_attempt_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(id, status) {
|
||||||
|
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||||
127
server/src/model/announceJobs/announceJobs.logic.js
Normal file
127
server/src/model/announceJobs/announceJobs.logic.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// ── 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,
|
||||||
|
}
|
||||||
116
server/src/model/announceJobs/announceJobs.model.js
Normal file
116
server/src/model/announceJobs/announceJobs.model.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ── Announcement pipeline: orchestration ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Sits between the DB rows and the worker: creates jobs on publish, records each
|
||||||
|
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
|
||||||
|
// announced_at when both legs land, and resets a leg for the admin retry button.
|
||||||
|
// The pure decisions (backoff, rollup, classification) live in .logic.js.
|
||||||
|
|
||||||
|
const db = require('./announceJobs.db')
|
||||||
|
const logic = require('./announceJobs.logic')
|
||||||
|
const posts = require('../posts/posts.model')
|
||||||
|
const log = require('../../utils/logger')('announce')
|
||||||
|
|
||||||
|
// Enqueue an announcement for a freshly-published news post: one job row (both
|
||||||
|
// legs pending, due immediately) plus a back-pointer on the post so the admin
|
||||||
|
// panel can find it. Returns the new job id.
|
||||||
|
async function enqueue(postId) {
|
||||||
|
const jobId = await db.create(postId)
|
||||||
|
await posts.linkAnnounceJob(postId, jobId)
|
||||||
|
log.info('announce job enqueued', { jobId, postId })
|
||||||
|
return jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should publishing this post fire the pipeline? Only on a real transition INTO
|
||||||
|
// "published news" — a false→true publish while in news, or a category change
|
||||||
|
// into news while already published — and never twice (guarded by the post's
|
||||||
|
// existing announce_job_id). Editing an already-announced post does not re-fire.
|
||||||
|
function shouldEnqueue(post, { wasPublished, wasNews }) {
|
||||||
|
if (!post || post.category !== 'news' || !post.published) return false
|
||||||
|
if (post.announce_job_id) return false
|
||||||
|
const wasNewsPublished = Boolean(wasPublished) && Boolean(wasNews)
|
||||||
|
return !wasNewsPublished
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience used by the post controller: enqueue iff shouldEnqueue. Never
|
||||||
|
// throws — a pipeline hiccup must not break saving/publishing a post.
|
||||||
|
async function enqueueIfNeeded(post, transition) {
|
||||||
|
try {
|
||||||
|
if (!shouldEnqueue(post, transition)) return null
|
||||||
|
return await enqueue(post.id)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('enqueueIfNeeded failed', { postId: post && post.id, message: err.message })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
|
||||||
|
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
|
||||||
|
// the attempt count and schedule the next run (or fail the leg once the cap is
|
||||||
|
// hit). Returns the updated job row.
|
||||||
|
async function recordOutcome(job, leg, { outcome, error }) {
|
||||||
|
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||||
|
|
||||||
|
if (outcome === 'done') {
|
||||||
|
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||||
|
} else if (outcome === 'terminal') {
|
||||||
|
await db.updateLeg(job.id, leg, { status: 'failed', attempts: attempts + 1, lastError: error, nextAttemptAt: null })
|
||||||
|
log.warn('announce leg failed (terminal)', { jobId: job.id, leg, error })
|
||||||
|
} else {
|
||||||
|
const nextAttempts = attempts + 1
|
||||||
|
const delay = logic.scheduleAfter(nextAttempts)
|
||||||
|
if (delay === null) {
|
||||||
|
await db.updateLeg(job.id, leg, { status: 'failed', attempts: nextAttempts, lastError: error, nextAttemptAt: null })
|
||||||
|
log.warn('announce leg failed (retries exhausted)', { jobId: job.id, leg, attempts: nextAttempts, error })
|
||||||
|
} else {
|
||||||
|
const nextAttemptAt = new Date(Date.now() + delay)
|
||||||
|
await db.updateLeg(job.id, leg, { status: 'pending', attempts: nextAttempts, lastError: error, nextAttemptAt })
|
||||||
|
log.info('announce leg retry scheduled', { jobId: job.id, leg, attempts: nextAttempts, nextAttemptAt })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshStatus(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||||
|
// announced_at the moment both legs have delivered.
|
||||||
|
async function refreshStatus(jobId) {
|
||||||
|
const job = await db.findById(jobId)
|
||||||
|
if (!job) return null
|
||||||
|
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||||
|
if (status !== job.status) await db.setStatus(jobId, status)
|
||||||
|
job.status = status
|
||||||
|
if (status === 'done') {
|
||||||
|
try {
|
||||||
|
await posts.markAnnounced(job.post_id)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('markAnnounced failed', { jobId, postId: job.post_id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin retry button: reset one leg to pending, clear its error/backoff, and let
|
||||||
|
// the worker pick it up on the next tick. Resets the attempt count so a retry
|
||||||
|
// after a config fix gets a full budget again.
|
||||||
|
async function resetLeg(postId, leg) {
|
||||||
|
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||||
|
const job = await db.findByPostId(postId)
|
||||||
|
if (!job) return null
|
||||||
|
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
|
||||||
|
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
|
||||||
|
return refreshStatus(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByPostId(postId) {
|
||||||
|
return db.findByPostId(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
enqueue,
|
||||||
|
shouldEnqueue,
|
||||||
|
enqueueIfNeeded,
|
||||||
|
recordOutcome,
|
||||||
|
refreshStatus,
|
||||||
|
resetLeg,
|
||||||
|
getByPostId,
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const { query } = require('../../utils/db')
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
const COLS =
|
const COLS =
|
||||||
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at'
|
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at, announced_at, announce_job_id'
|
||||||
|
|
||||||
// Published posts for a category, newest first — public feed.
|
// Published posts for a category, newest first — public feed.
|
||||||
async function listPublished(category) {
|
async function listPublished(category) {
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ async function setPublished(id, published) {
|
|||||||
return postsDb.findById(id)
|
return postsDb.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announcement pipeline back-pointers (see model/announceJobs).
|
||||||
|
async function linkAnnounceJob(id, jobId) {
|
||||||
|
await postsDb.update(id, { announce_job_id: jobId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAnnounced(id, at = new Date()) {
|
||||||
|
await postsDb.update(id, { announced_at: at })
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id) {
|
||||||
return postsDb.remove(id)
|
return postsDb.remove(id)
|
||||||
}
|
}
|
||||||
@@ -103,6 +112,8 @@ module.exports = {
|
|||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
setPublished,
|
setPublished,
|
||||||
|
linkAnnounceJob,
|
||||||
|
markAnnounced,
|
||||||
remove,
|
remove,
|
||||||
counts,
|
counts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,37 +3,25 @@ const wiki = require('../../../model/wiki/wiki.model')
|
|||||||
const settings = require('../../../model/settings/settings.model')
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const users = require('../../../model/users/users.model')
|
const users = require('../../../model/users/users.model')
|
||||||
const activity = require('../../../model/activity/activity.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 { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('admin')
|
const log = require('../../../utils/logger')('admin')
|
||||||
|
|
||||||
// Public base URL for links back to the site — same fallback pattern as
|
// Fire the announcement pipeline the moment a post transitions INTO
|
||||||
// sso.controller.js's redirect_uri builder.
|
// "published news" — a false→true publish while in news, or a category change
|
||||||
function appBaseUrl(req) {
|
// into news while already published. Enqueues one announce_jobs row whose two
|
||||||
const configured = process.env.APP_BASE_URL
|
// legs (in-game town crier + Discord #news) are then delivered with independent
|
||||||
if (configured) return configured.replace(/\/+$/, '')
|
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
|
||||||
return `${req.protocol}://${req.get('host')}`
|
// 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
|
||||||
// Fire-and-forget: announce a news post to Discord the moment it actually
|
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
|
||||||
// transitions from unpublished to published — not on every save or on a
|
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
|
||||||
// no-op re-publish of an already-live post. Never throws (botInternalClient
|
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
|
||||||
// itself never rejects); a bot outage must never break publishing a post.
|
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||||
function announceIfNewlyPublished(req, post, wasPublished) {
|
async function announceIfNewlyPublished(post, transition) {
|
||||||
if (!post || post.category !== 'news' || !post.published || wasPublished) return
|
await announceJobs.enqueueIfNeeded(post, transition)
|
||||||
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(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||||
@@ -120,7 +108,7 @@ async function createPost(req, res) {
|
|||||||
author_id: req.user.id,
|
author_id: req.user.id,
|
||||||
})
|
})
|
||||||
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
|
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)
|
return res.status(201).json(created)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('createPost', err)
|
log.error('createPost', err)
|
||||||
@@ -150,7 +138,10 @@ async function updatePost(req, res) {
|
|||||||
|
|
||||||
const updated = await posts.update(id, fields)
|
const updated = await posts.update(id, fields)
|
||||||
await activity.log({ req, action: 'post.update', detail: { id } })
|
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)
|
return res.json(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('updatePost', err)
|
log.error('updatePost', err)
|
||||||
@@ -169,7 +160,10 @@ async function publishPost(req, res) {
|
|||||||
action: 'post.publish',
|
action: 'post.publish',
|
||||||
detail: { id, published: Boolean(req.body.published) },
|
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)
|
return res.json(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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) {
|
async function uploadImage(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
|
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
|
||||||
const imageUrl = `/uploads/${req.file.filename}`
|
const imageUrl = `/uploads/${req.file.filename}`
|
||||||
@@ -601,6 +624,8 @@ module.exports = {
|
|||||||
updatePost,
|
updatePost,
|
||||||
publishPost,
|
publishPost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
getAnnounceStatus,
|
||||||
|
retryAnnounceLeg,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
listWiki,
|
listWiki,
|
||||||
|
|||||||
@@ -348,6 +348,32 @@ adminRouter.delete(
|
|||||||
validate,
|
validate,
|
||||||
ctrl.deletePost,
|
ctrl.deletePost,
|
||||||
)
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/posts/:id/announce',
|
||||||
|
// #swagger.tags = ['Admin · Posts']
|
||||||
|
// #swagger.summary = 'Get the announcement pipeline status for a post'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.getAnnounceStatus,
|
||||||
|
)
|
||||||
|
adminRouter.post(
|
||||||
|
'/posts/:id/announce/retry',
|
||||||
|
// #swagger.tags = ['Admin · Posts']
|
||||||
|
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
body('leg').isIn(['towncrier', 'discord']),
|
||||||
|
validate,
|
||||||
|
ctrl.retryAnnounceLeg,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
|
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const uoLinkSocket = require('./utils/uoLinkSocket')
|
|||||||
const uoLinkClient = require('./utils/uoLinkClient')
|
const uoLinkClient = require('./utils/uoLinkClient')
|
||||||
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const shardBroadcast = require('./utils/shardBroadcast')
|
const shardBroadcast = require('./utils/shardBroadcast')
|
||||||
|
const announceWorker = require('./utils/announceWorker')
|
||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
@@ -92,6 +93,11 @@ async function start() {
|
|||||||
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the news-announcement dispatcher: a light in-process poller that pushes
|
||||||
|
// published news posts to the in-game town crier + Discord with independent
|
||||||
|
// retry per leg. No-op until a news post is actually published.
|
||||||
|
announceWorker.start()
|
||||||
|
|
||||||
setupShutdown(server, internalServer)
|
setupShutdown(server, internalServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +135,7 @@ function setupShutdown(server, internalServer) {
|
|||||||
closing = true
|
closing = true
|
||||||
log.warn(`${signal} received — shutting down gracefully`)
|
log.warn(`${signal} received — shutting down gracefully`)
|
||||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||||
|
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||||
uoLinkSocket.stop() // close the uo-link WS ingest client
|
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||||
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||||
server.close(() => log.info('http server closed'))
|
server.close(() => log.info('http server closed'))
|
||||||
|
|||||||
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,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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) {
|
} catch (err) {
|
||||||
log.warn('bot internal call failed', { path, message: err.message })
|
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 {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
|
|||||||
86
server/test/announceJobs.test.js
Normal file
86
server/test/announceJobs.test.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||||
|
|
||||||
|
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||||
|
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||||
|
const lines = logic.buildTownCrierText(
|
||||||
|
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||||
|
const lines = logic.buildTownCrierText(
|
||||||
|
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.equal(lines[1], 'Hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||||
|
const longTitle = 'x'.repeat(500)
|
||||||
|
const lines = logic.buildTownCrierText(
|
||||||
|
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||||
|
assert.ok(lines[0].endsWith('…'))
|
||||||
|
assert.ok(lines.length <= logic.MAX_LINES)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||||
|
const lines = logic.buildTownCrierText(
|
||||||
|
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||||
|
test('classifyTownCrier: 2xx is done', () => {
|
||||||
|
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||||
|
for (const status of [400, 401, 409]) {
|
||||||
|
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifyTownCrier: shard-transient and network errors retry', () => {
|
||||||
|
for (const status of [503, 504, 500, 0]) {
|
||||||
|
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||||
|
test('classifyDiscord: ok is done, every failure retries', () => {
|
||||||
|
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
|
||||||
|
for (const status of [400, 503, 0]) {
|
||||||
|
assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── scheduleAfter (backoff) ──────────────────────────────────────────────────
|
||||||
|
test('scheduleAfter returns increasing delays then null at the attempt cap', () => {
|
||||||
|
const d1 = logic.scheduleAfter(1)
|
||||||
|
const d2 = logic.scheduleAfter(2)
|
||||||
|
assert.ok(d1 > 0 && d2 > d1, 'delays should grow')
|
||||||
|
// Exhausted once attempts reach MAX_ATTEMPTS.
|
||||||
|
assert.equal(logic.scheduleAfter(logic.MAX_ATTEMPTS), null)
|
||||||
|
assert.equal(logic.scheduleAfter(logic.MAX_ATTEMPTS + 3), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── rollupStatus ─────────────────────────────────────────────────────────────
|
||||||
|
test('rollupStatus derives the parent status from the two legs', () => {
|
||||||
|
assert.equal(logic.rollupStatus('done', 'done'), 'done')
|
||||||
|
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
|
||||||
|
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
|
||||||
|
// One terminal, the other not matching → partial.
|
||||||
|
assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
|
||||||
|
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
|
||||||
|
assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user