mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
discord bot
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user