From 62075df80db34c3095edbd87f569f96a6a5d81fd Mon Sep 17 00:00:00 2001 From: legop3 Date: Sat, 22 Nov 2025 02:05:49 -0500 Subject: [PATCH] discord bot --- server/config.example.yaml | 11 + server/index.js | 1 + server/package-lock.json | 264 +++++++++++++++++ server/package.json | 1 + server/src/services/chatService.js | 40 ++- server/src/services/discordBotService.js | 363 +++++++++++++++++++++++ server/src/services/modeManager.js | 11 +- server/src/services/roverManager.js | 16 + 8 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 server/src/services/discordBotService.js diff --git a/server/config.example.yaml b/server/config.example.yaml index 8c09a416..14977741 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -29,3 +29,14 @@ roomCameras: - id: "workshop" name: "Workshop Bench" description: "Shows the workbench and charging docks." + +discord: + token: "DISCORD_BOT_TOKEN" + guildId: "123456789012345678" # optional; bot works in any guild it's invited to + channels: + announcements: "123456789012345678" + adminAlerts: "123456789012345678" + chatBridge: "123456789012345678" + roles: + announcementPing: "123456789012345678" + adminPing: "123456789012345678" diff --git a/server/index.js b/server/index.js index 83a22a02..f2be3274 100644 --- a/server/index.js +++ b/server/index.js @@ -24,4 +24,5 @@ require('./src/services/logStreamService'); require('./src/services/homeAssistantService'); require('./src/services/sessionService'); require('./src/services/batteryManager'); +require('./src/services/discordBotService'); require('./src/services/httpServer'); diff --git a/server/package-lock.json b/server/package-lock.json index 1569e9cc..b510b569 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "bcrypt": "^6.0.0", + "discord.js": "^14.25.1", "express": "^4.19.2", "home-assistant-js-websocket": "^3.1.2", "js-yaml": "^4.1.1", @@ -21,6 +22,169 @@ "nodemon": "^3.1.0" } }, + "node_modules/@discordjs/builders": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", + "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.1", + "@discordjs/util": "^1.1.1", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.31", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -45,6 +209,25 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -340,6 +523,42 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/discord-api-types": { + "version": "0.38.34", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.34.tgz", + "integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -542,6 +761,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -810,6 +1035,24 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1530,6 +1773,18 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1550,6 +1805,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/server/package.json b/server/package.json index d20d0181..c4b4f11d 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "bcrypt": "^6.0.0", + "discord.js": "^14.25.1", "express": "^4.19.2", "home-assistant-js-websocket": "^3.1.2", "js-yaml": "^4.1.1", diff --git a/server/src/services/chatService.js b/server/src/services/chatService.js index 63f69851..93069de6 100644 --- a/server/src/services/chatService.js +++ b/server/src/services/chatService.js @@ -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, +}; diff --git a/server/src/services/discordBotService.js b/server/src/services/discordBotService.js new file mode 100644 index 00000000..5031eb37 --- /dev/null +++ b/server/src/services/discordBotService.js @@ -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 ` — lock a rover', + '`rover unlock ` — unlock a rover', + '`rover mode ` — 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 = {}; diff --git a/server/src/services/modeManager.js b/server/src/services/modeManager.js index 879b935b..8250a76d 100644 --- a/server/src/services/modeManager.js +++ b/server/src/services/modeManager.js @@ -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; diff --git a/server/src/services/roverManager.js b/server/src/services/roverManager.js index 8362943f..e6f1b6bc 100644 --- a/server/src/services/roverManager.js +++ b/server/src/services/roverManager.js @@ -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 });