// ── Admin: uo-link sidecar control ───────────────────────────────────────── // // Configure the connection to the uo-link sidecar (base/ws URL, shared-secret // token, protocol pin, enabled) and drive the town crier. SECURITY: the token // is write-only over this API — stored encrypted, NEVER returned; responses // expose only `hasToken` (same convention as the Discord bot token). Saving // (re)starts the WS ingest client so a change takes effect with no redeploy. const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model') const uoLinkClient = require('../../../utils/uoLinkClient') const uoLinkSocket = require('../../../utils/uoLinkSocket') const shardBroadcast = require('../../../utils/shardBroadcast') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('admin-uolink') // Assemble the masked config + live health + ingestion stats for the panel. async function buildStatus() { const config = await uoLinkConfig.getSafe() const health = await uoLinkClient.health() return { ...config, health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` }, ingest: uoLinkSocket.getState(), sse: shardBroadcast.stats(), } } // GET /admin/uo-link/config — masked config + live status + ingestion stats. async function getConfig(req, res) { try { return res.json(await buildStatus()) } catch (err) { log.error('uoLink.getConfig', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // PUT /admin/uo-link/config — save connection settings + (re)start the socket. async function saveConfig(req, res) { const { baseUrl, wsUrl, token, protocol, enabled } = req.body try { const current = await uoLinkConfig.getSafe() const willHaveToken = Boolean(token) || current.hasToken if (enabled && !willHaveToken) { return res.status(400).json({ message: 'An auth token is required before enabling.' }) } await uoLinkConfig.save({ baseUrl, wsUrl, token, protocol: protocol !== undefined ? Number(protocol) : undefined, enabled, updatedBy: req.user.id, }) // Drop the client's cached config so the health check below uses the new values. uoLinkClient.invalidateConfig() // (Re)start or stop the ingest socket to match the new enabled/URL/token. const saved = await uoLinkConfig.getSafe() if (saved.enabled && saved.hasToken) { await uoLinkSocket.start() } else { uoLinkSocket.stop() await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false }) } await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } }) log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled }) return res.json(await buildStatus()) } catch (err) { log.error('uoLink.saveConfig', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/uo-link/towncrier — publish/replace a town-crier message. async function postTownCrier(req, res) { const { id, lines, durationSec } = req.body try { const result = await uoLinkClient.postTownCrier({ id, lines, durationSec }) if (result.ok) { await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } }) return res.json(result.data || { ok: true, id }) } if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' }) if (result.status === 503 || result.status === 0) { return res.status(503).json({ message: 'The shard is unavailable right now.' }) } return res.status(502).json({ message: 'Could not reach the shard.' }) } catch (err) { log.error('uoLink.postTownCrier', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // DELETE /admin/uo-link/towncrier/:id — remove a town-crier message. async function deleteTownCrier(req, res) { const { id } = req.params try { const result = await uoLinkClient.deleteTownCrier(id) if (result.ok) { await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } }) return res.json(result.data || { ok: true, id }) } if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' }) if (result.status === 503 || result.status === 0) { return res.status(503).json({ message: 'The shard is unavailable right now.' }) } return res.status(502).json({ message: 'Could not reach the shard.' }) } catch (err) { log.error('uoLink.deleteTownCrier', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only. function stream(req, res) { shardBroadcast.subscribe(req, res, 'admin') } module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }