discord bot

This commit is contained in:
legop3
2025-11-22 02:05:49 -05:00
parent f2a0670861
commit 62075df80d
8 changed files with 699 additions and 8 deletions
+34 -6
View File
@@ -55,15 +55,16 @@ function resolveRoverId(socketId) {
return assignment?.roverId || null;
}
function buildMessage(socket, text) {
const roverId = resolveRoverId(socket.id);
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
return {
id: uuidv4(),
ts: Date.now(),
socketId: socket.id,
nickname: getNickname(socket) || null,
role: getRole(socket),
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
fromDiscord: Boolean(meta.fromDiscord),
text,
};
}
@@ -103,12 +104,34 @@ function handleIncoming({ text } = {}, socket, cb = () => {}) {
cb({ error: 'Message looks like spam' });
return;
}
const message = buildMessage(socket, clean);
const message = buildMessage(socket, clean, { fromDiscord: false });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
broadcastMessage(message);
cb({ success: true });
}
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
const clean = typeof text === 'string' ? text.trim() : '';
if (!clean || clean.length > 256) {
throw new Error('Message invalid');
}
if (hasProfanity(clean)) {
throw new Error('Message blocked');
}
if (isKeymash(clean)) {
throw new Error('Message looks like spam');
}
const message = buildMessage(null, clean, {
nickname,
role,
roverId,
fromDiscord: true,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
return message;
}
io.on('connection', (socket) => {
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
});
@@ -117,3 +140,8 @@ subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
});
module.exports = {
handleIncoming,
sendExternalMessage,
};
+363
View File
@@ -0,0 +1,363 @@
const {
Client,
GatewayIntentBits,
Partials,
ActivityType,
EmbedBuilder,
} = require('discord.js');
const logger = require('../globals/logger').child('discordBot');
const { loadConfig } = require('../helpers/configLoader');
const { subscribe } = require('./eventBus');
const { getRoster, lockRover } = require('./roverManager');
const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage } = require('./chatService');
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),
);
if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token');
return;
}
const intents = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
];
const client = new Client({
intents,
partials: [Partials.Channel],
});
const channelCache = new Map();
function formatRoverStatus(rover) {
if (!rover) return 'Unknown rover';
const percent = rover.batteryState?.percentDisplay;
const battery = percent != null ? `${percent}%` : 'n/a';
const lockLabel = rover.locked ? '🔒 locked' : '🔓 unlocked';
return `**${rover.name || rover.id}** — ${lockLabel} — battery ${battery}`;
}
function countReady() {
const roster = getRoster();
const total = roster.length;
const ready = roster.filter((r) => !r.locked).length;
return { ready, total };
}
async function updatePresence() {
if (!client?.user) return;
const { ready, total } = countReady();
try {
await client.user.setPresence({
activities: [{ name: `${ready}/${total} Rovers Ready`, type: ActivityType.Watching }],
status: 'online',
});
} catch (err) {
logger.warn('Failed to update Discord presence', err.message);
}
}
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 = {}) {
const channel = await fetchChannel(id);
if (!channel) return;
try {
await channel.send({ content, ...options });
} catch (err) {
logger.warn('Failed to send Discord message', { id, error: err.message });
}
}
function isAdminUser(discordId) {
return adminIds.has(String(discordId || '').trim());
}
function formatHelp() {
return [
'**Rover Bot Commands**',
'`rover help` — show this help',
'`rover status [id]` — show rover status (all or one)',
'`rover lock <id>` — lock a rover',
'`rover unlock <id>` — unlock a rover',
'`rover mode <open|turns|admin|lockdown>` — change server mode',
].join('\n');
}
function findRover(id) {
const roster = getRoster();
if (!id) return null;
return roster.find((r) => String(r.id) === String(id) || String(r.name) === String(id));
}
async function handleStatusCommand(message, roverId) {
const roster = getRoster();
if (!roverId) {
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
await message.reply(summary.slice(0, 1900));
return;
}
const rover = findRover(roverId);
await message.reply(formatRoverStatus(rover).slice(0, 1900));
}
async function handleLockCommand(message, roverId, locked) {
if (!roverId) {
await message.reply('Specify a rover ID. Example: `rover lock alpha`');
return;
}
try {
lockRover(roverId, locked, { reason: 'discord' });
await message.reply(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`);
} catch (err) {
await message.reply(`Failed: ${err.message}`);
}
}
async function handleModeCommand(message, mode) {
const next = String(mode || '').toLowerCase();
if (!Object.values(MODES).includes(next)) {
await message.reply('Invalid mode. Use one of: open, turns, admin, lockdown.');
return;
}
try {
setMode(next, null, { force: true });
await message.reply(`Mode set to ${next}.`);
} catch (err) {
await message.reply(`Failed to set mode: ${err.message}`);
}
}
async function handleCommand(message) {
if (message.author.bot) return;
const content = (message.content || '').trim();
if (!content.toLowerCase().startsWith('rover')) return;
if (!isAdminUser(message.author.id)) {
return; // ignore non-admins
}
const tokens = content.split(/\s+/);
tokens.shift(); // remove "rover"
const action = (tokens.shift() || '').toLowerCase();
switch (action) {
case 'help':
await message.reply(formatHelp());
break;
case 'status':
await handleStatusCommand(message, tokens[0]);
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[0]);
break;
default:
await message.reply(formatHelp());
break;
}
}
function formatChatLine(payload) {
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
const rover = payload.roverId ? `**[${payload.roverId}]** ` : '';
const role =
payload.role === 'admin' || payload.role === 'lockdown' ? '**(admin)** ' : '';
return `${rover}${role}${name}: ${payload.text}`;
}
async function handleBridgeInbound(message) {
const bridgeChannelId = discordConfig?.channels?.chatBridge;
if (!bridgeChannelId) return;
if (String(message.channelId) !== String(bridgeChannelId)) return;
if (message.author.bot) return;
const content = (message.content || '').trim();
if (content.toLowerCase().startsWith('rover')) 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';
try {
sendExternalMessage({
text: content,
nickname,
role,
roverId: null,
});
} catch (err) {
logger.warn('Failed to bridge inbound Discord chat', err.message);
}
}
function buildEmbed({ title, description, color }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
if (description) embed.setDescription(description);
embed.setTimestamp(new Date());
return embed;
}
async function announce({ channelId, content, pingRoleId, color, title, description }) {
if (!channelId) return;
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
const embed = buildEmbed({ title, description, color });
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: [embed] });
}
function handleBusEvent(event) {
const { type, payload } = event || {};
const channels = discordConfig.channels || {};
const roles = discordConfig.roles || {};
switch (type) {
case 'mode.changed':
announce({
channelId: channels.announcements,
pingRoleId: roles.announcementPing || null,
color: 0x2196f3,
title: 'Mode Changed',
description: `Server mode set to **${payload?.mode}**`,
});
updatePresence();
break;
case 'rover.locked':
announce({
channelId: channels.announcements,
color: 0xf0b651,
title: 'Rover Locked',
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
});
updatePresence();
break;
case 'rover.unlocked':
announce({
channelId: channels.announcements,
color: 0x4caf50,
title: 'Rover Unlocked',
description: `${payload?.roverId} unlocked.`,
});
updatePresence();
break;
case 'rover.online':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0x4caf50,
title: 'Rover Online',
description: `${payload?.roverId} is online.`,
});
updatePresence();
break;
case 'rover.offline':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xe53935,
title: 'Rover Offline',
description: `${payload?.roverId} went offline.`,
});
updatePresence();
break;
case 'battery.warn':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xf0b651,
title: 'Battery Warn',
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'low'}%.`,
});
break;
case 'battery.urgent':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xe53935,
title: 'Battery Urgent',
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'urgent'}%.`,
});
break;
case 'battery.docked':
announce({
channelId: channels.adminAlerts,
color: 0x2196f3,
title: 'Docked & Charging',
description: `${payload?.roverId} is docked and charging.`,
});
break;
case 'battery.locked':
announce({
channelId: channels.adminAlerts,
color: 0xf0b651,
title: 'Locked for Charging',
description: `${payload?.roverId} locked for charging.`,
});
updatePresence();
break;
case 'battery.unlocked':
announce({
channelId: channels.adminAlerts,
color: 0x4caf50,
title: 'Unlocked after Charging',
description: `${payload?.roverId} unlocked after charging.`,
});
updatePresence();
break;
default:
break;
}
}
function handleChatBridgeOutbound(event) {
const payload = event?.payload;
if (!payload || payload.fromDiscord) return;
const bridgeChannelId = discordConfig?.channels?.chatBridge;
if (!bridgeChannelId) return;
const line = formatChatLine(payload);
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
sendToChannel(bridgeChannelId, text);
}
client.on('messageCreate', async (message) => {
try {
await handleCommand(message);
await handleBridgeInbound(message);
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
updatePresence();
});
subscribe('*', handleBusEvent);
subscribe('chat:message', handleChatBridgeOutbound);
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
module.exports = {};
+9 -2
View File
@@ -2,6 +2,7 @@ const EventEmitter = require('events');
const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService');
const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const MODES = {
OPEN: 'open',
@@ -20,11 +21,12 @@ function canChangeMode(socket, nextMode) {
return isAdmin(socket);
}
function setMode(nextMode, socket) {
function setMode(nextMode, socket, options = {}) {
if (!Object.values(MODES).includes(nextMode)) {
throw new Error(`Unknown mode ${nextMode}`);
}
if (!canChangeMode(socket, nextMode)) {
const force = Boolean(options.force);
if (!force && !canChangeMode(socket, nextMode)) {
throw new Error('Not authorized to change mode');
}
if (currentMode === nextMode) {
@@ -36,6 +38,11 @@ function setMode(nextMode, socket) {
title: 'Mode Changed',
message: `Server mode set to ${nextMode}`,
});
publishEvent({
source: 'modeManager',
type: 'mode.changed',
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
});
modeEvents.emit('change', currentMode);
io.emit('mode', { mode: currentMode });
return currentMode;
+16
View File
@@ -5,6 +5,7 @@ const { sendAlert, COLORS } = require('./alertService');
const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager');
const { isAdmin, roleEvents } = require('./roleService');
const { publishEvent } = require('./eventBus');
const rovers = new Map(); // roverId -> record
const socketToRovers = new Map(); // socketId -> Set(roverId)
@@ -37,6 +38,7 @@ function upsertRover(meta, ws) {
} else {
logger.info('Rover hello camera servo', { id, servo: meta.cameraServo });
}
const isNew = !rovers.has(id);
const record = ensureRecord(id);
record.meta = meta;
record.ws = ws;
@@ -47,6 +49,9 @@ function upsertRover(meta, ws) {
sock?.join(record.room);
});
managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) {
publishEvent({ source: 'roverManager', type: 'rover.online', payload: { roverId: id } });
}
broadcastRoster();
return record;
}
@@ -62,6 +67,7 @@ function removeRover(id) {
});
broadcastRoster();
managerEvents.emit('rover', { roverId: id, action: 'removed' });
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } });
}
function lockRover(id, locked, options = {}) {
@@ -81,12 +87,22 @@ function lockRover(id, locked, options = {}) {
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
});
}
publishEvent({
source: 'roverManager',
type: 'rover.locked',
payload: { roverId: id, reason: record.lockReason },
});
} else {
record.locked = false;
record.lockReason = null;
if (!silent) {
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
}
publishEvent({
source: 'roverManager',
type: 'rover.unlocked',
payload: { roverId: id },
});
}
broadcastRoster();
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });