From 55a3adea993247e6b2010ed898a8189a2f9e8f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:40:47 -0500 Subject: [PATCH] feat(news): auto-push published news to the in-game Town Cryer News gump (2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4: sync the site's published news posts into the Protocol 2.1 News gump. - uoLinkClient.postNews / deleteNews. - utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article stays in the gump while its post is published news and is pulled when it leaves that state. buildArticle renders a compact gump-HTML block (centred title + plain-text excerpt — the gump supports only a small HTML subset) with a "more info" link to /site/news and an optional gump image from the `news_gump_image` setting. Every call is best-effort / never-throws. - Hooked into the posts pipeline alongside the existing announce enqueue: syncPost on create/update/publish (fresh publish announces; edits refresh silently; leaving published-news pulls the article), removePost on delete. - reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect — reconciles the gump to our source of truth and recovers any article whose original live push failed (silent, so a reconnect never re-proclaims old news). Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4). Co-Authored-By: Claude Opus 4.8 --- .../src/router/v1/admin/admin.controller.js | 9 ++ server/src/utils/newsGump.js | 124 ++++++++++++++++++ server/src/utils/uoLinkClient.js | 9 ++ server/src/utils/uoLinkSocket.js | 7 + server/test/newsGump.test.js | 69 ++++++++++ 5 files changed, 218 insertions(+) create mode 100644 server/src/utils/newsGump.js create mode 100644 server/test/newsGump.test.js diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index e3a9360..dbf98f1 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -4,6 +4,7 @@ const settings = require('../../../model/settings/settings.model') const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const announceJobs = require('../../../model/announceJobs/announceJobs.model') +const newsGump = require('../../../utils/newsGump') const { cleanBody } = require('../../../utils/sanitizeHtml') const log = require('../../../utils/logger')('admin') @@ -22,6 +23,11 @@ const log = require('../../../utils/logger')('admin') // enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save. async function announceIfNewlyPublished(post, transition) { await announceJobs.enqueueIfNeeded(post, transition) + // Keep the in-game Town Cryer News gump in sync with the same transition: push + // the article when it becomes published news, refresh it silently on an edit, + // and pull it when it leaves published-news. Best-effort (never throws), so a + // sidecar hiccup never breaks saving a post — same guarantee as the enqueue. + await newsGump.syncPost(post, transition) } // ── Dashboard & site mode ───────────────────────────────────────────── @@ -173,8 +179,11 @@ async function publishPost(req, res) { async function deletePost(req, res) { const id = Number(req.params.id) try { + const current = await posts.getById(id) await posts.remove(id) await activity.log({ req, action: 'post.delete', detail: { id } }) + // If it was live in the News gump, pull it (best-effort). + if (newsGump.inGump(current)) await newsGump.removePost(id) return res.json({ id }) } catch (err) { return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/utils/newsGump.js b/server/src/utils/newsGump.js new file mode 100644 index 0000000..804c22b --- /dev/null +++ b/server/src/utils/newsGump.js @@ -0,0 +1,124 @@ +// ── Town Cryer News gump sync (Protocol 2.1) ─────────────────────────────── +// +// Keeps the in-game Town Cryer *News* gump in sync with the site's published +// news posts. Distinct from the scrolling town-crier lines (that's a one-shot +// announce leg in announceWorker); this is a STATE SYNC — an article stays in the +// gump while its post is published news, and is pulled when the post is +// unpublished/deleted/re-categorised. +// +// The website is the source of truth. POST /news is idempotent (re-post replaces), +// so a refresh or a reconnect re-assert is safe. Every call is best-effort and +// never throws — a sidecar/shard hiccup must never break saving or deleting a +// post. Reliability comes from reassertAll() on every WS (re)connect +// (uoLinkSocket.backfill), which re-pushes the current published set silently and +// closes the gap if an earlier live push failed. + +const posts = require('../model/posts/posts.model') +const uoLinkClient = require('./uoLinkClient') +const settings = require('../model/settings/settings.model') +const { deriveExcerpt } = require('./sanitizeHtml') +const log = require('./logger')('news-gump') + +const MAX_TITLE = 120 +const MAX_BODY = 900 + +function baseUrl() { + return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') +} + +function clamp(value, max) { + const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim() + return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…` +} + +// A post belongs in the gump exactly when it is published AND in the news category. +function inGump(post) { + return Boolean(post && post.published && post.category === 'news') +} + +// Optional UO gump image id for news articles (a shard art id), from the +// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll. +async function gumpImage() { + try { + const raw = await settings.get('news_gump_image') + const n = Number(raw) + return Number.isInteger(n) && n > 0 ? n : undefined + } catch { + return undefined + } +} + +// Build the in-game News article from a post. Body is a compact gump-HTML block +// (title centred + a plain-text excerpt) rather than the post's full rich HTML — +// the UO gump only supports a small HTML subset, so we keep it predictable. The +// "more info" URL is the public news list (news posts have no per-post route). +async function buildArticle(post, { announce = true } = {}) { + const title = clamp(post.title, MAX_TITLE) + const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY) + const body = excerpt ? `
${title}


${excerpt}` : `
${title}
` + return { + id: String(post.id), + title, + body, + image: await gumpImage(), + url: `${baseUrl()}/site/news`, + announce, + } +} + +// Push a post to the gump (only if it belongs there). announce=true has the criers +// proclaim the title; false is a silent refresh/re-assert. +async function pushPost(post, { announce = true } = {}) { + if (!inGump(post)) return { ok: false, skipped: true } + const res = await uoLinkClient.postNews(await buildArticle(post, { announce })) + if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error }) + return res +} + +// Remove a post from the gump. A 404 (not present) is not an error worth noting. +async function removePost(id) { + const res = await uoLinkClient.deleteNews(String(id)) + if (!res.ok && res.status !== 404) { + log.warn('news gump remove failed', { id, status: res.status, error: res.error }) + } + return res +} + +// Reconcile the gump after a post create/update/publish. `transition` +// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place +// edit (silent refresh) and catches a post leaving published-news (pull it). +async function syncPost(post, transition = {}) { + try { + if (inGump(post)) { + const wasInGump = Boolean(transition.wasPublished && transition.wasNews) + await pushPost(post, { announce: !wasInGump }) + } else if (transition.wasPublished && transition.wasNews) { + await removePost(post.id) + } + } catch (err) { + log.warn('news gump sync failed', { id: post && post.id, message: err.message }) + } +} + +// Re-push every currently-published news post, silently — run on each WS +// (re)connect to reconcile the gump to our source of truth (also recovers any +// article whose original live push failed). Best-effort; never throws. +async function reassertAll() { + try { + const list = await posts.listAll('news') + const published = (list || []).filter((p) => p.published) + let pushed = 0 + for (const p of published) { + const full = await posts.getById(p.id) // list projection may omit the body + if (full) { + await pushPost(full, { announce: false }) + pushed += 1 + } + } + if (pushed) log.info('re-asserted news gump articles', { count: pushed }) + } catch (err) { + log.warn('news gump reassert failed', { message: err.message }) + } +} + +module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll } diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index 26bca79..ff3ef66 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -118,6 +118,13 @@ const postTownCrier = ({ id, lines, durationSec }) => call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) +// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL) +// in the in-game News window; re-posting the same id REPLACES it. `announce` +// (default true on the sidecar) controls whether the criers proclaim the title. +const postNews = ({ id, title, body, image, url, announce }) => + call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } }) +const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' }) + // ── Staff write plane (§6) ───────────────────────────────────────────────── // Every call carries `actor` — the website username of the staff member — set by // the controller from the session, NEVER from the browser. The shard records it @@ -155,6 +162,8 @@ module.exports = { linkLookup, postTownCrier, deleteTownCrier, + postNews, + deleteNews, adminKick, adminBan, adminUnban, diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js index f84f8f1..4adbacc 100644 --- a/server/src/utils/uoLinkSocket.js +++ b/server/src/utils/uoLinkSocket.js @@ -17,6 +17,7 @@ const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkClient = require('./uoLinkClient') const shardIngest = require('./shardIngest') const shardState = require('../model/shardState/shardState.model') +const newsGump = require('./newsGump') const log = require('./logger')('uo-link-socket') const BACKOFF_MIN_MS = 1000 @@ -102,6 +103,12 @@ async function backfill() { await shardState.setPresence(presence.data) log.info('snapshotted online population from /online', { count: presence.data.count }) } + + // Re-assert our published news into the in-game Town Cryer News gump. The + // website is the source of truth; this reconciles the gump on every + // (re)connect (and recovers any article whose original live push failed). + // Silent (announce:false) so a reconnect never re-proclaims old news. + await newsGump.reassertAll() } catch (err) { log.warn('backfill failed (continuing on live feed)', { message: err.message }) } diff --git a/server/test/newsGump.test.js b/server/test/newsGump.test.js new file mode 100644 index 0000000..beca3a2 --- /dev/null +++ b/server/test/newsGump.test.js @@ -0,0 +1,69 @@ +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Exercise the News-gump sync decisions against a fake sidecar client by +// monkeypatching the shared modules newsGump require()s (same instance) — no DB, +// no network. +const uoLinkClient = require('../src/utils/uoLinkClient') +const settings = require('../src/model/settings/settings.model') +const newsGump = require('../src/utils/newsGump') + +let calls +const saved = {} + +beforeEach(() => { + calls = { post: [], del: [] } + saved.postNews = uoLinkClient.postNews + saved.deleteNews = uoLinkClient.deleteNews + saved.get = settings.get + uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } } + uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } } + settings.get = async () => null // no gump image configured +}) + +afterEach(() => { + uoLinkClient.postNews = saved.postNews + uoLinkClient.deleteNews = saved.deleteNews + settings.get = saved.get +}) + +const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over }) + +test('buildArticle centres the title, links the news list, and respects announce', async () => { + const a = await newsGump.buildArticle(newsPost(), { announce: false }) + assert.equal(a.id, '42') + assert.match(a.body, /
Double XP Weekend<\/CENTER>/) + assert.match(a.body, /Starts Friday\./) + assert.match(a.url, /\/site\/news$/) + assert.equal(a.announce, false) +}) + +test('a fresh publish into news pushes with announce=true', async () => { + await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, true) + assert.equal(calls.del.length, 0) +}) + +test('an edit of already-published news refreshes silently (announce=false)', async () => { + await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, false) +}) + +test('unpublishing published news pulls the article from the gump', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 0) + assert.deepEqual(calls.del, ['42']) +}) + +test('a draft never-published news post does nothing', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) + assert.equal(calls.del.length, 0) +}) + +test('a non-news post (e.g. screenshot) is never pushed', async () => { + await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) +})