feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ 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 announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||||
|
const newsGump = require('../../../utils/newsGump')
|
||||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('admin')
|
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.
|
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||||
async function announceIfNewlyPublished(post, transition) {
|
async function announceIfNewlyPublished(post, transition) {
|
||||||
await announceJobs.enqueueIfNeeded(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 ─────────────────────────────────────────────
|
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||||
@@ -173,8 +179,11 @@ async function publishPost(req, res) {
|
|||||||
async function deletePost(req, res) {
|
async function deletePost(req, res) {
|
||||||
const id = Number(req.params.id)
|
const id = Number(req.params.id)
|
||||||
try {
|
try {
|
||||||
|
const current = await posts.getById(id)
|
||||||
await posts.remove(id)
|
await posts.remove(id)
|
||||||
await activity.log({ req, action: 'post.delete', detail: { 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 })
|
return res.json({ id })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
|||||||
124
server/src/utils/newsGump.js
Normal file
124
server/src/utils/newsGump.js
Normal file
@@ -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 ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
|
||||||
|
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 }
|
||||||
@@ -118,6 +118,13 @@ const postTownCrier = ({ id, lines, durationSec }) =>
|
|||||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
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) ─────────────────────────────────────────────────
|
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||||
// Every call carries `actor` — the website username of the staff member — set by
|
// 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
|
// the controller from the session, NEVER from the browser. The shard records it
|
||||||
@@ -155,6 +162,8 @@ module.exports = {
|
|||||||
linkLookup,
|
linkLookup,
|
||||||
postTownCrier,
|
postTownCrier,
|
||||||
deleteTownCrier,
|
deleteTownCrier,
|
||||||
|
postNews,
|
||||||
|
deleteNews,
|
||||||
adminKick,
|
adminKick,
|
||||||
adminBan,
|
adminBan,
|
||||||
adminUnban,
|
adminUnban,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|||||||
const uoLinkClient = require('./uoLinkClient')
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
const shardIngest = require('./shardIngest')
|
const shardIngest = require('./shardIngest')
|
||||||
const shardState = require('../model/shardState/shardState.model')
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const newsGump = require('./newsGump')
|
||||||
const log = require('./logger')('uo-link-socket')
|
const log = require('./logger')('uo-link-socket')
|
||||||
|
|
||||||
const BACKOFF_MIN_MS = 1000
|
const BACKOFF_MIN_MS = 1000
|
||||||
@@ -102,6 +103,12 @@ async function backfill() {
|
|||||||
await shardState.setPresence(presence.data)
|
await shardState.setPresence(presence.data)
|
||||||
log.info('snapshotted online population from /online', { count: presence.data.count })
|
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) {
|
} catch (err) {
|
||||||
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||||
}
|
}
|
||||||
|
|||||||
69
server/test/newsGump.test.js
Normal file
69
server/test/newsGump.test.js
Normal file
@@ -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, /<CENTER>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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user