Files
website/server/src/utils/newsGump.js
Claude 55a3adea99 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>
2026-07-17 15:40:47 -05:00

125 lines
5.0 KiB
JavaScript

// ── 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 }