mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
discord bot
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 <global|private>` 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 };
|
||||
@@ -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 <cookieUserId|nickname|ip> [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 <id|cookieUserId|nickname|ip>`', 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 <selector> [reason]`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDeterCommand };
|
||||
@@ -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 };
|
||||
@@ -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 <global|private>` — set chat bridge to this channel',
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
'`rs bridge off` — disable chat bridge for this server',
|
||||
'`rs lock <id>` — lock a rover',
|
||||
'`rs unlock <id>` — unlock a rover',
|
||||
'`rs mode <open|turns|admin|lockdown>` — 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 <cookieUserId|nickname>`', 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 <cookieUserId|nickname>`.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createVerifyCommand };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user