diff --git a/server/src/services/discordBotService/configuration.js b/server/src/services/discordBotService/configuration.js index 5e3175e8..6a6ef62d 100644 --- a/server/src/services/discordBotService/configuration.js +++ b/server/src/services/discordBotService/configuration.js @@ -18,6 +18,7 @@ module.exports = { adminAlerts: '123456789012345678', replay: '123456789012345678', humanAlerts: '123456789012345678', + liveStatus: '', }, roles: { stalkerPing: '123456789012345678', @@ -31,6 +32,7 @@ module.exports = { token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }), guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }), channels: strictObject({ + liveStatus: string({ title: 'Live status', description: 'Dedicated live fleet report channel ID. The bot deletes ALL other messages here. Requires View Channel, Read Message History, Send Messages, and Manage Messages. Leave empty to disable.', examples: ['123456789012345678'], maxLength: 100 }), general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }), announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }), adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }), diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 4e7085a6..a5b06903 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -22,8 +22,8 @@ const { MODES, getMode, setMode } = require('../modeManager'); const { sendExternalMessage, sendExternalTyping } = require('../chatService'); const { commandReplyToText } = require('../chatService/commandResultFormatter'); const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2'); -const { getActiveDrivers } = require('../turnService'); -const { getNickname } = require('../nicknameService'); +const { getActiveDrivers, turnEvents } = require('../turnService'); +const { getNickname, nicknameEvents } = require('../nicknameService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const homeAssistantService = require('../homeAssistantService'); @@ -74,6 +74,7 @@ const greenModeService = require('../greenModeService'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createIntegrations } = require('./integrations'); const { createFleetDailyReports } = require('./fleetDailyReports'); +const { createLiveStatus } = require('./liveStatus'); const fleetReportService = require('../fleetReportService'); const { registerPreferredDeliveryProvider } = require('../replayDeliveryService'); const { @@ -335,6 +336,11 @@ function createDiscordRuntime() { }); const integrationHandlers = integrations.register(); + const liveStatus = createLiveStatus({ + client, logger, discordConfig, roverManager, getActiveDrivers, getNickname, + io, fetchChannel: channelIO.fetchChannel, sanitizeMentions, + turnEvents, nicknameEvents, + }); function isTextCommand(content) { // Both transports share this parser so command detection cannot drift from @@ -383,6 +389,10 @@ function createDiscordRuntime() { client.on('messageCreate', async (message) => { try { + if (message.channelId === discordConfig.channels?.liveStatus?.trim()) { + liveStatus.update(true); + return; + } await integrationHandlers.handleBridgeInbound(message); const commandMessage = createBridgeMirroredCommandMessage(message); await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser })); @@ -413,12 +423,14 @@ function createDiscordRuntime() { // is ready avoids failed sends during login while the collector continues to // operate independently of Discord availability. restartFleetDailyReports(); + liveStatus.start(); }); return { client, refresh() { if (client.isReady()) restartFleetDailyReports(); + if (client.isReady()) liveStatus.start(); }, stop() { for (const event of ['clientReady', 'messageCreate', 'typingStart', 'messageReactionAdd']) { @@ -427,6 +439,7 @@ function createDiscordRuntime() { integrationHandlers.stop(); unregisterReplayProvider(); presence.stop(); + liveStatus.stop(); channelIO.stop(); fleetDailyReports?.stop(); }, diff --git a/server/src/services/discordBotService/liveStatus.js b/server/src/services/discordBotService/liveStatus.js new file mode 100644 index 00000000..143795e5 --- /dev/null +++ b/server/src/services/discordBotService/liveStatus.js @@ -0,0 +1,184 @@ +// Owns the single-message live report and the dedicated channel's cleanup. +const { PermissionFlagsBits } = require('discord.js'); +const { buildRoverStatusSnapshot } = require('./batteryEmbeds'); + +function createLiveStatus({ client, logger, discordConfig, roverManager, getActiveDrivers, + getNickname, io, fetchChannel, sanitizeMentions, + turnEvents, nicknameEvents }) { + const COOLDOWN_MS = 2000; + let timer = null; + let sweepTimer = null; + let pending = false; + let cleanupNeeded = false; + let nextUpdateAt = 0; + let listening = false; + const unsubscribe = []; + let stopped = false; + let running = false; + let channelId = null; + let messageId = null; + let lastContent = null; + + const clean = (value, limit = 120) => sanitizeMentions(String(value ?? '')) + .replace(/[\r\n]+/g, ' ').slice(0, limit); + + function buildReport() { + const drivers = getActiveDrivers(); + const roster = roverManager.getRoster(); + const lines = []; + for (const rover of roster) { + const snapshot = buildRoverStatusSnapshot(roverManager.rovers.get(rover.id)); + const driverId = drivers[rover.id]; + const driver = driverId ? clean(getNickname(io.sockets.sockets.get(driverId)) || 'Someone', 32) : null; + const status = snapshot?.docked ? 'docked' : rover.needsHelp ? 'needs help' + : driver ? `${driver} driving` : rover.locked ? 'locked' : 'available'; + lines.push(`${clean(rover.name, 60)}: ${status}`); + } + let content = lines.join('\n') || 'No rovers online.'; + if (content.length > 2000) content = `${content.slice(0, 1950)}\n… More rovers online.`; + return { content, allowedMentions: { parse: [] } }; + } + + function schedule() { + if (stopped || running || timer || !pending || !client.isReady() + || !discordConfig.channels?.liveStatus?.trim()) return; + // Use a fixed deadline rather than resetting a debounce on every sensor + // frame: a continuously changing rover must not postpone updates forever. + timer = setTimeout(() => { + timer = null; + flush(); + }, Math.max(0, nextUpdateAt - Date.now())); + timer.unref?.(); + } + + function update(forceCleanup = false) { + if (stopped) return; + pending = true; + cleanupNeeded ||= forceCleanup; + schedule(); + } + + async function flush() { + if (stopped || running || !client.isReady()) return; + pending = false; + const target = discordConfig.channels?.liveStatus?.trim(); + if (target !== channelId) { + channelId = target; + messageId = null; + lastContent = null; + } + if (!target) return; + running = true; + const cleanup = cleanupNeeded; + cleanupNeeded = false; + let usedDiscord = false; + const active = () => !stopped && client.isReady() + && discordConfig.channels?.liveStatus?.trim() === target; + try { + const report = buildReport(); + const replace = report.content !== lastContent; + // Sensor events can arrive many times per second. Compare the rendered + // report locally before consuming any Discord API capacity. + if (!replace && !cleanup) return; + usedDiscord = true; + const channel = await fetchChannel(target); + if (!active()) return; + if (!channel?.isTextBased() || !channel.guild || !channel.messages || !channel.send) { + throw new Error('Live status requires a guild text channel'); + } + const permissions = channel.permissionsFor(client.user); + if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages])) { + throw new Error('Live status requires View Channel, Read Message History, Send Messages, and Manage Messages'); + } + let found = false; + let before; + // Paginate the entire history, including pinned and old messages. Individual + // deletion also handles messages too old for Discord's bulk-delete endpoint. + while (active()) { + const messages = await channel.messages.fetch({ limit: 100, before, cache: false }); + if (!active()) return; + for (const message of messages.values()) { + if (!active()) return; + if (!replace && message.id === messageId) { + found = true; + continue; + } + try { + await message.delete(); + } catch (err) { + if (err.code !== 10008) throw err; // Already-deleted messages need no cleanup. + } + } + if (messages.size < 100) break; + before = messages.last().id; + } + if (!active()) return; + if (replace || !found) { + const message = await channel.send(report); + if (!active()) return; + messageId = message.id; + lastContent = report.content; + } + } catch (err) { + cleanupNeeded = true; + logger.warn('Discord live status update failed', { channelId: target, error: err.message }); + } finally { + running = false; + // Cool down after completion, so slow deletions and rate-limited requests + // cannot overlap with another batch. Events received meanwhile stay pending. + if (usedDiscord) nextUpdateAt = Date.now() + COOLDOWN_MS; + schedule(); + } + } + + const onChange = () => update(); + function onDelete(message) { + if (message.channelId === discordConfig.channels?.liveStatus?.trim() + && message.id === messageId) update(true); + } + function onBulkDelete(messages) { + for (const message of messages.values()) onDelete(message); + } + + return { + update, + start() { + if (stopped) return; + if (!listening) { + listening = true; + for (const event of ['rover', 'sensor', 'lock', 'private', 'help']) { + roverManager.managerEvents.on(event, onChange); + unsubscribe.push(() => roverManager.managerEvents.off(event, onChange)); + } + turnEvents.on('activeDriver', onChange); + nicknameEvents.on('change', onChange); + client.on('messageDelete', onDelete); + client.on('messageDeleteBulk', onBulkDelete); + unsubscribe.push( + () => turnEvents.off('activeDriver', onChange), + () => nicknameEvents.off('change', onChange), + () => client.off('messageDelete', onDelete), + () => client.off('messageDeleteBulk', onBulkDelete), + ); + } + clearInterval(sweepTimer); + sweepTimer = null; + if (discordConfig.channels?.liveStatus?.trim()) { + // Recover from missed gateway events and failed operations without polling + // for normal rover changes, which are handled by the subscriptions above. + sweepTimer = setInterval(() => update(true), 30000); + sweepTimer.unref?.(); + } + update(true); + }, + stop() { + stopped = true; + clearTimeout(timer); + clearInterval(sweepTimer); + unsubscribe.forEach((remove) => remove()); + }, + }; +} + +module.exports = { createLiveStatus };