diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index ab0a0678..4008df0b 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -44,7 +44,7 @@ - [ ] audio forward service - [ ] button box service - [ ] chat service -- [ ] discord bot service +- [x] discord bot service - [x] home assistant service - [ ] llm commentary service - [x] private rover access request service @@ -85,6 +85,7 @@ - Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer. - Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer. - Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer. +- Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer. ## WebUI frontend ### BIGGEST OFFENDERS diff --git a/server/src/services/discordBotService/channelIO.js b/server/src/services/discordBotService/channelIO.js new file mode 100644 index 00000000..99d1031a --- /dev/null +++ b/server/src/services/discordBotService/channelIO.js @@ -0,0 +1,87 @@ +// Discord Channel IO Module +// Purpose: Centralizes Discord channel fetch/send helpers plus typing-indicator message lifecycle for bridge UX. +// Scope: Provides safe channel cache operations and typing message management without command or policy logic. +const { MessageFlags } = require('discord.js'); + +function createChannelIO({ client, logger, sanitizeMentions }) { + const channelCache = new Map(); + const typingMessageCache = new Map(); + + async function fetchChannel(id) { + if (!id) return null; + if (channelCache.has(id)) return channelCache.get(id); + try { + const channel = await client.channels.fetch(id); + if (channel) { + channelCache.set(id, channel); + return channel; + } + } catch (err) { + logger.warn('Failed to fetch Discord channel', { id, error: err.message }); + } + return null; + } + + async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) { + const channel = await fetchChannel(id); + if (!channel) return; + try { + const messageContent = sanitizeContent ? sanitizeMentions(content) : content; + await channel.send({ content: messageContent, allowedMentions, ...options }); + } catch (err) { + logger.warn('Failed to send Discord message', { id, error: err.message }); + } + } + + function typingCacheKey(guildId, typingId) { + return `${guildId}:${typingId}`; + } + + async function clearTypingMessage(guildId, typingId) { + const key = typingCacheKey(guildId, typingId); + const record = typingMessageCache.get(key); + if (!record) return; + typingMessageCache.delete(key); + if (record.timeoutId) clearTimeout(record.timeoutId); + const channel = await fetchChannel(record.channelId); + if (!channel?.messages?.fetch) return; + try { + const msg = await channel.messages.fetch(record.messageId); + await msg.delete(); + } catch (err) { + if (err?.code !== 10008) { + logger.warn('Failed to delete typing message', { guildId, error: err.message }); + } + } + } + + async function sendTypingMessage(entry, payload, formatWebhookUsername, getTypingId) { + const typingId = getTypingId(payload); + const key = typingCacheKey(entry.guildId, typingId); + if (typingMessageCache.has(key)) return; + const channel = await fetchChannel(entry.channelId); + if (!channel?.send) return; + const username = formatWebhookUsername(payload); + const content = `-# *${username} is typing...*`; + try { + const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]}); + const timeoutId = setTimeout(() => { + clearTypingMessage(entry.guildId, typingId); + }, 20000); + typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId }); + } catch (err) { + logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message }); + } + } + + return { + fetchChannel, + sendToChannel, + clearTypingMessage, + sendTypingMessage, + }; +} + +module.exports = { + createChannelIO, +}; diff --git a/server/src/services/discordBotService/commands/bridge.js b/server/src/services/discordBotService/commands/bridge.js new file mode 100644 index 00000000..c87abba7 --- /dev/null +++ b/server/src/services/discordBotService/commands/bridge.js @@ -0,0 +1,72 @@ +// Discord Bridge Command +// Purpose: Handles chat bridge configuration/status commands per guild. +// Scope: Manages bridge channel, mode, and webhook provisioning. +const { PermissionsBitField } = require('discord.js'); + +function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser }) { + function canManageBridge(message) { + if (isAdminUser(message.author.id)) return true; + if (!message.guild || !message.member) return false; + const perms = message.member.permissions; + if (!perms) return false; + return perms.has(PermissionsBitField.Flags.ManageGuild) || perms.has(PermissionsBitField.Flags.Administrator); + } + function canManageWebhooksInChannel(channel) { + if (!channel?.guild) return false; + const botMember = channel.guild.members?.me; + const perms = channel.permissionsFor(botMember); + if (!perms) return false; + return perms.has(PermissionsBitField.Flags.ManageWebhooks); + } + async function ensureBridgeWebhook(channel, guildId) { + if (!channel?.id || !guildId) return null; + if (!canManageWebhooksInChannel(channel)) throw new Error('Missing Manage Webhooks permission in this channel.'); + const existing = getGuildConfig(guildId); + if (existing?.channelId && String(existing.channelId) === String(channel.id) && existing?.webhookId && existing?.webhookToken) return existing; + const webhook = await channel.createWebhook({ name: 'Rover Chat Bridge', reason: 'Rover chat bridge webhook' }); + if (!webhook?.id || !webhook?.token) throw new Error('Failed to create webhook.'); + return setGuildConfig(guildId, { channelId: channel.id, mode: existing?.mode || 'global', webhookId: webhook.id, webhookToken: webhook.token }); + } + function status(entry) { + if (!entry) return 'Chat bridge is not configured for this server.'; + return `Chat bridge is **${entry.mode}** in <#${entry.channelId}>.`; + } + + return async function handleBridgeCommand(message, tokens) { + if (!message.guild) return message.reply({ content: 'Chat bridge must be configured in a server channel.', allowedMentions: { parse: [], repliedUser: false } }); + const guildId = message.guild.id; + let action = (tokens.shift() || 'status').toLowerCase(); + let mode = null; + if (action === 'global' || action === 'private') { mode = action; action = 'here'; } + else if (action === 'here' || action === 'mode') { mode = (tokens.shift() || '').toLowerCase(); } + if (mode && !VALID_MODES.has(mode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } }); + + if (action === 'status') return message.reply({ content: status(getGuildConfig(guildId)), allowedMentions: { parse: [], repliedUser: false } }); + if (action === 'off') { removeGuildConfig(guildId); return message.reply({ content: 'Chat bridge disabled for this server.', allowedMentions: { parse: [], repliedUser: false } }); } + if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } }); + + if (action === 'here') { + try { + const entry = await ensureBridgeWebhook(message.channel, guildId); + if (mode) setGuildConfig(guildId, { channelId: entry.channelId, mode, webhookId: entry.webhookId, webhookToken: entry.webhookToken }); + const updated = getGuildConfig(guildId); + return message.reply({ content: `Chat bridge set to **${updated.mode}** in <#${updated.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + return message.reply({ content: `Failed to set chat bridge: ${err.message}`, allowedMentions: { parse: [], repliedUser: false } }); + } + } + + if (action === 'mode') { + const current = getGuildConfig(guildId); + if (!current?.channelId) return message.reply({ content: 'No chat bridge channel set. Use `rs bridge here ` first.', allowedMentions: { parse: [], repliedUser: false } }); + const nextMode = normalizeMode(mode, null); + if (!VALID_MODES.has(nextMode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } }); + const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode, webhookId: current.webhookId, webhookToken: current.webhookToken }); + return message.reply({ content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } }); + } + + return message.reply({ content: 'Unknown bridge command. Try `rs bridge`.', allowedMentions: { parse: [], repliedUser: false } }); + }; +} + +module.exports = { createBridgeCommand }; diff --git a/server/src/services/discordBotService/commands/deter.js b/server/src/services/discordBotService/commands/deter.js new file mode 100644 index 00000000..75949bfb --- /dev/null +++ b/server/src/services/discordBotService/commands/deter.js @@ -0,0 +1,49 @@ +// Discord Deter Command +// Purpose: Handles deterrence moderation commands for lockdown admins. +// Scope: Supports list, ban, and unban subcommands. +function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) { + function mask(v) { + const key = String(v || '').trim(); + if (!key) return 'n/a'; + if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`; + return `${key.slice(0, 6)}...${key.slice(-6)}`; + } + + return async function handleDeterCommand(message, tokens) { + if (!isLockdownAdminUser(message.author?.id)) { + await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const action = (tokens.shift() || 'list').toLowerCase(); + if (action === 'list') { + const users = listDeterredUsers(); + if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } }); + const lines = users.map((entry, idx) => `${idx + 1}. ${entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`); + return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } }); + } + if (action === 'ban') { + const selector = String(tokens.shift() || '').trim(); + const reason = tokens.join(' ').trim(); + if (!selector) return message.reply({ content: 'Usage: `rs deter ban [reason]`', allowedMentions: { parse: [], repliedUser: false } }); + try { + const deterred = deterUser(selector, { reason, actor: message.author?.id || null }); + return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + } + if (action === 'unban') { + const selector = tokens.join(' ').trim(); + if (!selector) return message.reply({ content: 'Usage: `rs deter unban `', allowedMentions: { parse: [], repliedUser: false } }); + try { + const removed = undeterUser(selector, message.author?.id || null); + return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + } + return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban [reason]`, or `rs deter unban `.', allowedMentions: { parse: [], repliedUser: false } }); + }; +} + +module.exports = { createDeterCommand }; diff --git a/server/src/services/discordBotService/commands/goal.js b/server/src/services/discordBotService/commands/goal.js new file mode 100644 index 00000000..5e82b49a --- /dev/null +++ b/server/src/services/discordBotService/commands/goal.js @@ -0,0 +1,31 @@ +// Discord Goal Command +// Purpose: Handles community goal view/update/clear operations. +// Scope: Allows read by all and write by admins. +function createGoalCommand({ getCommunityGoal, setCommunityGoal, clearCommunityGoal, isAdminUser, sanitizeMentions }) { + return async function handleGoalCommand(message, tokens) { + const query = tokens.join(' ').trim(); + const lower = query.toLowerCase(); + if (!query) { + const goal = getCommunityGoal(); + await message.reply({ content: goal?.text ? `Community goal: ${sanitizeMentions(goal.text)}` : 'No community goal set.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + if (!isAdminUser(message.author.id)) { + await message.reply({ content: 'Only admins can update the community goal.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + try { + if (lower === 'clear') { + clearCommunityGoal({ by: message.author?.id || null }); + await message.reply({ content: 'Community goal cleared.', allowedMentions: { parse: [], repliedUser: false } }); + } else { + setCommunityGoal(query, { by: message.author?.id || null }); + await message.reply({ content: sanitizeMentions(`Community goal set: ${query}`), allowedMentions: { parse: [], repliedUser: false } }); + } + } catch (err) { + await message.reply({ content: sanitizeMentions(`Failed to update goal: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + }; +} + +module.exports = { createGoalCommand }; diff --git a/server/src/services/discordBotService/commands/help.js b/server/src/services/discordBotService/commands/help.js new file mode 100644 index 00000000..c2c59d65 --- /dev/null +++ b/server/src/services/discordBotService/commands/help.js @@ -0,0 +1,25 @@ +// Discord Help Command +// Purpose: Provides help text for rover bot Discord commands. +// Scope: Returns static command usage text. +function formatHelp() { + return [ + '**Rover Bot Commands**', + '`rs help` — show this help', + '`rs status [id]` — show rover status (all or one)', + '`rs replay [sources]` — send instant replay (room/rover)', + '`rs bridge` — show chat bridge status for this server', + '`rs bridge here ` — set chat bridge to this channel', + '`rs bridge mode ` — change chat bridge mode', + '`rs bridge off` — disable chat bridge for this server', + '`rs lock ` — lock a rover', + '`rs unlock ` — unlock a rover', + '`rs mode ` — change server mode', + '`rs reason [text|clear]` — show or set admin mode reason', + '`rs goal [text|clear]` — show or set community goal', + '`rs verify list|remove ...` — manage verified users', + '`rs deter list|ban|unban ...` — manage deterred users', + '`ts` — show time status', + ].join('\n'); +} + +module.exports = { formatHelp }; diff --git a/server/src/services/discordBotService/commands/index.js b/server/src/services/discordBotService/commands/index.js new file mode 100644 index 00000000..ecb20d75 --- /dev/null +++ b/server/src/services/discordBotService/commands/index.js @@ -0,0 +1,91 @@ +// Discord Commands Router +// Purpose: Routes incoming Discord command messages to one-file-per-command handlers. +// Scope: Central command dispatcher and permission gate orchestration. +const { formatHelp } = require('./help'); +const { createStatusCommand } = require('./status'); +const { createReplayCommand } = require('./replay'); +const { createLockCommand } = require('./lock'); +const { createModeCommand } = require('./mode'); +const { createReasonCommand } = require('./reason'); +const { createGoalCommand } = require('./goal'); +const { createVerifyCommand } = require('./verify'); +const { createDeterCommand } = require('./deter'); +const { createBridgeCommand } = require('./bridge'); +const { createTimeStatusCommand } = require('./timeStatus'); + +function createCommandHandlers(deps) { + const { + getMode, + MODES, + isAdminUser, + isLockdownAdminUser, + } = deps; + + const handleStatusCommand = createStatusCommand(deps); + const handleReplayCommand = createReplayCommand(deps); + const handleLockCommand = createLockCommand(deps); + const handleModeCommand = createModeCommand(deps); + const handleReasonCommand = createReasonCommand(deps); + const handleGoalCommand = createGoalCommand(deps); + const handleVerifyCommand = createVerifyCommand(deps); + const handleDeterCommand = createDeterCommand(deps); + const handleBridgeCommand = createBridgeCommand(deps); + const handleTimeStatusCommand = createTimeStatusCommand(deps); + + async function handleCommand(message) { + if (message.author.bot) return; + const content = (message.content || '').trim(); + const lower = content.toLowerCase(); + if (lower === 'ts' || lower.startsWith('ts')) return handleTimeStatusCommand(message); + if (!lower.startsWith('rs')) return; + + const tokens = content.split(/\s+/); + tokens.shift(); + const action = (tokens.shift() || '').toLowerCase(); + const isAdmin = isAdminUser(message.author.id); + const isLockdownAdmin = isLockdownAdminUser(message.author.id); + const mode = getMode(); + const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']); + + if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') { + return; + } + + if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) { + await message.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + + switch (action) { + case '': + case 'status': + return handleStatusCommand(message, tokens[0]); + case 'help': + return message.reply(formatHelp()); + case 'replay': + return handleReplayCommand(message, tokens.join(' ')); + case 'bridge': + return handleBridgeCommand(message, tokens); + case 'lock': + return handleLockCommand(message, tokens[0], true); + case 'unlock': + return handleLockCommand(message, tokens[0], false); + case 'mode': + return handleModeCommand(message, tokens); + case 'goal': + return handleGoalCommand(message, tokens); + case 'reason': + return handleReasonCommand(message, tokens); + case 'verify': + return handleVerifyCommand(message, tokens); + case 'deter': + return handleDeterCommand(message, tokens); + default: + return message.reply(formatHelp()); + } + } + + return { handleCommand }; +} + +module.exports = { createCommandHandlers }; diff --git a/server/src/services/discordBotService/commands/lock.js b/server/src/services/discordBotService/commands/lock.js new file mode 100644 index 00000000..62006a38 --- /dev/null +++ b/server/src/services/discordBotService/commands/lock.js @@ -0,0 +1,19 @@ +// Discord Lock Command +// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control. +// Scope: Applies lock state updates for a single rover ID. +function createLockCommand({ lockRover, sanitizeMentions }) { + return async function handleLockCommand(message, roverId, locked) { + if (!roverId) { + await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + try { + lockRover(roverId, locked, { reason: 'discord' }); + await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`), allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + }; +} + +module.exports = { createLockCommand }; diff --git a/server/src/services/discordBotService/commands/mode.js b/server/src/services/discordBotService/commands/mode.js new file mode 100644 index 00000000..fe7783af --- /dev/null +++ b/server/src/services/discordBotService/commands/mode.js @@ -0,0 +1,23 @@ +// Discord Mode Command +// Purpose: Handles `rs mode` updates from Discord admins. +// Scope: Validates mode values and applies mode changes with optional reason text. +function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) { + return async function handleModeCommand(message, tokens = []) { + const next = String(tokens.shift() || '').toLowerCase(); + const reasonText = tokens.join(' ').trim(); + if (!Object.values(MODES).includes(next)) { + await message.reply({ content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + try { + const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin'; + setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } }); + if (reasonText) setAdminReason(reasonText, { by: message.author?.id || null }); + await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + }; +} + +module.exports = { createModeCommand }; diff --git a/server/src/services/discordBotService/commands/reason.js b/server/src/services/discordBotService/commands/reason.js new file mode 100644 index 00000000..c69255f2 --- /dev/null +++ b/server/src/services/discordBotService/commands/reason.js @@ -0,0 +1,31 @@ +// Discord Reason Command +// Purpose: Handles admin-mode reason view/update/clear operations. +// Scope: Allows read by all and write by admins. +function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, isAdminUser, sanitizeMentions }) { + return async function handleReasonCommand(message, tokens) { + const query = tokens.join(' ').trim(); + const lower = query.toLowerCase(); + if (!query) { + const reason = getAdminReason(); + await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + if (!isAdminUser(message.author.id)) { + await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + try { + if (lower === 'clear') { + clearAdminReason({ by: message.author?.id || null }); + await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } }); + } else { + setAdminReason(query, { by: message.author?.id || null }); + await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } }); + } + } catch (err) { + await message.reply({ content: sanitizeMentions(`Failed to update reason: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + }; +} + +module.exports = { createReasonCommand }; diff --git a/server/src/services/discordBotService/commands/replay.js b/server/src/services/discordBotService/commands/replay.js new file mode 100644 index 00000000..95eec9f8 --- /dev/null +++ b/server/src/services/discordBotService/commands/replay.js @@ -0,0 +1,58 @@ +// Discord Replay Command +// Purpose: Handles replay capture requests from Discord. +// Scope: Resolves source selectors, enforces cooldowns, and replies with replay video attachment. +const { AttachmentBuilder } = require('discord.js'); + +function createReplayCommand({ getMode, MODES, tryTriggerReplay, getReplaySources, getDefaultDiscordSources, validateSources, buildReplayVideo, sanitizeMentions }) { + function normalizeReplayQuery(input) { return String(input || '').trim().toLowerCase(); } + function sanitizeReplayTitleForFilename(title) { + const cleaned = String(title || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 96); + return cleaned || 'replay'; + } + function buildDefaultReplayTitle(requester, sources = []) { + const roverSource = sources.find((entry) => entry?.type === 'rover'); + return `${String(requester || 'Someone').trim() || 'Someone'} driving ${roverSource?.label || roverSource?.id || 'a rover'}`; + } + function resolveReplaySources(query) { + const cleaned = normalizeReplayQuery(query); + if (!cleaned || cleaned === 'all' || cleaned === '*') return { sources: getDefaultDiscordSources() }; + const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean); + const all = getReplaySources(); + const matches = []; + tokens.forEach((token) => { + const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token]; + const candidate = all.find((entry) => (String(entry.id).toLowerCase() === rest || String(entry.label || '').toLowerCase() === rest) && (!prefix || entry.type === prefix)); + if (candidate) matches.push({ type: candidate.type, id: candidate.id, label: candidate.label }); + }); + const sources = validateSources(matches); + return sources.length ? { sources } : { error: 'No matching sources found', matches: [] }; + } + + return async function handleReplayCommand(message, query) { + if (getMode() === MODES.LOCKDOWN) { + await message.reply({ content: 'Replay is disabled while the server is in lockdown.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' }); + if (!attempt.ok) { + await message.reply({ content: `Replay cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`, allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const resolved = resolveReplaySources(query); + if (resolved?.error) { + await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; + const title = buildDefaultReplayTitle(requester, resolved.sources || []); + try { + const { buffer } = await buildReplayVideo({ sources: resolved.sources || [], title, requester }); + const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(title)}.mp4` }); + await message.reply({ content: sanitizeMentions(`**${title}**`), files: [attachment], allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + await message.reply({ content: sanitizeMentions(`Replay failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + }; +} + +module.exports = { createReplayCommand }; diff --git a/server/src/services/discordBotService/commands/status.js b/server/src/services/discordBotService/commands/status.js new file mode 100644 index 00000000..80a64499 --- /dev/null +++ b/server/src/services/discordBotService/commands/status.js @@ -0,0 +1,64 @@ +// Discord Status Command +// Purpose: Handles rover status display command with battery and lock details. +// Scope: Builds and sends rover status embed for one rover or all visible rovers. +const { EmbedBuilder } = require('discord.js'); + +function createStatusCommand({ rovers, roverManager }) { + function formatVoltage(voltageMv) { return voltageMv == null ? 'n/a' : `${(voltageMv / 1000).toFixed(2)}V`; } + function formatCurrent(currentMa) { return currentMa == null ? 'n/a' : `${currentMa}mA`; } + function formatChargeState(batteryState) { + if (!batteryState) return 'n/a'; + const chargeText = batteryState.charge != null && batteryState.capacity != null ? `${batteryState.charge}/${batteryState.capacity}mAh` : 'n/a'; + const percentText = batteryState.percentDisplay != null ? `${batteryState.percentDisplay}%` : 'n/a'; + return `${chargeText} (${percentText})`; + } + function findRoverRecord(id) { + if (!id) return null; + for (const record of rovers.values()) { + if (String(record.id) === String(id) || String(record.meta?.name) === String(id)) return record; + } + return null; + } + function buildSnapshot(record) { + if (!record) return null; + const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null; + return { + name: record.meta?.name || record.id, + locked: record.locked, + lockReason: record.lockReason, + docked: Boolean(sensors?.chargingSources?.homeBase), + charging: Boolean([2,3,4].includes(sensors?.chargingState?.code)), + chargingLabel: sensors?.chargingState?.label || 'unknown', + voltageMv: sensors?.voltageMv ?? null, + currentMa: sensors?.currentMa ?? null, + batteryState: record.batteryState, + oiMode: sensors?.oiMode?.label || 'unknown', + }; + } + function buildEmbed(records) { + const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(0x2196f3).setTimestamp(new Date()); + const snapshots = records.map(buildSnapshot).filter(Boolean); + if (!snapshots.length) { + embed.setDescription('No rovers online.'); + return embed; + } + snapshots.forEach((s) => { + const lockLabel = s.locked ? `locked${s.lockReason ? ` (${s.lockReason})` : ''}` : 'unlocked'; + embed.addFields({ name: s.name, value: [`Dock: ${s.docked ? 'docked' : 'undocked'}`, `Charging: ${s.charging ? `charging (${s.chargingLabel})` : 'not charging'}`, `Battery: ${formatChargeState(s.batteryState)}`, `Voltage: ${formatVoltage(s.voltageMv)}`, `Current: ${formatCurrent(s.currentMa)}`, `OI: ${s.oiMode}`, `Lock: ${lockLabel}`].join('\n'), inline: true }); + }); + return embed; + } + + return async function handleStatusCommand(message, roverId) { + const single = roverId ? findRoverRecord(roverId) : null; + if (roverId && !single) { + const embed = new EmbedBuilder().setTitle('Rover Status').setDescription('Unknown rover.').setColor(0x2196f3).setTimestamp(new Date()); + await message.reply({ embeds: [embed], allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const records = roverId ? [single] : Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id)); + await message.reply({ embeds: [buildEmbed(records)], allowedMentions: { parse: [], repliedUser: false } }); + }; +} + +module.exports = { createStatusCommand }; diff --git a/server/src/services/discordBotService/commands/timeStatus.js b/server/src/services/discordBotService/commands/timeStatus.js new file mode 100644 index 00000000..233a47c2 --- /dev/null +++ b/server/src/services/discordBotService/commands/timeStatus.js @@ -0,0 +1,36 @@ +// Discord Time Status Command +// Purpose: Handles `ts` command to show timezone snapshots. +// Scope: Builds a concise time embed for common zones and server local zone. +const { EmbedBuilder } = require('discord.js'); + +function createTimeStatusCommand({ config, discordConfig }) { + function buildEmbed({ title, description, color, includeSiteUrl = true }) { + const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); + const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; + if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); + else if (siteUrl) embed.setDescription(siteUrl); + embed.setTimestamp(new Date()); + return embed; + } + function getServerTimezone() { + return config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York'; + } + function formatTimeInZone(date, timeZone) { + try { + return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date); + } catch { + return 'n/a'; + } + } + return async function handleTimeStatusCommand(message) { + const serverTimezone = getServerTimezone(); + const now = new Date(); + const zones = ['UTC', 'America/Los_Angeles', 'America/Denver', 'America/Chicago', 'America/New_York']; + const lines = zones.map((zone) => `${zone} — ${formatTimeInZone(now, zone)}${zone === serverTimezone ? ' **(server local timezone)**' : ''}`); + const embed = buildEmbed({ title: 'Time Status', description: lines.join('\n'), color: 0x2196f3 }); + embed.setFooter({ text: `Server local timezone: ${serverTimezone}` }); + await message.reply({ embeds: [embed], allowedMentions: { parse: [], repliedUser: false } }); + }; +} + +module.exports = { createTimeStatusCommand }; diff --git a/server/src/services/discordBotService/commands/verify.js b/server/src/services/discordBotService/commands/verify.js new file mode 100644 index 00000000..5a53a90a --- /dev/null +++ b/server/src/services/discordBotService/commands/verify.js @@ -0,0 +1,38 @@ +// Discord Verify Command +// Purpose: Handles verified-user moderation commands for lockdown admins. +// Scope: Supports list and remove subcommands. +function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) { + function mask(v) { + const key = String(v || '').trim(); + if (!key) return 'n/a'; + if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`; + return `${key.slice(0, 6)}...${key.slice(-6)}`; + } + + return async function handleVerifyCommand(message, tokens) { + if (!isLockdownAdminUser(message.author?.id)) { + await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } }); + return; + } + const action = (tokens.shift() || 'list').toLowerCase(); + if (action === 'list') { + const users = listVerifiedUsers(); + if (!users.length) return message.reply({ content: 'No verified users.', allowedMentions: { parse: [], repliedUser: false } }); + const lines = users.map((entry, idx) => `${idx + 1}. ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`); + return message.reply({ content: ['Verified users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } }); + } + if (action === 'remove') { + const selector = tokens.join(' ').trim(); + if (!selector) return message.reply({ content: 'Usage: `rs verify remove `', allowedMentions: { parse: [], repliedUser: false } }); + try { + const removed = removeVerifiedUser(selector, message.author?.id || null); + return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } }); + } catch (err) { + return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); + } + } + return message.reply({ content: 'Unknown verify command. Use `rs verify list` or `rs verify remove `.', allowedMentions: { parse: [], repliedUser: false } }); + }; +} + +module.exports = { createVerifyCommand }; diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 2edc99ca..f5b3b24a 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -1,21 +1,14 @@ -// discord Bot Service -// Purpose: Defines the discord Bot Service module and the helpers/state used by this service unit. -// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. +// Discord Bot Service +// Purpose: Composes Discord client bootstrap, command routing, presence rotation, and integration/event wiring. +// Scope: Keeps the service entrypoint focused on orchestration while delegating behavior to focused modules. const { Client, GatewayIntentBits, Partials, - ActivityType, - EmbedBuilder, - AttachmentBuilder, - PermissionsBitField, - WebhookClient, - MessageFlags, } = require('discord.js'); const logger = require('../../globals/logger').child('discordBot'); const io = require('../../globals/io'); const { loadConfig } = require('../../helpers/configLoader'); -const { subscribe } = require('../eventBus'); const roverManager = require('../roverManager'); const { getRoster, lockRover, rovers } = roverManager; const { MODES, getMode, setMode } = require('../modeManager'); @@ -47,25 +40,22 @@ const { undeterUser, } = require('../verificationService'); const { - DM_APPROVE_EMOJI: PRIVATE_ACCESS_APPROVE_EMOJI, - DM_DENY_EMOJI: PRIVATE_ACCESS_DENY_EMOJI, attachDmMessage: attachPrivateAccessDmMessage, getRequestByMessageId: getPrivateAccessRequestByMessageId, approveRequest: approvePrivateAccessRequest, denyRequest: denyPrivateAccessRequest, } = require('../privateRoverAccessRequestService'); +const { subscribe } = require('../eventBus'); +const { createPresenceManager } = require('./presence'); +const { createChannelIO } = require('./channelIO'); +const { createCommandHandlers } = require('./commands'); +const { createIntegrations } = require('./integrations'); + const config = loadConfig(); const discordConfig = config.discord || {}; const enabled = Boolean(discordConfig.token); -const adminIds = new Set( - (config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean), -); -const lockdownAdminIds = new Set( - (config.admins || []) - .filter((admin) => admin.lockdown) - .map((admin) => String(admin.discord_id || '').trim()) - .filter(Boolean), -); +const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean)); +const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean)); if (!enabled) { logger.info('Discord bot disabled; missing token in config.discord.token'); @@ -87,207 +77,14 @@ const client = new Client({ partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User], }); -const channelCache = new Map(); -const typingMessageCache = new Map(); -let skippedFirstModeAnnouncement = false; -const PRESENCE_ROTATE_MS = 20000; -let presenceInterval = null; -let presenceShowGoal = false; -const VERIFY_APPROVE_EMOJI = '✅'; -const VERIFY_DENY_EMOJI = '❌'; -const ADMIN_ALERT_EVENT_TYPES = new Set([ - 'rover.online', - 'rover.offline', - 'rover.dockGuard', - 'battery.warn', - 'battery.urgent', - 'battery.docked', - 'battery.undocked', - 'battery.charging.start', - 'battery.charging.stop', - 'battery.locked', - 'battery.unlocked', -]); function sanitizeMentions(text) { if (!text) return ''; return String(text) - // strip mention tokens .replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]') - // neutralize everyone/here .replace(/@everyone/gi, '[everyone]') .replace(/@here/gi, '[here]'); } -function formatVoltage(voltageMv) { - if (voltageMv == null) return 'n/a'; - return `${(voltageMv / 1000).toFixed(2)}V`; -} - -function formatCurrent(currentMa) { - if (currentMa == null) return 'n/a'; - // const amps = currentMa / 1000; - return `${currentMa}mA`; -} - -function formatChargeState(batteryState) { - if (!batteryState) return 'n/a'; - const charge = batteryState.charge; - const capacity = batteryState.capacity; - const percent = batteryState.percentDisplay; - const chargeText = charge != null && capacity != null ? `${charge}/${capacity}mAh` : 'n/a'; - const percentText = percent != null ? `${percent}%` : 'n/a'; - return `${chargeText} (${percentText})`; -} - -function formatDockEmoji(docked) { - return docked ? '🏠' : '🧭'; -} - -function formatChargeEmoji(charging) { - return charging ? '⚡' : '🔌'; -} - -function formatLockEmoji(locked) { - return locked ? '🔒' : '🔓'; -} - -function formatBatteryEmoji(batteryState) { - if (batteryState?.urgentActive) return '🛑'; - if (batteryState?.warnActive) return '⚠️'; - return '🔋'; -} - -function formatOiEmoji(oiMode) { - if (oiMode === 'full') return '🕹️'; - if (oiMode === 'safe') return '🧰'; - if (oiMode === 'passive') return '🟢'; - if (oiMode === 'off') return '⏹️'; - return '❔'; -} - -function formatDuration(ms) { - if (ms == null) return 'n/a'; - const seconds = Math.max(0, Math.round(ms / 1000)); - const minutes = Math.floor(seconds / 60); - const remainder = seconds % 60; - if (minutes <= 0) return `${seconds}s`; - return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; -} - -function isCharging(sensors) { - const label = sensors?.chargingState?.label?.toLowerCase(); - const chargingByLabel = - label === 'waiting' || label === 'full charging' || label === 'trickle charging'; - const code = sensors?.chargingState?.code; - const chargingByCode = code === 2 || code === 3 || code === 4; - return chargingByLabel || chargingByCode; -} - -function buildRoverStatusSnapshot(record) { - if (!record) return null; - const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null; - const docked = Boolean(sensors?.chargingSources?.homeBase); - const charging = isCharging(sensors); - const chargingLabel = sensors?.chargingState?.label || 'unknown'; - const oiMode = sensors?.oiMode?.label || 'unknown'; - return { - id: record.id, - name: record.meta?.name || record.id, - locked: record.locked, - lockReason: record.lockReason, - docked, - charging, - chargingLabel, - voltageMv: sensors?.voltageMv ?? null, - currentMa: sensors?.currentMa ?? null, - batteryState: record.batteryState, - oiMode, - }; -} - -function countReady() { - const roster = getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id)); - const total = roster.length; - const ready = roster.filter((r) => !r.locked).length; - return { ready, total }; -} - -function truncatePresenceText(text, maxLength) { - if (!text) return ''; - if (text.length <= maxLength) return text; - if (maxLength <= 3) return text.slice(0, maxLength); - return `${text.slice(0, maxLength - 3)}...`; -} - -function buildPresenceName() { - const { ready, total } = countReady(); - const mode = getMode(); - const goal = getCommunityGoal(); - const goalText = goal?.text ? String(goal.text).trim() : ''; - if (presenceShowGoal && goalText) { - const trimmed = truncatePresenceText(goalText, 110); - return `Goal: ${trimmed}`; - } - return `${mode} · ${ready}/${total} Rovers Ready`; -} - -async function updatePresence() { - if (!client?.user) return; - try { - await client.user.setPresence({ - activities: [{ name: buildPresenceName(), type: ActivityType.Watching }], - status: 'online', - }); - } catch (err) { - logger.warn('Failed to update Discord presence', err.message); - } -} - -function schedulePresenceRotation() { - if (presenceInterval) { - clearInterval(presenceInterval); - presenceInterval = null; - } - const goal = getCommunityGoal(); - if (!goal?.text) { - presenceShowGoal = false; - updatePresence(); - return; - } - presenceShowGoal = false; - updatePresence(); - presenceInterval = setInterval(() => { - presenceShowGoal = !presenceShowGoal; - updatePresence(); - }, PRESENCE_ROTATE_MS); -} - -async function fetchChannel(id) { - if (!id) return null; - if (channelCache.has(id)) return channelCache.get(id); - try { - const channel = await client.channels.fetch(id); - if (channel) { - channelCache.set(id, channel); - return channel; - } - } catch (err) { - logger.warn('Failed to fetch Discord channel', { id, error: err.message }); - } - return null; -} - -async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) { - const channel = await fetchChannel(id); - if (!channel) return; - try { - const messageContent = sanitizeContent ? sanitizeMentions(content) : content; - await channel.send({ content: messageContent, allowedMentions, ...options }); - } catch (err) { - logger.warn('Failed to send Discord message', { id, error: err.message }); - } -} - function isAdminUser(discordId) { return adminIds.has(String(discordId || '').trim()); } @@ -296,1700 +93,117 @@ function isLockdownAdminUser(discordId) { return lockdownAdminIds.has(String(discordId || '').trim()); } -function formatHelp() { - return [ - '**Rover Bot Commands**', - '`rs help` — show this help', - '`rs status [id]` — show rover status (all or one)', - '`rs replay [sources]` — send instant replay (room/rover)', - '`rs bridge` — show chat bridge status for this server', - '`rs bridge here ` — set chat bridge to this channel', - '`rs bridge mode ` — change chat bridge mode', - '`rs bridge off` — disable chat bridge for this server', - '`rs lock ` — lock a rover', - '`rs unlock ` — unlock a rover', - '`rs mode ` — change server mode', - '`rs reason [text|clear]` — show or set admin mode reason', - '`rs goal [text|clear]` — show or set community goal', - '`rs verify list` — list verified users (lockdown admins)', - '`rs verify remove ` — remove verified user (lockdown admins)', - '`rs deter list` — list deterred users (lockdown admins)', - '`rs deter ban [reason]` — deter a user (lockdown admins)', - '`rs deter unban ` — remove deterrence (lockdown admins)', - '`ts` — show time status', - ].join('\n'); +function countReady() { + const roster = getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id)); + const total = roster.length; + const ready = roster.filter((r) => !r.locked).length; + return { ready, total }; } -function findRoverRecord(id) { - if (!id) return null; - for (const record of rovers.values()) { - if (String(record.id) === String(id) || String(record.meta?.name) === String(id)) { - return record; - } - } - return null; -} - -async function handleStatusCommand(message, roverId) { - const record = roverId ? findRoverRecord(roverId) : null; - if (roverId && !record) { - const embed = buildEmbed({ title: 'Rover Status', description: 'Unknown rover.', color: 0x2196f3 }); - await message.reply({ - embeds: [embed], - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const records = roverId ? [record] : Array.from(rovers.values()); - const embed = buildBatteryStatusEmbed(0x2196f3, records); - await message.reply({ - embeds: [embed], - allowedMentions: { parse: [], repliedUser: false }, - }); -} - -function buildDriverCaption() { - const activeDrivers = getActiveDrivers(); - const roster = Array.from(rovers.values()); - if (!roster.length) return 'Drivers: no rovers online.'; - const entries = roster.map((record) => { - const driverId = activeDrivers[record.id]; - if (!driverId) { - return `${record.meta?.name || record.id}: none`; - } - const socket = io.sockets.sockets.get(driverId); - const nickname = getNickname(socket) || socket?.data?.user?.username || driverId; - return `${record.meta?.name || record.id}: ${nickname}`; - }); - return `Drivers: ${entries.join(', ')}`; -} - -function normalizeReplayQuery(input) { - return String(input || '').trim().toLowerCase(); -} - -function sanitizeReplayTitleForFilename(title) { - const cleaned = String(title || '') - .replace(/[\\/:*?"<>|]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 96); - return cleaned || 'replay'; -} - -function buildDefaultReplayTitle(requester, sources = []) { - const requesterLabel = String(requester || 'Someone').trim() || 'Someone'; - const roverSource = sources.find((entry) => entry?.type === 'rover'); - const roverLabel = roverSource?.label || roverSource?.id || 'a rover'; - return `${requesterLabel} driving ${roverLabel}`; -} - -function buildReplayDriverLines(requester, usedSources = []) { - const activeDrivers = getActiveDrivers(); - const roverRecords = Array.from(rovers.values()); - const roverById = new Map(roverRecords.map((record) => [String(record.id), record])); - const requestedRoverIds = new Set( - usedSources.filter((entry) => entry?.type === 'rover').map((entry) => String(entry.id)), - ); - const lines = []; - requestedRoverIds.forEach((roverId) => { - const socketId = activeDrivers[roverId]; - if (!socketId) return; - const socket = io.sockets.sockets.get(socketId); - const nickname = getNickname(socket) || socket?.data?.user?.username || socketId; - const roverRecord = roverById.get(roverId); - const roverName = roverRecord?.meta?.name || roverRecord?.id || roverId; - const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase(); - lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`); - }); - return lines; -} - -function resolveReplaySources(query) { - const cleaned = normalizeReplayQuery(query); - if (!cleaned || cleaned === 'all' || cleaned === '*') { - return { sources: getDefaultDiscordSources() }; - } - const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean); - const all = getReplaySources(); - const matches = []; - tokens.forEach((token) => { - const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token]; - const candidates = all.filter((entry) => { - const matchId = String(entry.id).toLowerCase() === rest; - const matchLabel = String(entry.label || '').toLowerCase() === rest; - if (!matchId && !matchLabel) return false; - if (!prefix) return true; - return entry.type === prefix; - }); - if (candidates.length === 1) { - matches.push({ type: candidates[0].type, id: candidates[0].id, label: candidates[0].label }); - } - }); - const sources = validateSources(matches); - if (!sources.length) { - return { error: 'No matching sources found', matches: [] }; - } - return { sources }; -} - -function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) { - const lines = []; - if (title) { - lines.push(`**${title}**`); - lines.push(''); - } - const driverLines = buildReplayDriverLines(requester, usedSources); - if (driverLines.length) { - lines.push(...driverLines); - } - if (missingSources.length) { - if (driverLines.length) lines.push(''); - lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`); - } - if (!lines.length) { - lines.push(buildDriverCaption()); - } - return lines.join('\n'); -} - -async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true) { - if (!channelId) { - throw new Error('Replay channel not configured'); - } - const resolvedTitle = String(explicitTitle || '').trim() || buildDefaultReplayTitle(requester, sources); - const { buffer, usedSources, missingSources } = await buildReplayVideo({ - sources, - title: resolvedTitle, - requester, - includeSidebar, - }); - const filenameBase = sanitizeReplayTitleForFilename(resolvedTitle); - const attachment = new AttachmentBuilder(buffer, { name: `${filenameBase}.mp4` }); - const caption = buildReplayCaption({ requester, usedSources, missingSources, title: resolvedTitle }); - await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] }); -} - -async function handleReplayCommand(message, query) { - if (getMode() === MODES.LOCKDOWN) { - await message.reply({ - content: 'Replay is disabled while the server is in lockdown.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const attempt = tryTriggerReplay({ - by: message.author?.id || null, - source: 'discord', - }); - if (!attempt.ok) { - const remaining = Math.ceil(attempt.remainingMs / 1000); - await message.reply({ - content: `Replay cooldown active. Try again in ${remaining}s.`, - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const resolved = resolveReplaySources(query); - if (resolved?.error) { - await message.reply({ - content: sanitizeMentions(resolved.error), - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const sources = resolved.sources || []; - const requester = - message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; - try { - const resolvedTitle = buildDefaultReplayTitle(requester, sources); - const { buffer, usedSources, missingSources } = await buildReplayVideo({ - sources, - title: resolvedTitle, - requester, - }); - const filenameBase = sanitizeReplayTitleForFilename(resolvedTitle); - const attachment = new AttachmentBuilder(buffer, { name: `${filenameBase}.mp4` }); - const caption = buildReplayCaption({ requester, usedSources, missingSources, title: resolvedTitle }); - await message.reply({ - content: sanitizeMentions(caption), - files: [attachment], - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - logger.warn('Replay capture failed', err.message); - await message.reply({ - content: sanitizeMentions(`Replay failed: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } -} - -async function handleLockCommand(message, roverId, locked) { - if (!roverId) { - await message.reply({ - content: 'Specify a rover ID. Example: `rs lock alpha`', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - try { - lockRover(roverId, locked, { reason: 'discord' }); - await message.reply({ - content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } -} - -async function handleModeCommand(message, tokens = []) { - const next = String(tokens.shift() || '').toLowerCase(); - const reasonText = tokens.join(' ').trim(); - if (!Object.values(MODES).includes(next)) { - await message.reply({ - content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - try { - const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin'; - setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } }); - if (reasonText) { - setAdminReason(reasonText, { by: message.author?.id || null }); - } - await message.reply({ - content: sanitizeMentions(`Mode set to ${next}.`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to set mode: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } -} - -async function handleReasonCommand(message, tokens) { - const query = tokens.join(' ').trim(); - const lower = query.toLowerCase(); - if (!query) { - const reason = getAdminReason(); - const text = reason?.text ? reason.text : null; - await message.reply({ - content: text ? `Admin mode reason: ${sanitizeMentions(text)}` : 'No admin mode reason set.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (!isAdminUser(message.author.id)) { - await message.reply({ - content: 'Only admins can update the admin mode reason.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (lower === 'clear') { - try { - clearAdminReason({ by: message.author?.id || null }); - await message.reply({ - content: 'Admin mode reason cleared.', - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to clear reason: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - try { - setAdminReason(query, { by: message.author?.id || null }); - await message.reply({ - content: sanitizeMentions(`Admin mode reason set: ${query}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to set reason: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } -} - -async function handleGoalCommand(message, tokens) { - const query = tokens.join(' ').trim(); - const lower = query.toLowerCase(); - if (!query) { - const goal = getCommunityGoal(); - const text = goal?.text ? goal.text : null; - await message.reply({ - content: text ? `Community goal: ${sanitizeMentions(text)}` : 'No community goal set.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (!isAdminUser(message.author.id)) { - await message.reply({ - content: 'Only admins can update the community goal.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (lower === 'clear') { - try { - clearCommunityGoal({ by: message.author?.id || null }); - await message.reply({ - content: 'Community goal cleared.', - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to clear goal: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - try { - setCommunityGoal(query, { by: message.author?.id || null }); - await message.reply({ - content: sanitizeMentions(`Community goal set: ${query}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to set goal: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } -} - -function formatMaskedCookieKey(value) { - const key = String(value || '').trim(); - if (!key) return 'n/a'; - if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`; - return `${key.slice(0, 6)}...${key.slice(-6)}`; -} - -async function handleVerifyCommand(message, tokens) { - if (!isLockdownAdminUser(message.author?.id)) { - await message.reply({ - content: 'Only lockdown admins can manage verified users.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - const action = (tokens.shift() || 'list').toLowerCase(); - if (action === 'list') { - const users = listVerifiedUsers(); - if (!users.length) { - await message.reply({ - content: 'No verified users.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const lines = users.map((entry, idx) => { - const updated = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'unknown'; - const ipCount = Array.isArray(entry.knownIps) ? entry.knownIps.length : 0; - return `${idx + 1}. ${entry.nickname || 'unknown'} | ${formatMaskedCookieKey(entry.cookieUserId)} | ips:${ipCount} | updated:${updated}`; - }); - await message.reply({ - content: ['Verified users:', ...lines].join('\n').slice(0, 1900), - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (action === 'remove') { - const selector = tokens.join(' ').trim(); - if (!selector) { - await message.reply({ - content: 'Usage: `rs verify remove `', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - try { - const removed = removeVerifiedUser(selector, message.author?.id || null); - await message.reply({ - content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${formatMaskedCookieKey(removed.cookieUserId)}).`, - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - await message.reply({ - content: 'Unknown verify command. Use `rs verify list` or `rs verify remove `.', - allowedMentions: { parse: [], repliedUser: false }, - }); -} - -async function handleDeterCommand(message, tokens) { - if (!isLockdownAdminUser(message.author?.id)) { - await message.reply({ - content: 'Only lockdown admins can manage deterred users.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - const action = (tokens.shift() || 'list').toLowerCase(); - - if (action === 'list') { - const users = listDeterredUsers(); - if (!users.length) { - await message.reply({ - content: 'No deterred users.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const lines = users.map((entry, idx) => { - const updated = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'unknown'; - const ipCount = Array.isArray(entry.knownIps) ? entry.knownIps.length : 0; - const reason = entry.reason ? ` | reason:${entry.reason}` : ''; - return `${idx + 1}. ${entry.id} | ${entry.nickname || 'unknown'} | ${formatMaskedCookieKey(entry.cookieUserId)} | ips:${ipCount} | updated:${updated}${reason}`; - }); - await message.reply({ - content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (action === 'ban') { - const selector = String(tokens.shift() || '').trim(); - const reason = tokens.join(' ').trim(); - if (!selector) { - await message.reply({ - content: 'Usage: `rs deter ban [reason]`', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - try { - const deterred = deterUser(selector, { reason, actor: message.author?.id || null }); - await message.reply({ - content: sanitizeMentions( - `${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${formatMaskedCookieKey(deterred.cookieUserId)}).`, - ), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to deter user: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - if (action === 'unban') { - const selector = tokens.join(' ').trim(); - if (!selector) { - await message.reply({ - content: 'Usage: `rs deter unban `', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - try { - const removed = undeterUser(selector, message.author?.id || null); - await message.reply({ - content: sanitizeMentions( - `Removed deterrence for ${removed.nickname || 'unknown'} (${formatMaskedCookieKey(removed.cookieUserId)}).`, - ), - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - await message.reply({ - content: 'Unknown deter command. Use `rs deter list`, `rs deter ban [reason]`, or `rs deter unban `.', - allowedMentions: { parse: [], repliedUser: false }, - }); -} - -function canManageBridge(message) { - if (isAdminUser(message.author.id)) return true; - if (!message.guild || !message.member) return false; - const perms = message.member.permissions; - if (!perms) return false; - return ( - perms.has(PermissionsBitField.Flags.ManageGuild) || - perms.has(PermissionsBitField.Flags.Administrator) - ); -} - -function canManageWebhooksInChannel(channel) { - if (!channel?.guild) return false; - const botMember = channel.guild.members?.me; - const perms = channel.permissionsFor(botMember); - if (!perms) return false; - return perms.has(PermissionsBitField.Flags.ManageWebhooks); -} - -async function ensureBridgeWebhook(channel, guildId) { - if (!channel?.id || !guildId) return null; - if (!canManageWebhooksInChannel(channel)) { - throw new Error('Missing Manage Webhooks permission in this channel.'); - } - const existing = getGuildConfig(guildId); - if ( - existing?.channelId && - String(existing.channelId) === String(channel.id) && - existing?.webhookId && - existing?.webhookToken - ) { - return existing; - } - const webhook = await channel.createWebhook({ - name: 'Rover Chat Bridge', - reason: 'Rover chat bridge webhook', - }); - if (!webhook?.id || !webhook?.token) { - throw new Error('Failed to create webhook.'); - } - return setGuildConfig(guildId, { - channelId: channel.id, - mode: existing?.mode || 'global', - webhookId: webhook.id, - webhookToken: webhook.token, - }); -} - -function formatBridgeStatus(entry) { - if (!entry) return 'Chat bridge is not configured for this server.'; - return `Chat bridge is **${entry.mode}** in <#${entry.channelId}>.`; -} - -async function handleBridgeCommand(message, tokens) { - if (!message.guild) { - await message.reply({ - content: 'Chat bridge must be configured in a server channel.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const guildId = message.guild.id; - let action = (tokens.shift() || 'status').toLowerCase(); - let mode = null; - if (action === 'global' || action === 'private') { - mode = action; - action = 'here'; - } else if (action === 'here' || action === 'mode') { - mode = (tokens.shift() || '').toLowerCase(); - } - if (mode && !VALID_MODES.has(mode)) { - await message.reply({ - content: 'Invalid mode. Use `global` or `private`.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (action === 'status') { - const entry = getGuildConfig(guildId); - await message.reply({ - content: formatBridgeStatus(entry), - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (action === 'off') { - removeGuildConfig(guildId); - await message.reply({ - content: 'Chat bridge disabled for this server.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (!canManageBridge(message)) { - await message.reply({ - content: 'You need Manage Server permissions to change the chat bridge.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - if (action === 'here') { - try { - const entry = await ensureBridgeWebhook(message.channel, guildId); - if (mode) { - setGuildConfig(guildId, { channelId: entry.channelId, mode, webhookId: entry.webhookId, webhookToken: entry.webhookToken }); - } - const updated = getGuildConfig(guildId); - await message.reply({ - content: `Chat bridge set to **${updated.mode}** in <#${updated.channelId}>.`, - allowedMentions: { parse: [], repliedUser: false }, - }); - } catch (err) { - await message.reply({ - content: `Failed to set chat bridge: ${err.message}`, - allowedMentions: { parse: [], repliedUser: false }, - }); - } - return; - } - - if (action === 'mode') { - const current = getGuildConfig(guildId); - if (!current?.channelId) { - await message.reply({ - content: 'No chat bridge channel set. Use `rs bridge here ` first.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const nextMode = normalizeMode(mode, null); - if (!VALID_MODES.has(nextMode)) { - await message.reply({ - content: 'Invalid mode. Use `global` or `private`.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - const entry = setGuildConfig(guildId, { - channelId: current.channelId, - mode: nextMode, - webhookId: current.webhookId, - webhookToken: current.webhookToken, - }); - await message.reply({ - content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - await message.reply({ - content: 'Unknown bridge command. Try `rs bridge`.', - allowedMentions: { parse: [], repliedUser: false }, - }); -} - -async function handleCommand(message) { - if (message.author.bot) return; - const content = (message.content || '').trim(); - const lower = content.toLowerCase(); - if (lower === 'ts' || lower.startsWith('ts')) { - await handleTimeStatusCommand(message); - return; - } - if (!lower.startsWith('rs')) return; - - const tokens = content.split(/\s+/); - tokens.shift(); // remove prefix - const action = (tokens.shift() || '').toLowerCase(); - const isAdmin = isAdminUser(message.author.id); - const isLockdownAdmin = isLockdownAdminUser(message.author.id); - const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false; - const mode = getMode(); - const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']); - - if ( - !isAdmin && - !isBridgeAdmin && - action !== '' && - action !== 'status' && - action !== 'help' && - action !== 'replay' && - action !== 'bridge' && - action !== 'goal' && - action !== 'reason' && - action !== 'verify' && - action !== 'deter' - ) { - return; // ignore non-admins for privileged commands - } - - if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) { - await message.reply({ - content: 'Lockdown mode: only lockdown admins can run that command.', - allowedMentions: { parse: [], repliedUser: false }, - }); - return; - } - - switch (action) { - case '': - await handleStatusCommand(message, tokens[0]); - break; - case 'help': - await message.reply(formatHelp()); - break; - case 'status': - await handleStatusCommand(message, tokens[0]); - break; - case 'replay': - await handleReplayCommand(message, tokens.join(' ')); - break; - case 'bridge': - await handleBridgeCommand(message, tokens); - break; - case 'lock': - await handleLockCommand(message, tokens[0], true); - break; - case 'unlock': - await handleLockCommand(message, tokens[0], false); - break; - case 'mode': - await handleModeCommand(message, tokens); - break; - case 'goal': - await handleGoalCommand(message, tokens); - break; - case 'reason': - await handleReasonCommand(message, tokens); - break; - case 'verify': - await handleVerifyCommand(message, tokens); - break; - case 'deter': - await handleDeterCommand(message, tokens); - break; - default: - await message.reply(formatHelp()); - break; - } -} - -function formatWebhookUsername(payload) { - const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown'; - if (payload.fromDiscord) { - const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : ''; - const isAdmin = - payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin'; - const adminTag = isAdmin ? ' [Rover Admin]' : ''; - return `${name}${origin}${adminTag}`; - } - const record = payload.roverId ? rovers.get(payload.roverId) : null; - const percent = record?.batteryState?.percentDisplay; - const voltageMv = record?.lastSensor?.decoded?.voltageMv ?? record?.lastSensor?.sensors?.voltageMv; - const batteryText = percent != null ? `battery ${percent}%` : null; - const voltageText = voltageMv != null ? `voltage ${formatVoltage(voltageMv)}` : null; - const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`; - const roleText = - payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' - ? 'Admin' - : null; - const suffix = [roverText, roleText].filter(Boolean).join(' · '); - return suffix ? `${name} · ${suffix}` : name; -} - -function getTypingId(payload = {}) { - if (payload.typingId) return payload.typingId; - if (payload.fromDiscord) { - if (payload.discordUserId) return `discord:${payload.discordUserId}`; - if (payload.discordUserName) return `discord:${payload.discordUserName}`; - if (payload.nickname) return `discord:${payload.nickname}`; - return 'discord:unknown'; - } - if (payload.socketId) return `socket:${payload.socketId}`; - if (payload.nickname) return `socket:${payload.nickname}`; - return 'socket:unknown'; -} - -function typingCacheKey(guildId, typingId) { - return `${guildId}:${typingId}`; -} - -async function clearTypingMessage(guildId, typingId) { - const key = typingCacheKey(guildId, typingId); - const record = typingMessageCache.get(key); - if (!record) return; - typingMessageCache.delete(key); - if (record.timeoutId) clearTimeout(record.timeoutId); - const channel = await fetchChannel(record.channelId); - if (!channel?.messages?.fetch) return; - try { - const msg = await channel.messages.fetch(record.messageId); - await msg.delete(); - } catch (err) { - if (err?.code !== 10008) { - logger.warn('Failed to delete typing message', { guildId, error: err.message }); - } - } -} - -async function sendTypingMessage(entry, payload) { - const typingId = getTypingId(payload); - const key = typingCacheKey(entry.guildId, typingId); - if (typingMessageCache.has(key)) return; - const channel = await fetchChannel(entry.channelId); - if (!channel?.send) return; - const username = formatWebhookUsername(payload); - const content = `-# *${username} is typing...*`; - try { - const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]}); - const timeoutId = setTimeout(() => { - clearTypingMessage(entry.guildId, typingId); - }, 20000); - typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId }); - } catch (err) { - logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message }); - } -} - -async function handleBridgeInbound(message) { - if (!message.guild) return; - const guildConfig = getGuildConfig(message.guild.id); - if (!guildConfig?.channelId) return; - if (String(message.channelId) !== String(guildConfig.channelId)) return; - if (message.author.bot) return; - const content = (message.content || '').trim(); - const lower = content.toLowerCase(); - if (lower.startsWith('rs') || lower === 'ts' || lower.startsWith('ts ')) return; // don't echo commands - const nickname = - message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; - const role = isAdminUser(message.author.id) ? 'admin' : 'user'; - const guildIconUrl = message.guild.iconURL?.({ extension: 'png', size: 64 }) || null; - const userAvatarUrl = message.author.displayAvatarURL?.({ extension: 'png', size: 64 }) || null; - try { - sendExternalMessage({ - text: content, - nickname, - role, - roverId: null, - discordGuildId: message.guild.id, - discordGuildName: message.guild.name, - discordGuildIconUrl: guildIconUrl, - discordChannelId: message.channelId, - discordUserId: message.author?.id || null, - discordUserName: message.author?.globalName || message.author?.username || null, - discordUserAvatarUrl: userAvatarUrl, - }); - } catch (err) { - logger.warn('Failed to bridge inbound Discord chat', err.message); - } -} - -function buildEmbed({ title, description, color, includeSiteUrl = true }) { - const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); - const siteUrl = - includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; - if (description) { - embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); - } else if (siteUrl) { - embed.setDescription(siteUrl); - } - embed.setTimestamp(new Date()); - return embed; -} - -function getServerTimezone() { - return config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York'; -} - -function formatTimeInZone(date, timeZone) { - try { - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone, - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - return formatter.format(date); - } catch (err) { - return 'n/a'; - } -} - -function buildTimeStatusEmbed() { - const serverTimezone = getServerTimezone(); - const now = new Date(); - const zones = [ - { label: 'UTC', zone: 'UTC' }, - { label: 'US Pacific', zone: 'America/Los_Angeles' }, - { label: 'US Mountain', zone: 'America/Denver' }, - { label: 'US Central', zone: 'America/Chicago' }, - { label: 'US Eastern', zone: 'America/New_York' }, - { label: 'Europe London', zone: 'Europe/London' }, - { label: 'Europe Berlin', zone: 'Europe/Berlin' }, - { label: 'Asia Kolkata', zone: 'Asia/Kolkata' }, - { label: 'Asia Shanghai', zone: 'Asia/Shanghai' }, - { label: 'Asia Tokyo', zone: 'Asia/Tokyo' }, - { label: 'Australia Sydney', zone: 'Australia/Sydney' }, - { label: 'New Zealand Auckland', zone: 'Pacific/Auckland' }, - ]; - - const entries = zones.map((entry) => { - const time = formatTimeInZone(now, entry.zone); - const highlight = - String(entry.zone).toLowerCase() === String(serverTimezone).toLowerCase() - ? ' **(server local timezone)**' - : ''; - return `${entry.label} — ${time}${highlight}`; - }); - - if (!zones.some((entry) => String(entry.zone).toLowerCase() === String(serverTimezone).toLowerCase())) { - const time = formatTimeInZone(now, serverTimezone); - entries.push(`Server Local — ${time} **(server local timezone)**`); - } - - const embed = buildEmbed({ - title: 'Time Status', - description: entries.join('\n'), - color: 0x2196f3, - }); - embed.setFooter({ text: `Server local timezone: ${serverTimezone}` }); - return embed; -} - -async function handleTimeStatusCommand(message) { - const embed = buildTimeStatusEmbed(); - await message.reply({ - embeds: [embed], - allowedMentions: { parse: [], repliedUser: false }, - }); -} - -function buildBatteryStatusEmbed(color, records = null) { - const embed = buildEmbed({ title: 'Rover Battery Status', color: color || 0x2196f3 }); - const sourceRecords = records || Array.from(rovers.values()); - const baseRecords = records - ? sourceRecords - : sourceRecords.filter((entry) => roverManager.canReplayRoverId(entry?.id)); - const snapshots = baseRecords.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean); - if (snapshots.length === 0) { - embed.setDescription('No rovers online.'); - return embed; - } - snapshots.forEach((snapshot) => { - const lockLabel = snapshot.locked - ? `locked${snapshot.lockReason ? ` (${snapshot.lockReason})` : ''}` - : 'unlocked'; - const dockLabel = snapshot.docked ? 'docked' : 'undocked'; - const chargingLabel = snapshot.charging ? `charging (${snapshot.chargingLabel})` : 'not charging'; - const header = [ - formatBatteryEmoji(snapshot.batteryState), - formatDockEmoji(snapshot.docked), - formatChargeEmoji(snapshot.charging), - formatLockEmoji(snapshot.locked), - ].join(' '); - const lines = [ - `Dock: ${dockLabel}`, - `Charging: ${chargingLabel}`, - `Battery: ${formatChargeState(snapshot.batteryState)}`, - `Voltage: ${formatVoltage(snapshot.voltageMv)}`, - `Current: ${formatCurrent(snapshot.currentMa)}`, - `OI: ${snapshot.oiMode} ${formatOiEmoji(snapshot.oiMode)}`, - `Lock: ${lockLabel}`, - ]; - embed.addFields({ - name: `${header} ${snapshot.name}`, - value: lines.join('\n'), - inline: true, - }); - }); - return embed; -} - -function buildAllUnlockedEmbed(color, records = null) { - const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 }); - const baseRecords = (records || Array.from(rovers.values())).filter((entry) => - roverManager.canReplayRoverId(entry?.id), - ); - const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean); - if (snapshots.length === 0) { - embed.setDescription('No rovers online.'); - return embed; - } - snapshots.forEach((snapshot) => { - const percent = snapshot?.batteryState?.percentDisplay; - const percentLabel = percent != null ? `${percent}%` : 'n/a'; - embed.addFields({ - name: snapshot.name, - value: `Battery: ${percentLabel}`, - inline: true, - }); - }); - return embed; -} - -function buildAllUnlockedCaption(records = null) { - return 'All rovers unlocked.'; -} - -function buildAccessModeEmbed(mode, color) { - const visible = Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id)); - const total = visible.length; - const unlocked = visible.filter((entry) => !entry.locked).length; - const embed = buildEmbed({ - title: 'Access Mode Updated', - description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`, - color: color || 0x2196f3, - }); - return embed; -} - -function buildBatteryCaption(type, payload) { - const roverId = payload?.roverId || 'unknown'; - const record = rovers.get(roverId) || findRoverRecord(roverId); - const snapshot = buildRoverStatusSnapshot(record); - const base = snapshot?.name || roverId; - const percent = snapshot?.batteryState?.percentDisplay; - const percentLabel = percent != null ? `${percent}%` : 'n/a'; - const dockLabel = snapshot?.docked ? 'docked' : 'undocked'; - const chargingLabel = snapshot?.charging ? 'charging' : 'not charging'; - const voltage = formatVoltage(snapshot?.voltageMv ?? null); - const current = formatCurrent(snapshot?.currentMa ?? null); - const charge = formatChargeState(snapshot?.batteryState ?? null); - const detail = `${dockLabel}, ${chargingLabel}, ${voltage}, ${current}, ${charge}`; - - switch (type) { - case 'battery.warn': - return `Battery warn: ${base} at ${percentLabel}. ${detail}`; - case 'battery.urgent': - return `Battery urgent: ${base} at ${percentLabel}. ${detail}`; - case 'battery.docked': - return `Docked: ${base}. ${detail}`; - case 'battery.undocked': - return `Undocked: ${base}. ${detail}`; - case 'battery.charging.start': - return `Charging started: ${base}. ${detail}`; - case 'battery.charging.stop': - return `Charging stopped: ${base}. ${detail}`; - case 'battery.locked': - return `Locked for charging: ${base}. ${detail}`; - case 'battery.unlocked': - return `Unlocked after charging: ${base}. ${detail}`; - default: - return `Battery update: ${base}. ${detail}`; - } -} - -async function announce({ - channelId, - content, - pingRoleId, - prefixMentions = true, - includeSiteUrl = true, - color, - title, - description, - embeds, - files, -}) { - if (!channelId) return; - const mentionChunks = []; - if (pingRoleId) mentionChunks.push(`<@&${pingRoleId}>`); - const prefix = prefixMentions && mentionChunks.length ? `${mentionChunks.join(' ')} ` : ''; - const payloadEmbeds = - Array.isArray(embeds) && embeds.length > 0 - ? embeds - : [buildEmbed({ title, description, color, includeSiteUrl })]; - const allowedMentions = { - parse: [], - roles: pingRoleId ? [pingRoleId] : [], - }; - await sendToChannel( - channelId, - `${prefix}${content || ''}`.trim(), - { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, - allowedMentions, - !pingRoleId, // keep mention intact when pinging - ); -} - -async function announceUserStatus({ channelId, content, color, title, description, embeds }) { - const roles = discordConfig.roles || {}; - const mode = getMode(); - const allowPing = mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN; - const pingRoleId = allowPing ? roles.announcementPing || null : null; - const mainLine = pingRoleId ? `<@&${pingRoleId}> ${content}`.trim() : content; - await announce({ - channelId, - content: mainLine, - pingRoleId, - prefixMentions: false, - color, - title, - description, - embeds, - }); -} - -function handleBusEvent(event) { - const { type, payload } = event || {}; - const channels = discordConfig.channels || {}; - const roles = discordConfig.roles || {}; - const roverId = payload?.roverId || null; - if (roverId && !roverManager.canReplayRoverId(roverId) && !ADMIN_ALERT_EVENT_TYPES.has(type)) { - return; - } - switch (type) { - case 'mode.changed': - if (!skippedFirstModeAnnouncement) { - skippedFirstModeAnnouncement = true; - schedulePresenceRotation(); - break; - } - if (payload?.mode === MODES.OPEN || payload?.mode === MODES.TURNS) { - const caption = `Access mode set to ${payload?.mode}.`; - announceUserStatus({ - channelId: channels.announcements, - content: caption, - color: 0x2196f3, - embeds: [buildAccessModeEmbed(payload?.mode, 0x2196f3)], - }); - } - schedulePresenceRotation(); - break; - case 'communityGoal.updated': { - const goalText = payload?.text ? sanitizeMentions(String(payload.text)) : null; - const caption = goalText ? `Community goal: ${goalText}` : 'Community goal cleared.'; - announceUserStatus({ - channelId: channels.announcements, - content: caption, - color: 0x8bc34a, - title: 'Community Goal', - description: goalText ? goalText : 'Community goal cleared.', - }); - schedulePresenceRotation(); - break; - } - case 'rover.locked': - announce({ - channelId: channels.announcements, - color: 0xf0b651, - title: 'Rover Locked', - description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`, - includeSiteUrl: false, - }); - schedulePresenceRotation(); - break; - case 'rover.unlocked': - schedulePresenceRotation(); - break; - case 'rovers.allUnlocked': - announceUserStatus({ - channelId: channels.announcements, - content: buildAllUnlockedCaption(), - color: 0x4caf50, - embeds: [buildAllUnlockedEmbed(0x4caf50)], - }); - schedulePresenceRotation(); - break; - case 'rover.online': - announce({ - channelId: channels.adminAlerts, - pingRoleId: roles.adminPing || null, - color: 0x4caf50, - title: 'Rover Online', - description: `${payload?.roverId} is online.`, - }); - schedulePresenceRotation(); - break; - case 'rover.offline': - announce({ - channelId: channels.adminAlerts, - pingRoleId: roles.adminPing || null, - color: 0xe53935, - title: 'Rover Offline', - description: `${payload?.roverId} went offline.`, - }); - schedulePresenceRotation(); - break; - case 'rover.dockGuard': - announce({ - channelId: channels.adminAlerts, - color: 0xf0b651, - title: 'Dock Guard Triggered', - description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration( - payload?.idleMs, - )}. Seek dock + sensor stream reissued until movement.`, - }); - break; - case 'battery.warn': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - pingRoleId: roles.adminPing || null, - color: 0xf0b651, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0xf0b651, Array.from(rovers.values()))], - }); - break; - case 'battery.urgent': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - pingRoleId: roles.adminPing || null, - color: 0xe53935, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0xe53935, Array.from(rovers.values()))], - }); - break; - case 'battery.docked': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0x2196f3, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0x2196f3, Array.from(rovers.values()))], - }); - break; - case 'battery.undocked': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0x2196f3, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0x2196f3, Array.from(rovers.values()))], - }); - break; - case 'battery.charging.start': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0x2196f3, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0x2196f3, Array.from(rovers.values()))], - }); - break; - case 'battery.charging.stop': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0xf0b651, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0xf0b651, Array.from(rovers.values()))], - }); - break; - case 'battery.locked': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0xf0b651, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0xf0b651, Array.from(rovers.values()))], - }); - schedulePresenceRotation(); - break; - case 'battery.unlocked': - if (!buildBatteryCaption(type, payload)) break; - announce({ - channelId: channels.adminAlerts, - color: 0x4caf50, - content: buildBatteryCaption(type, payload), - title: 'Battery Status', - description: null, - embeds: [buildBatteryStatusEmbed(0x4caf50, Array.from(rovers.values()))], - }); - schedulePresenceRotation(); - break; - case 'replay.requested': - sendReplayToChannel( - payload?.channelId, - payload?.requester, - payload?.sources || [], - payload?.title || '', - payload?.includeSidebar !== false, - ).catch((err) => { - logger.warn('Replay send failed', err.message); - }); - break; - case 'humanAlert.buttonPressed': { - const imageBase64 = payload?.imageBase64 ? String(payload.imageBase64) : ''; - let attachment = null; - if (imageBase64) { - try { - const imageBuffer = Buffer.from(imageBase64, 'base64'); - if (imageBuffer.length > 0) { - attachment = new AttachmentBuilder(imageBuffer, { name: 'human-alert-mosaic.jpg' }); - } - } catch (err) { - logger.warn('Failed to decode human alert image for Discord', err.message); - } - } - const triggeredAt = Number(payload?.triggeredAt); - const triggeredLabel = Number.isFinite(triggeredAt) - ? `Triggered: ` - : null; - const embed = buildEmbed({ - title: 'Human Alert Button Pressed', - description: [triggeredLabel].filter(Boolean).join('\n'), - color: 0xe53935, - }); - announce({ - channelId: channels.humanAlerts, - pingRoleId: roles.humanAlertPing || null, - content: payload?.message || 'Human alert button pressed.', - embeds: [embed], - files: attachment ? [attachment] : [], - includeSiteUrl: false, - }); - break; - } - case 'buttonBox.discordStalkerPing': { - const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.'; - const stalkerRoleId = roles.stalkerPing || null; - const content = message; - announce({ - channelId: channels.general, - pingRoleId: stalkerRoleId, - content, - color: 0xe91e63, - title: 'Button Box', - description: message, - includeSiteUrl: false, - }); - break; - } - default: - break; - } -} - -async function sendVerificationRequestDms(event) { - const payload = event?.payload || {}; - const requestId = payload.id; - if (!requestId) return; - const adminIdsToNotify = Array.from(lockdownAdminIds); - if (!adminIdsToNotify.length) { - logger.warn('No lockdown admins configured for verification request DM', { requestId }); - return; - } - - const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown'; - const content = [ - '**Verification Request**', - `Request ID: \`${requestId}\``, - `Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`, - `Identity key: \`${payload.cookieUserId || 'unknown'}\``, - `IP: \`${payload.ip || 'unknown'}\``, - `Created: ${createdAt}`, - '', - `React with ${VERIFY_APPROVE_EMOJI} to approve or ${VERIFY_DENY_EMOJI} to deny.`, - ].join('\n'); - - await Promise.all( - adminIdsToNotify.map(async (adminId) => { - try { - const user = await client.users.fetch(String(adminId)); - if (!user) return; - const dm = await user.createDM(); - const message = await dm.send({ content, allowedMentions: { parse: [] } }); - try { - await message.react(VERIFY_APPROVE_EMOJI); - await message.react(VERIFY_DENY_EMOJI); - } catch (err) { - logger.warn('Failed to add verification reactions', { requestId, adminId, error: err.message }); - } - attachDmMessage(requestId, message.id, adminId); - } catch (err) { - logger.warn('Failed to DM lockdown admin for verification request', { - requestId, - adminId, - error: err.message, - }); - } - }), - ); -} - -async function sendPrivateRoverAccessRequestDms(event) { - const payload = event?.payload || {}; - const requestId = payload.id; - if (!requestId) return; - const adminIdsToNotify = Array.from(lockdownAdminIds); - if (!adminIdsToNotify.length) { - logger.warn('No lockdown admins configured for private rover access request DM', { requestId }); - return; - } - const requester = payload.requester || {}; - const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown'; - const content = [ - '**Private Rover Access Request**', - `Request ID: \`${requestId}\``, - `Rover: ${sanitizeMentions(payload.roverName || payload.roverId || 'unknown')} (\`${payload.roverId || 'unknown'}\`)`, - `Requester: ${sanitizeMentions(requester.nickname || requester.socketId || 'unknown')}`, - `Role: \`${requester.role || 'unknown'}\``, - `Verified: \`${requester.isVerified ? 'yes' : 'no'}\``, - `Identity key: \`${requester.cookieUserId || 'unknown'}\``, - `IP: \`${requester.ip || 'unknown'}\``, - `Created: ${createdAt}`, - '', - `React with ${PRIVATE_ACCESS_APPROVE_EMOJI} to approve or ${PRIVATE_ACCESS_DENY_EMOJI} to deny.`, - ].join('\n'); - - await Promise.all( - adminIdsToNotify.map(async (adminId) => { - try { - const user = await client.users.fetch(String(adminId)); - if (!user) return; - const dm = await user.createDM(); - const message = await dm.send({ content, allowedMentions: { parse: [] } }); - try { - await message.react(PRIVATE_ACCESS_APPROVE_EMOJI); - await message.react(PRIVATE_ACCESS_DENY_EMOJI); - } catch (err) { - logger.warn('Failed to add private rover access reactions', { requestId, adminId, error: err.message }); - } - attachPrivateAccessDmMessage(requestId, message.id, adminId); - } catch (err) { - logger.warn('Failed to DM lockdown admin for private rover access request', { - requestId, - adminId, - error: err.message, - }); - } - }), - ); -} - -async function handleVerificationReaction(reaction, user) { - if (!reaction || !user || user.bot) return; - const emoji = reaction.emoji?.name; - if (emoji !== VERIFY_APPROVE_EMOJI && emoji !== VERIFY_DENY_EMOJI) return; - if (!isLockdownAdminUser(user.id)) return; - - const maybePartial = reaction.message?.partial || reaction.partial; - if (maybePartial) { - try { - await reaction.fetch(); - } catch (err) { - logger.warn('Failed to fetch partial reaction', err.message); - return; - } - } - - const messageId = reaction.message?.id; - if (!messageId) return; - const linked = getRequestByMessageId(messageId); - if (!linked?.request || linked.request.status !== 'pending') return; - - try { - if (emoji === VERIFY_APPROVE_EMOJI) { - approveRequest(linked.request.id, user.id); - await reaction.message.reply({ - content: `Approved request \`${linked.request.id}\`.`, - allowedMentions: { parse: [] }, - }); - } else { - denyRequest(linked.request.id, user.id); - await reaction.message.reply({ - content: `Denied request \`${linked.request.id}\`.`, - allowedMentions: { parse: [] }, - }); - } - } catch (err) { - logger.warn('Failed to resolve verification request from reaction', { - requestId: linked.request.id, - error: err.message, - }); - } -} - -async function handlePrivateAccessReaction(reaction, user) { - if (!reaction || !user || user.bot) return; - const emoji = reaction.emoji?.name; - if (emoji !== PRIVATE_ACCESS_APPROVE_EMOJI && emoji !== PRIVATE_ACCESS_DENY_EMOJI) return; - if (!isLockdownAdminUser(user.id)) return; - - const maybePartial = reaction.message?.partial || reaction.partial; - if (maybePartial) { - try { - await reaction.fetch(); - } catch (err) { - logger.warn('Failed to fetch partial private access reaction', err.message); - return; - } - } - - const messageId = reaction.message?.id; - if (!messageId) return; - const linked = getPrivateAccessRequestByMessageId(messageId); - if (!linked?.request || linked.request.status !== 'pending') return; - - try { - if (emoji === PRIVATE_ACCESS_APPROVE_EMOJI) { - const { assignedSocketId } = approvePrivateAccessRequest(linked.request.id, user.id); - const assignmentNote = assignedSocketId - ? ` Granted and assigned to socket \`${assignedSocketId}\`.` - : ' Granted.'; - await reaction.message.reply({ - content: `Approved private access request \`${linked.request.id}\`.${assignmentNote}`, - allowedMentions: { parse: [] }, - }); - } else { - denyPrivateAccessRequest(linked.request.id, user.id); - await reaction.message.reply({ - content: `Denied private access request \`${linked.request.id}\`.`, - allowedMentions: { parse: [] }, - }); - } - } catch (err) { - logger.warn('Failed to resolve private rover access request from reaction', { - requestId: linked.request.id, - error: err.message, - }); - } -} - -function handleChatBridgeOutbound(event) { - const payload = event?.payload; - if (!payload) return; - if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; - const guildConfigs = listGuildConfigs(); - if (!guildConfigs.length) return; - const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text; - const username = formatWebhookUsername(payload); - const avatarURL = payload.fromDiscord - ? payload.discordUserAvatarUrl || null - : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null; - const typingId = getTypingId(payload); - guildConfigs.forEach((entry) => { - if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return; - if (payload.fromDiscord) { - if (payload.discordGuildId && String(payload.discordGuildId) === String(entry.guildId)) { - return; - } - if (entry.mode === 'private') return; - } - const webhook = new WebhookClient({ id: entry.webhookId, token: entry.webhookToken }); - webhook - .send({ - content: text, - username, - avatarURL, - allowedMentions: { parse: [] }, - }) - .then(() => { - if (!payload.fromDiscord) { - clearTypingMessage(entry.guildId, typingId); - } - }) - .catch((err) => { - logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message }); - }); - }); -} - -function handleChatTypingOutbound(event) { - const payload = event?.payload; - if (!payload || payload.fromDiscord) return; - if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; - const guildConfigs = listGuildConfigs(); - if (!guildConfigs.length) return; - guildConfigs.forEach((entry) => { - if (!entry?.channelId) return; - if (payload.isTyping) { - sendTypingMessage(entry, payload); - } else { - clearTypingMessage(entry.guildId, getTypingId(payload)); - } - }); -} - -async function handleDiscordTypingStart(typing) { - const channelId = typing?.channelId || typing?.channel?.id || null; - const guildId = typing?.guild?.id || typing?.channel?.guild?.id || null; - if (!guildId || !channelId) return; - const guildConfig = getGuildConfig(guildId); - if (!guildConfig?.channelId) return; - if (String(channelId) !== String(guildConfig.channelId)) return; - const user = typing?.user || null; - if (user?.bot) return; - const member = typing?.member || null; - const nickname = member?.nickname || user?.globalName || user?.username || 'Discord'; - const role = isAdminUser(user?.id) ? 'admin' : 'user'; - const guildIconUrl = typing?.guild?.iconURL?.({ extension: 'png', size: 64 }) || null; - const userAvatarUrl = user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null; - sendExternalTyping({ - nickname, - role, - roverId: null, - discordGuildId: guildId, - discordGuildName: typing?.guild?.name || null, - discordGuildIconUrl: guildIconUrl, - discordChannelId: channelId, - discordUserId: user?.id || null, - discordUserName: user?.globalName || user?.username || null, - discordUserAvatarUrl: userAvatarUrl, - isTyping: true, - }); -} +const channelIO = createChannelIO({ + client, + logger, + sanitizeMentions, +}); + +const presence = createPresenceManager({ + client, + logger, + getMode, + getCommunityGoal, + countReady, +}); + +const commands = createCommandHandlers({ + logger, + client, + io, + rovers, + roverManager, + getMode, + MODES, + setMode, + lockRover, + getNickname, + getActiveDrivers, + buildReplayVideo, + getReplaySources, + getDefaultDiscordSources, + validateSources, + tryTriggerReplay, + getCommunityGoal, + setCommunityGoal, + clearCommunityGoal, + getAdminReason, + setAdminReason, + clearAdminReason, + getGuildConfig, + setGuildConfig, + removeGuildConfig, + normalizeMode, + VALID_MODES, + listVerifiedUsers, + removeVerifiedUser, + listDeterredUsers, + deterUser, + undeterUser, + sanitizeMentions, + sendToChannel: channelIO.sendToChannel, + isAdminUser, + isLockdownAdminUser, + discordConfig, + config, +}); + +const integrations = createIntegrations({ + logger, + client, + config, + discordConfig, + rovers, + roverManager, + getMode, + MODES, + getActiveDrivers, + getNickname, + subscribe, + sendExternalMessage, + sendExternalTyping, + getGuildConfig, + listGuildConfigs, + attachDmMessage, + getRequestByMessageId, + approveRequest, + denyRequest, + attachPrivateAccessDmMessage, + getPrivateAccessRequestByMessageId, + approvePrivateAccessRequest, + denyPrivateAccessRequest, + lockdownAdminIds, + isAdminUser, + isLockdownAdminUser, + sendToChannel: channelIO.sendToChannel, + clearTypingMessage: channelIO.clearTypingMessage, + sendTypingMessage: channelIO.sendTypingMessage, + schedulePresenceRotation: presence.schedulePresenceRotation, +}); + +const integrationHandlers = integrations.register(); client.on('messageCreate', async (message) => { try { - await handleCommand(message); - await handleBridgeInbound(message); + await commands.handleCommand(message); + await integrationHandlers.handleBridgeInbound(message); } catch (err) { logger.warn('Error handling Discord message', err.message); } }); -client.on('typingStart', (typing) => { - handleDiscordTypingStart(typing).catch((err) => { - logger.warn('Error handling Discord typing', err.message); - }); -}); - -client.on('messageReactionAdd', (reaction, user) => { - handleVerificationReaction(reaction, user).catch((err) => { - logger.warn('Error handling verification reaction', err.message); - }); - handlePrivateAccessReaction(reaction, user).catch((err) => { - logger.warn('Error handling private access reaction', err.message); - }); -}); - client.once('ready', () => { logger.info('Discord bot logged in', { tag: client.user?.tag }); - schedulePresenceRotation(); + presence.schedulePresenceRotation(); }); -subscribe('*', handleBusEvent); -subscribe('verification.requested', sendVerificationRequestDms); -subscribe('privateRoverAccess.requested', sendPrivateRoverAccessRequestDms); -subscribe('chat:message', handleChatBridgeOutbound); -subscribe('chat:typing', handleChatTypingOutbound); - client.login(discordConfig.token).catch((err) => { logger.error('Discord login failed', err.message); }); diff --git a/server/src/services/discordBotService/integrations/busEvents.js b/server/src/services/discordBotService/integrations/busEvents.js new file mode 100644 index 00000000..a5aeaf2e --- /dev/null +++ b/server/src/services/discordBotService/integrations/busEvents.js @@ -0,0 +1,73 @@ +// Discord Bus Event Integrations +// Purpose: Handles event-bus announcements to Discord channels. +// Scope: Processes supported event types and posts formatted messages/embeds. +const { EmbedBuilder, AttachmentBuilder } = require('discord.js'); + +function createBusEventHandler(deps) { + const { logger, discordConfig, getMode, MODES, roverManager, schedulePresenceRotation, formatDuration, sendToChannel } = deps; + const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']); + let skippedFirstModeAnnouncement = false; + + function buildEmbed({ title, description, color, includeSiteUrl = true }) { + const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); + const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; + if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); + else if (siteUrl) embed.setDescription(siteUrl); + embed.setTimestamp(new Date()); + return embed; + } + + async function announce({ channelId, content, pingRoleId, color, title, description, embeds, files }) { + if (!channelId) return; + const prefix = pingRoleId ? `<@&${pingRoleId}> ` : ''; + const payloadEmbeds = Array.isArray(embeds) && embeds.length ? embeds : [buildEmbed({ title, description, color })]; + await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId); + } + + return function handleBusEvent(event) { + const { type, payload } = event || {}; + const channels = discordConfig.channels || {}; + const roles = discordConfig.roles || {}; + const roverId = payload?.roverId || null; + if (roverId && !roverManager.canReplayRoverId(roverId) && !ADMIN_ALERT_EVENT_TYPES.has(type)) return; + + switch (type) { + case 'mode.changed': + if (!skippedFirstModeAnnouncement) { skippedFirstModeAnnouncement = true; schedulePresenceRotation(); break; } + if (payload?.mode === MODES.OPEN || payload?.mode === MODES.TURNS) { + announce({ channelId: channels.announcements, content: `Access mode set to ${payload?.mode}.`, color: 0x2196f3, title: 'Access Mode Updated', description: `Access mode set to **${payload?.mode}**` }); + } + schedulePresenceRotation(); + break; + case 'communityGoal.updated': + announce({ channelId: channels.announcements, content: payload?.text ? `Community goal: ${payload.text}` : 'Community goal cleared.', color: 0x8bc34a, title: 'Community Goal', description: payload?.text || 'Community goal cleared.' }); + schedulePresenceRotation(); + break; + case 'rover.online': + announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0x4caf50, title: 'Rover Online', description: `${payload?.roverId} is online.` }); + break; + case 'rover.offline': + announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xe53935, title: 'Rover Offline', description: `${payload?.roverId} went offline.` }); + break; + case 'rover.dockGuard': + announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` }); + break; + case 'humanAlert.buttonPressed': { + const imageBase64 = payload?.imageBase64 ? String(payload.imageBase64) : ''; + let attachment = null; + if (imageBase64) { + try { + const imageBuffer = Buffer.from(imageBase64, 'base64'); + if (imageBuffer.length > 0) attachment = new AttachmentBuilder(imageBuffer, { name: 'human-alert-mosaic.jpg' }); + } catch (err) { logger.warn('Failed to decode human alert image for Discord', err.message); } + } + announce({ channelId: channels.humanAlerts, pingRoleId: roles.humanAlertPing || null, content: payload?.message || 'Human alert button pressed.', embeds: [buildEmbed({ title: 'Human Alert Button Pressed', description: null, color: 0xe53935 })], files: attachment ? [attachment] : [] }); + break; + } + default: + break; + } + }; +} + +module.exports = { createBusEventHandler }; diff --git a/server/src/services/discordBotService/integrations/chatBridge.js b/server/src/services/discordBotService/integrations/chatBridge.js new file mode 100644 index 00000000..cd72d5df --- /dev/null +++ b/server/src/services/discordBotService/integrations/chatBridge.js @@ -0,0 +1,112 @@ +// Discord Chat Bridge Integrations +// Purpose: Bridges chat and typing between Discord and site sockets. +// Scope: Handles inbound Discord messages plus outbound webhook and typing relay. +const { WebhookClient } = require('discord.js'); + +function createChatBridgeHandlers(deps) { + const { + logger, + client, + roverManager, + getGuildConfig, + listGuildConfigs, + sendExternalMessage, + sendExternalTyping, + isAdminUser, + clearTypingMessage, + sendTypingMessage, + formatWebhookUsername, + getTypingId, + } = deps; + + async function handleBridgeInbound(message) { + if (!message.guild) return; + const guildConfig = getGuildConfig(message.guild.id); + if (!guildConfig?.channelId) return; + if (String(message.channelId) !== String(guildConfig.channelId)) return; + if (message.author.bot) return; + const content = (message.content || '').trim(); + const lower = content.toLowerCase(); + if (lower.startsWith('rs') || lower === 'ts' || lower.startsWith('ts ')) return; + + const nickname = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; + const role = isAdminUser(message.author.id) ? 'admin' : 'user'; + const guildIconUrl = message.guild.iconURL?.({ extension: 'png', size: 64 }) || null; + const userAvatarUrl = message.author.displayAvatarURL?.({ extension: 'png', size: 64 }) || null; + + try { + sendExternalMessage({ text: content, nickname, role, roverId: null, discordGuildId: message.guild.id, discordGuildName: message.guild.name, discordGuildIconUrl: guildIconUrl, discordChannelId: message.channelId, discordUserId: message.author?.id || null, discordUserName: message.author?.globalName || message.author?.username || null, discordUserAvatarUrl: userAvatarUrl }); + } catch (err) { + logger.warn('Failed to bridge inbound Discord chat', err.message); + } + } + + function handleChatBridgeOutbound(event) { + const payload = event?.payload; + if (!payload) return; + if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; + const guildConfigs = listGuildConfigs(); + if (!guildConfigs.length) return; + + const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text; + const username = formatWebhookUsername(payload); + const avatarURL = payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null; + const typingId = getTypingId(payload); + + guildConfigs.forEach((entry) => { + if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return; + if (payload.fromDiscord) { + if (payload.discordGuildId && String(payload.discordGuildId) === String(entry.guildId)) return; + if (entry.mode === 'private') return; + } + const webhook = new WebhookClient({ id: entry.webhookId, token: entry.webhookToken }); + webhook.send({ content: text, username, avatarURL, allowedMentions: { parse: [] } }) + .then(() => { + if (!payload.fromDiscord) clearTypingMessage(entry.guildId, typingId); + }) + .catch((err) => { + logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message }); + }); + }); + } + + function handleChatTypingOutbound(event) { + const payload = event?.payload; + if (!payload || payload.fromDiscord) return; + if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return; + const guildConfigs = listGuildConfigs(); + if (!guildConfigs.length) return; + + guildConfigs.forEach((entry) => { + if (!entry?.channelId) return; + if (payload.isTyping) sendTypingMessage(entry, payload, formatWebhookUsername, getTypingId); + else clearTypingMessage(entry.guildId, getTypingId(payload)); + }); + } + + async function handleDiscordTypingStart(typing) { + const channelId = typing?.channelId || typing?.channel?.id || null; + const guildId = typing?.guild?.id || typing?.channel?.guild?.id || null; + if (!guildId || !channelId) return; + const guildConfig = getGuildConfig(guildId); + if (!guildConfig?.channelId) return; + if (String(channelId) !== String(guildConfig.channelId)) return; + const user = typing?.user || null; + if (user?.bot) return; + const member = typing?.member || null; + const nickname = member?.nickname || user?.globalName || user?.username || 'Discord'; + const role = isAdminUser(user?.id) ? 'admin' : 'user'; + const guildIconUrl = typing?.guild?.iconURL?.({ extension: 'png', size: 64 }) || null; + const userAvatarUrl = user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null; + sendExternalTyping({ nickname, role, roverId: null, discordGuildId: guildId, discordGuildName: typing?.guild?.name || null, discordGuildIconUrl: guildIconUrl, discordChannelId: channelId, discordUserId: user?.id || null, discordUserName: user?.globalName || user?.username || null, discordUserAvatarUrl: userAvatarUrl, isTyping: true }); + } + + return { + handleBridgeInbound, + handleChatBridgeOutbound, + handleChatTypingOutbound, + handleDiscordTypingStart, + }; +} + +module.exports = { createChatBridgeHandlers }; diff --git a/server/src/services/discordBotService/integrations/dmModeration.js b/server/src/services/discordBotService/integrations/dmModeration.js new file mode 100644 index 00000000..eb086243 --- /dev/null +++ b/server/src/services/discordBotService/integrations/dmModeration.js @@ -0,0 +1,96 @@ +// Discord DM Moderation Integrations +// Purpose: Sends verification/private-access DMs and resolves reactions. +// Scope: Handles moderation request workflows tied to Discord DMs. +function createDmModerationHandlers(deps) { + const { + logger, + client, + lockdownAdminIds, + attachDmMessage, + getRequestByMessageId, + approveRequest, + denyRequest, + attachPrivateAccessDmMessage, + getPrivateAccessRequestByMessageId, + approvePrivateAccessRequest, + denyPrivateAccessRequest, + isLockdownAdminUser, + sanitizeMentions, + } = deps; + + const APPROVE = '✅'; + const DENY = '❌'; + + async function sendVerificationRequestDms(event) { + const payload = event?.payload || {}; + const requestId = payload.id; + if (!requestId) return; + const content = [`**Verification Request**`, `Request ID: \`${requestId}\``, `Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`, '', `React with ${APPROVE} to approve or ${DENY} to deny.`].join('\n'); + await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => { + try { + const user = await client.users.fetch(String(adminId)); + if (!user) return; + const dm = await user.createDM(); + const message = await dm.send({ content, allowedMentions: { parse: [] } }); + try { await message.react(APPROVE); await message.react(DENY); } catch {} + attachDmMessage(requestId, message.id, adminId); + } catch (err) { + logger.warn('Failed to DM lockdown admin for verification request', { requestId, adminId, error: err.message }); + } + })); + } + + async function sendPrivateRoverAccessRequestDms(event) { + const payload = event?.payload || {}; + const requestId = payload.id; + if (!requestId) return; + const content = [`**Private Rover Access Request**`, `Request ID: \`${requestId}\``, '', `React with ${APPROVE} to approve or ${DENY} to deny.`].join('\n'); + await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => { + try { + const user = await client.users.fetch(String(adminId)); + if (!user) return; + const dm = await user.createDM(); + const message = await dm.send({ content, allowedMentions: { parse: [] } }); + try { await message.react(APPROVE); await message.react(DENY); } catch {} + attachPrivateAccessDmMessage(requestId, message.id, adminId); + } catch (err) { + logger.warn('Failed to DM lockdown admin for private rover access request', { requestId, adminId, error: err.message }); + } + })); + } + + async function handleVerificationReaction(reaction, user) { + if (!reaction || !user || user.bot || !isLockdownAdminUser(user.id)) return; + const emoji = reaction.emoji?.name; + if (emoji !== APPROVE && emoji !== DENY) return; + if (reaction.message?.partial || reaction.partial) { + try { await reaction.fetch(); } catch { return; } + } + const linked = getRequestByMessageId(reaction.message?.id); + if (!linked?.request || linked.request.status !== 'pending') return; + if (emoji === APPROVE) approveRequest(linked.request.id, user.id); + else denyRequest(linked.request.id, user.id); + } + + async function handlePrivateAccessReaction(reaction, user) { + if (!reaction || !user || user.bot || !isLockdownAdminUser(user.id)) return; + const emoji = reaction.emoji?.name; + if (emoji !== APPROVE && emoji !== DENY) return; + if (reaction.message?.partial || reaction.partial) { + try { await reaction.fetch(); } catch { return; } + } + const linked = getPrivateAccessRequestByMessageId(reaction.message?.id); + if (!linked?.request || linked.request.status !== 'pending') return; + if (emoji === APPROVE) approvePrivateAccessRequest(linked.request.id, user.id); + else denyPrivateAccessRequest(linked.request.id, user.id); + } + + return { + sendVerificationRequestDms, + sendPrivateRoverAccessRequestDms, + handleVerificationReaction, + handlePrivateAccessReaction, + }; +} + +module.exports = { createDmModerationHandlers }; diff --git a/server/src/services/discordBotService/integrations/helpers.js b/server/src/services/discordBotService/integrations/helpers.js new file mode 100644 index 00000000..048e98e9 --- /dev/null +++ b/server/src/services/discordBotService/integrations/helpers.js @@ -0,0 +1,43 @@ +// Discord Integrations Helpers +// Purpose: Shared helper functions for Discord event integrations. +// Scope: Formatting and identity helpers used across integration handlers. +function sanitizeMentions(text) { + if (!text) return ''; + return String(text).replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]').replace(/@everyone/gi, '[everyone]').replace(/@here/gi, '[here]'); +} + +function formatDuration(ms) { + if (ms == null) return 'n/a'; + const seconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + if (minutes <= 0) return `${seconds}s`; + return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +function formatWebhookUsername(payload) { + const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown'; + if (payload.fromDiscord) { + const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : ''; + const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : ''; + return `${name}${origin}${adminTag}`; + } + const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`; + const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null; + const suffix = [roverText, roleText].filter(Boolean).join(' · '); + return suffix ? `${name} · ${suffix}` : name; +} + +function getTypingId(payload = {}) { + if (payload.typingId) return payload.typingId; + if (payload.fromDiscord) return payload.discordUserId ? `discord:${payload.discordUserId}` : 'discord:unknown'; + if (payload.socketId) return `socket:${payload.socketId}`; + return payload.nickname ? `socket:${payload.nickname}` : 'socket:unknown'; +} + +module.exports = { + sanitizeMentions, + formatDuration, + formatWebhookUsername, + getTypingId, +}; diff --git a/server/src/services/discordBotService/integrations/index.js b/server/src/services/discordBotService/integrations/index.js new file mode 100644 index 00000000..9b5557dd --- /dev/null +++ b/server/src/services/discordBotService/integrations/index.js @@ -0,0 +1,53 @@ +// Discord Integrations Coordinator +// Purpose: Composes split integration handlers and registers Discord/event-bus subscriptions. +// Scope: Wires message/typing/reaction handlers and exposes bridge inbound handler. +const { createDmModerationHandlers } = require('./dmModeration'); +const { createChatBridgeHandlers } = require('./chatBridge'); +const { createBusEventHandler } = require('./busEvents'); +const { sanitizeMentions, formatDuration, formatWebhookUsername, getTypingId } = require('./helpers'); + +function createIntegrations(deps) { + const { + logger, + client, + subscribe, + sendToChannel, + clearTypingMessage, + sendTypingMessage, + schedulePresenceRotation, + } = deps; + + const dm = createDmModerationHandlers({ ...deps, sanitizeMentions }); + const chat = createChatBridgeHandlers({ ...deps, clearTypingMessage, sendTypingMessage, formatWebhookUsername, getTypingId }); + const handleBusEvent = createBusEventHandler({ ...deps, sendToChannel, schedulePresenceRotation, formatDuration }); + + function register() { + client.on('typingStart', (typing) => { + chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message)); + }); + + client.on('messageReactionAdd', (reaction, user) => { + dm.handleVerificationReaction(reaction, user).catch((err) => logger.warn('Error handling verification reaction', err.message)); + dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message)); + }); + + subscribe('*', handleBusEvent); + subscribe('verification.requested', dm.sendVerificationRequestDms); + subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); + subscribe('chat:message', chat.handleChatBridgeOutbound); + subscribe('chat:typing', chat.handleChatTypingOutbound); + + return { + handleBridgeInbound: chat.handleBridgeInbound, + handleBusEvent, + }; + } + + return { + register, + }; +} + +module.exports = { + createIntegrations, +}; diff --git a/server/src/services/discordBotService/presence.js b/server/src/services/discordBotService/presence.js new file mode 100644 index 00000000..c3efca55 --- /dev/null +++ b/server/src/services/discordBotService/presence.js @@ -0,0 +1,68 @@ +// Discord Presence Module +// Purpose: Owns rotating Discord presence text derived from rover readiness, mode, and community goals. +// Scope: Handles presence update scheduling/state and exposes start/recompute controls for orchestration. +const { ActivityType } = require('discord.js'); + +function createPresenceManager({ client, logger, getMode, getCommunityGoal, countReady }) { + const PRESENCE_ROTATE_MS = 20000; + let presenceInterval = null; + let presenceShowGoal = false; + + function truncatePresenceText(text, maxLength) { + if (!text) return ''; + if (text.length <= maxLength) return text; + if (maxLength <= 3) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - 3)}...`; + } + + function buildPresenceName() { + const { ready, total } = countReady(); + const mode = getMode(); + const goal = getCommunityGoal(); + const goalText = goal?.text ? String(goal.text).trim() : ''; + if (presenceShowGoal && goalText) { + const trimmed = truncatePresenceText(goalText, 110); + return `Goal: ${trimmed}`; + } + return `${mode} · ${ready}/${total} Rovers Ready`; + } + + async function updatePresence() { + if (!client?.user) return; + try { + await client.user.setPresence({ + activities: [{ name: buildPresenceName(), type: ActivityType.Watching }], + status: 'online', + }); + } catch (err) { + logger.warn('Failed to update Discord presence', err.message); + } + } + + function schedulePresenceRotation() { + if (presenceInterval) { + clearInterval(presenceInterval); + presenceInterval = null; + } + const goal = getCommunityGoal(); + if (!goal?.text) { + presenceShowGoal = false; + updatePresence(); + return; + } + presenceShowGoal = false; + updatePresence(); + presenceInterval = setInterval(() => { + presenceShowGoal = !presenceShowGoal; + updatePresence(); + }, PRESENCE_ROTATE_MS); + } + + return { + schedulePresenceRotation, + }; +} + +module.exports = { + createPresenceManager, +};