// ── The in-game town-crier announce leg ──────────────────────────────────── // // MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third // entangled file: utils/announceWorker.js is core's news dispatcher, but one of // its two delivery legs goes to the shard through uoLinkClient.postTownCrier. // PR 4 turned the legs into registrations, and this file is what module-uo will // register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the // leg id staying `towncrier` (grandfathered in registries.js: the id is a stored // value in announce_job_legs.leg). const uoLinkClient = require('./uoLinkClient') const { deriveExcerpt } = require('./excerpt') const { articleUrl, baseUrl, legError } = require('./announceLinks') const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600 // Sidecar town-crier caps, mirrored from the admin route validation // (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })). // We pre-truncate to these so a published post never bounces with an error. const MAX_LINES = 8 const MAX_LINE_LEN = 200 // 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()}…` } // 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: base } = {}) { 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(base), MAX_LINE_LEN) if (url) lines.push(url) return lines.filter(Boolean).slice(0, MAX_LINES) } async function dispatch(post) { const lines = buildTownCrierText(post, { baseUrl: baseUrl() }) // Stable id: re-posting `post-` 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, }) } function classify(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) } } const leg = { leg: 'towncrier', label: 'In-game town crier', dispatch, classify, } module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }