mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
discord bot
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+264
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user