mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
better discord chat bridging
This commit is contained in:
@@ -39,7 +39,7 @@ discord:
|
||||
channels:
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
chatBridge: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
replay: "123456789012345678"
|
||||
roles:
|
||||
announcementPing: "123456789012345678"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"700146429359816735": {
|
||||
"guildId": "700146429359816735",
|
||||
"channelId": "1441685291944513566",
|
||||
"mode": "global",
|
||||
"createdAt": 1768169994193,
|
||||
"updatedAt": 1768169994193
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-ppyUE0Ej.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-aZHU9GG3.css">
|
||||
<script type="module" crossorigin src="/assets/index-BX-wWPs0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DGAJKj1s.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -71,6 +71,13 @@ function buildMessage(socket, text, meta = {}) {
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
};
|
||||
@@ -158,7 +165,19 @@ function maybeSpeak(socket, message, ttsOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
|
||||
function sendExternalMessage({
|
||||
text,
|
||||
nickname = 'Discord',
|
||||
role = 'admin',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 256) {
|
||||
@@ -175,6 +194,13 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
|
||||
role,
|
||||
roverId,
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
});
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
ActivityType,
|
||||
EmbedBuilder,
|
||||
AttachmentBuilder,
|
||||
PermissionsBitField,
|
||||
} = require('discord.js');
|
||||
const logger = require('../globals/logger').child('discordBot');
|
||||
const io = require('../globals/io');
|
||||
@@ -18,6 +19,14 @@ const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
const {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
removeGuildConfig,
|
||||
setGuildConfig,
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
} = require('./discordGuildStore');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -198,6 +207,10 @@ function formatHelp() {
|
||||
'`rs help` — show this help',
|
||||
'`rs status [id]` — show rover status (all or one)',
|
||||
'`rs replay [camera]` — send room camera instant replay',
|
||||
'`rs bridge` — show chat bridge status for this server',
|
||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
'`rs bridge off` — disable chat bridge for this server',
|
||||
'`rs lock <id>` — lock a rover',
|
||||
'`rs unlock <id>` — unlock a rover',
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
@@ -401,6 +414,117 @@ async function handleModeCommand(message, mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function canManageBridge(message) {
|
||||
if (isAdminUser(message.author.id)) return true;
|
||||
if (!message.guild || !message.member) return false;
|
||||
const perms = message.member.permissions;
|
||||
if (!perms) return false;
|
||||
return (
|
||||
perms.has(PermissionsBitField.Flags.ManageGuild) ||
|
||||
perms.has(PermissionsBitField.Flags.Administrator)
|
||||
);
|
||||
}
|
||||
|
||||
function formatBridgeStatus(entry) {
|
||||
if (!entry) return 'Chat bridge is not configured for this server.';
|
||||
return `Chat bridge is **${entry.mode}** in <#${entry.channelId}>.`;
|
||||
}
|
||||
|
||||
async function handleBridgeCommand(message, tokens) {
|
||||
if (!message.guild) {
|
||||
await message.reply({
|
||||
content: 'Chat bridge must be configured in a server channel.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const guildId = message.guild.id;
|
||||
let action = (tokens.shift() || 'status').toLowerCase();
|
||||
let mode = null;
|
||||
if (action === 'global' || action === 'private') {
|
||||
mode = action;
|
||||
action = 'here';
|
||||
} else if (action === 'here' || action === 'mode') {
|
||||
mode = (tokens.shift() || '').toLowerCase();
|
||||
}
|
||||
if (mode && !VALID_MODES.has(mode)) {
|
||||
await message.reply({
|
||||
content: 'Invalid mode. Use `global` or `private`.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
const entry = getGuildConfig(guildId);
|
||||
await message.reply({
|
||||
content: formatBridgeStatus(entry),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'off') {
|
||||
removeGuildConfig(guildId);
|
||||
await message.reply({
|
||||
content: 'Chat bridge disabled for this server.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canManageBridge(message)) {
|
||||
await message.reply({
|
||||
content: 'You need Manage Server permissions to change the chat bridge.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'here') {
|
||||
const channelId = message.channelId;
|
||||
const entry = setGuildConfig(guildId, {
|
||||
channelId,
|
||||
mode: normalizeMode(mode, getGuildConfig(guildId)?.mode || 'global'),
|
||||
});
|
||||
await message.reply({
|
||||
content: `Chat bridge set to **${entry.mode}** in <#${entry.channelId}>.`,
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'mode') {
|
||||
const current = getGuildConfig(guildId);
|
||||
if (!current?.channelId) {
|
||||
await message.reply({
|
||||
content: 'No chat bridge channel set. Use `rs bridge here <global|private>` first.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextMode = normalizeMode(mode, null);
|
||||
if (!VALID_MODES.has(nextMode)) {
|
||||
await message.reply({
|
||||
content: 'Invalid mode. Use `global` or `private`.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode });
|
||||
await message.reply({
|
||||
content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`,
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await message.reply({
|
||||
content: 'Unknown bridge command. Try `rs bridge`.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCommand(message) {
|
||||
if (message.author.bot) return;
|
||||
const content = (message.content || '').trim();
|
||||
@@ -415,8 +539,17 @@ async function handleCommand(message) {
|
||||
tokens.shift(); // remove prefix
|
||||
const action = (tokens.shift() || '').toLowerCase();
|
||||
const isAdmin = isAdminUser(message.author.id);
|
||||
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
||||
|
||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay') {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!isBridgeAdmin &&
|
||||
action !== '' &&
|
||||
action !== 'status' &&
|
||||
action !== 'help' &&
|
||||
action !== 'replay' &&
|
||||
action !== 'bridge'
|
||||
) {
|
||||
return; // ignore non-admins for privileged commands
|
||||
}
|
||||
|
||||
@@ -433,6 +566,9 @@ async function handleCommand(message) {
|
||||
case 'replay':
|
||||
await handleReplayCommand(message, tokens.join(' '));
|
||||
break;
|
||||
case 'bridge':
|
||||
await handleBridgeCommand(message, tokens);
|
||||
break;
|
||||
case 'lock':
|
||||
await handleLockCommand(message, tokens[0], true);
|
||||
break;
|
||||
@@ -450,16 +586,26 @@ async function handleCommand(message) {
|
||||
|
||||
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}`;
|
||||
const roleTag =
|
||||
payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin'
|
||||
? '`Admin`'
|
||||
: null;
|
||||
const roverTag = payload.roverId ? `\`Rover: ${payload.roverId}\`` : '`Rover: none`';
|
||||
const record = payload.roverId ? rovers.get(payload.roverId) : null;
|
||||
const percent = record?.batteryState?.percentDisplay;
|
||||
const voltageMv = record?.lastSensor?.decoded?.voltageMv ?? record?.lastSensor?.sensors?.voltageMv;
|
||||
const batteryTag = percent != null ? `\`Battery: ${percent}%\`` : null;
|
||||
const voltageTag = voltageMv != null ? `\`Voltage: ${formatVoltage(voltageMv)}\`` : null;
|
||||
const tags = [roverTag, batteryTag, voltageTag, roleTag].filter(Boolean).join(' ');
|
||||
const header = tags ? `**${name}** ${tags}` : `**${name}**`;
|
||||
return `${header}\n${payload.text}`;
|
||||
}
|
||||
|
||||
async function handleBridgeInbound(message) {
|
||||
const bridgeChannelId = discordConfig?.channels?.chatBridge;
|
||||
if (!bridgeChannelId) return;
|
||||
if (String(message.channelId) !== String(bridgeChannelId)) return;
|
||||
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();
|
||||
@@ -467,12 +613,21 @@ async function handleBridgeInbound(message) {
|
||||
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);
|
||||
@@ -809,12 +964,21 @@ function handleBusEvent(event) {
|
||||
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.fromDiscord) return;
|
||||
const bridgeChannelId = discordConfig?.channels?.chatBridge;
|
||||
if (!bridgeChannelId) return;
|
||||
if (!payload) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
const line = formatChatLine(payload);
|
||||
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
|
||||
sendToChannel(bridgeChannelId, text, {}, { parse: [] });
|
||||
guildConfigs.forEach((entry) => {
|
||||
if (!entry?.channelId) return;
|
||||
if (payload.fromDiscord) {
|
||||
if (payload.discordGuildId && String(payload.discordGuildId) === String(entry.guildId)) {
|
||||
return;
|
||||
}
|
||||
if (entry.mode === 'private') return;
|
||||
}
|
||||
sendToChannel(entry.channelId, text, {}, { parse: [] });
|
||||
});
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../globals/logger').child('discordGuildStore');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'discord-guilds.json');
|
||||
const VALID_MODES = new Set(['global', 'private']);
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load guild store', err.message);
|
||||
}
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeMode(input, fallback = 'global') {
|
||||
const candidate = String(input || '').trim().toLowerCase();
|
||||
if (VALID_MODES.has(candidate)) return candidate;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function listGuildConfigs() {
|
||||
const store = loadStore();
|
||||
return Object.values(store);
|
||||
}
|
||||
|
||||
function getGuildConfig(guildId) {
|
||||
if (!guildId) return null;
|
||||
const store = loadStore();
|
||||
return store[String(guildId)] || null;
|
||||
}
|
||||
|
||||
function setGuildConfig(guildId, { channelId, mode }) {
|
||||
if (!guildId) {
|
||||
throw new Error('guildId required');
|
||||
}
|
||||
const store = { ...loadStore() };
|
||||
const now = Date.now();
|
||||
const key = String(guildId);
|
||||
const prev = store[key] || null;
|
||||
const next = {
|
||||
guildId: key,
|
||||
channelId: channelId != null ? String(channelId) : prev?.channelId || null,
|
||||
mode: normalizeMode(mode, prev?.mode || 'global'),
|
||||
createdAt: prev?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store[key] = next;
|
||||
saveStore(store);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeGuildConfig(guildId) {
|
||||
if (!guildId) return;
|
||||
const store = { ...loadStore() };
|
||||
delete store[String(guildId)];
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
setGuildConfig,
|
||||
removeGuildConfig,
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import { useChat } from '../context/ChatContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
function roleColors(role, fromDiscord) {
|
||||
if (fromDiscord) return 'text-indigo-200';
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
@@ -26,6 +26,25 @@ function displayName(message) {
|
||||
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
||||
}
|
||||
|
||||
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
||||
if (!guildIconUrl && !userAvatarUrl) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-center bg-cover' : ''}`}
|
||||
style={guildIconUrl ? { backgroundImage: `url(${guildIconUrl})` } : undefined}
|
||||
/>
|
||||
<span
|
||||
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-center bg-cover' : ''}`}
|
||||
style={userAvatarUrl ? { backgroundImage: `url(${userAvatarUrl})` } : undefined}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||
|
||||
@@ -114,10 +133,13 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
sorted.map((msg) => {
|
||||
const isAdmin =
|
||||
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
|
||||
const discordLabel = msg.fromDiscord
|
||||
? `${msg.discordGuildName || 'Discord'} · ${displayName(msg)}`
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`surface-muted text-sm flex items-start gap-1 ${
|
||||
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
: msg.fromDiscord
|
||||
@@ -125,19 +147,26 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
|
||||
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role, msg.fromDiscord)}`}>
|
||||
{msg.fromDiscord ? (
|
||||
<>
|
||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
||||
<DiscordAvatar
|
||||
guildIconUrl={msg.discordGuildIconUrl}
|
||||
userAvatarUrl={msg.discordUserAvatarUrl}
|
||||
label={discordLabel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role)}`}>
|
||||
{displayName(msg)}
|
||||
</span>
|
||||
{msg.fromDiscord && (
|
||||
<span className="rounded bg-indigo-500/30 px-1 text-[0.7rem] text-indigo-100">
|
||||
Discord
|
||||
</span>
|
||||
)}
|
||||
{msg.roverId && (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{msg.roverId}</span>
|
||||
)}
|
||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
|
||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||
{formatTime(msg.ts)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user