This commit is contained in:
legop3
2026-06-20 00:51:35 -04:00
parent e6c16c3af9
commit e95eeeb526
6 changed files with 110 additions and 95 deletions
@@ -0,0 +1,44 @@
// Chat Command Result Formatter
// Purpose: Converts command reply payloads into readable web-chat text.
// Scope: Keeps web chat rendering local instead of routing command output through Discord bridge messages.
function normalizeText(value) {
return String(value || '').trim();
}
function embedToText(embed) {
const data = embed?.data || embed || {};
const lines = [];
if (data.title) lines.push(String(data.title));
if (data.description) lines.push(String(data.description));
(Array.isArray(data.fields) ? data.fields : []).forEach((field) => {
if (!field) return;
// Discord embeds are the main structured result shape produced by existing
// commands. Web chat is plain text, so only flatten the human-readable
// fields instead of preserving Discord presentation concerns.
lines.push(`${field.name || 'Field'}\n${field.value || ''}`.trim());
});
if (data.footer?.text) lines.push(String(data.footer.text));
return lines.map(normalizeText).filter(Boolean).join('\n\n');
}
function commandReplyToText(payload) {
if (typeof payload === 'string') return normalizeText(payload);
if (!payload || typeof payload !== 'object') return '';
const parts = [];
if (payload.content) parts.push(String(payload.content));
(Array.isArray(payload.embeds) ? payload.embeds : []).forEach((embed) => {
const text = embedToText(embed);
if (text) parts.push(text);
});
(Array.isArray(payload.files) ? payload.files : []).forEach((file) => {
const name = file?.name || file?.filename || 'attachment';
parts.push(String(name));
});
return parts.map(normalizeText).filter(Boolean).join('\n\n');
}
module.exports = {
commandReplyToText,
embedToText,
};
+1 -1
View File
@@ -55,7 +55,7 @@ function createHandlers({ sendSystemMessage }) {
// messages. Running the command after broadcast preserves the user-visible // messages. Running the command after broadcast preserves the user-visible
// transcript while keeping permissions and command execution entirely on // transcript while keeping permissions and command execution entirely on
// the server. // the server.
const ranCommand = await runChatTextCommand({ text: clean, socket }); const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
cb({ success: true, command: ranCommand }); cb({ success: true, command: ranCommand });
return; return;
} catch (err) { } catch (err) {
+14 -28
View File
@@ -20,6 +20,7 @@ const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../discordBotService/commands'); const { createCommandHandlers } = require('../discordBotService/commands');
const { commandReplyToText } = require('./commandResultFormatter');
const { const {
buildReplayJobId, buildReplayJobId,
buildReplayTitle, buildReplayTitle,
@@ -45,7 +46,7 @@ function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown'; return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
} }
function createWebReplayTextCommand(socket, replayApi) { function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
return function createReplayHandler({ return function createReplayHandler({
rovers, rovers,
getReplaySources: getAllReplaySources, getReplaySources: getAllReplaySources,
@@ -106,12 +107,12 @@ function createWebReplayTextCommand(socket, replayApi) {
}, },
}); });
const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] }); const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] });
await message.reply({ content: `Replay accepted: ${title}` }); sendSystemMessage(`Replay accepted: ${title}`, { nickname: 'Rover bot', bot: true });
}; };
}; };
} }
function createChatCommandMessage({ socket, text }) { function createChatCommandMessage({ socket, text, sendSystemMessage }) {
const nickname = buildRequesterLabel(socket); const nickname = buildRequesterLabel(socket);
return { return {
content: String(text || '').trim(), content: String(text || '').trim(),
@@ -124,32 +125,20 @@ function createChatCommandMessage({ socket, text }) {
nickname, nickname,
}, },
reply: async (payload) => { reply: async (payload) => {
const replyPayload = typeof payload === 'string' const response = sanitizeMentions(commandReplyToText(payload));
? { content: sanitizeMentions(payload) } if (!response) return null;
: { return sendSystemMessage(response, { nickname: 'Rover bot', bot: true });
content: sanitizeMentions(payload?.content || ''),
options: {
embeds: payload?.embeds || undefined,
files: payload?.files || undefined,
},
};
// Web chat does not get a private shortcut for command results. The bot
// posts the Discord-shaped reply into the configured bridge channel, and
// the normal bridge inbound path decides how that Discord message appears
// in web chat.
publishEvent({ source: 'chatCommand', type: 'discord.bridgeSend', payload: replyPayload });
return null;
}, },
}; };
} }
async function runChatTextCommand({ text, socket }) { async function runChatTextCommand({ text, socket, sendSystemMessage }) {
if (!isTextCommand(text)) return false; if (!isTextCommand(text)) return false;
// ReplayEngineV2 has startup side effects by design. Loading it lazily here // ReplayEngineV2 has startup side effects by design. Loading it lazily here
// keeps ordinary chatService initialization from changing the service boot // keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline. // order, while still letting `rs replay` use the existing replay pipeline.
const replayApi = require('../replayEngineV2'); const replayApi = require('../replayEngineV2');
const message = createChatCommandMessage({ socket, text }); const message = createChatCommandMessage({ socket, text, sendSystemMessage });
const commands = createCommandHandlers({ const commands = createCommandHandlers({
logger: null, logger: null,
client: null, client: null,
@@ -189,20 +178,17 @@ async function runChatTextCommand({ text, socket }) {
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig, discordConfig,
config, config,
createReplayTextCommand: createWebReplayTextCommand(socket, replayApi), createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
}); });
// Let the shared router perform normal command permission checks. Site chat // Let the shared router perform normal command permission checks. Site chat
// has already broadcast the user's command text; any command reply is posted // has already broadcast the user's command text, so command replies become a
// into Discord and returns to web chat through the ordinary bridge inbound path. // local bot chat message instead of being routed through Discord as an
// internal transport.
try { try {
await commands.handleCommand(message); await commands.handleCommand(message);
} catch (err) { } catch (err) {
publishEvent({ sendSystemMessage(`Command failed: ${sanitizeMentions(err.message || 'unknown error')}`, { nickname: 'Rover bot', bot: true });
source: 'chatCommand',
type: 'discord.bridgeSend',
payload: { content: sanitizeMentions(`Command failed: ${err.message || 'unknown error'}`) },
});
} }
return true; return true;
} }
+46 -1
View File
@@ -13,6 +13,7 @@ const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager; const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('../modeManager'); const { MODES, getMode, setMode } = require('../modeManager');
const { sendExternalMessage, sendExternalTyping } = require('../chatService'); const { sendExternalMessage, sendExternalTyping } = require('../chatService');
const { commandReplyToText } = require('../chatService/commandResultFormatter');
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2'); const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
const { getActiveDrivers } = require('../turnService'); const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService'); const { getNickname } = require('../nicknameService');
@@ -192,10 +193,54 @@ const commands = createCommandHandlers({
const integrationHandlers = integrations.register(); const integrationHandlers = integrations.register();
function isTextCommand(content) {
const clean = String(content || '').trim();
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
}
function isBridgeChannelMessage(message) {
if (!message?.guild?.id || !message?.channelId) return false;
const guildConfig = getGuildConfig(message.guild.id);
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
}
function createBridgeMirroredCommandMessage(message) {
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
const originalReply = message.reply.bind(message);
return Object.assign(Object.create(message), {
reply: async (payload) => {
const sent = await originalReply(payload);
const text = sanitizeMentions(commandReplyToText(payload));
if (text) {
// Discord command replies are mirrored to web chat by the Discord
// command adapter, not by the chat bridge. The bridge continues to
// ignore bot-authored Discord messages, which prevents typing helper
// messages and bot replies from feeding back into chat.
sendExternalMessage({
text,
nickname: client.user?.username || 'Rover bot',
role: 'admin',
roverId: null,
discordGuildId: message.guild.id,
discordGuildName: message.guild.name,
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
discordChannelId: message.channelId,
discordUserId: client.user?.id || null,
discordUserName: client.user?.username || 'Rover bot',
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
bot: true,
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
});
}
return sent;
},
});
}
client.on('messageCreate', async (message) => { client.on('messageCreate', async (message) => {
try { try {
await integrationHandlers.handleBridgeInbound(message); await integrationHandlers.handleBridgeInbound(message);
await commands.handleCommand(message); await commands.handleCommand(createBridgeMirroredCommandMessage(message));
} catch (err) { } catch (err) {
logger.warn('Error handling Discord message', err.message); logger.warn('Error handling Discord message', err.message);
} }
@@ -19,45 +19,6 @@ function formatToolCallsCodeBlock(toolCalls = []) {
return `\`\`\`txt\nTool calls:\n${rows.join('\n')}\n\`\`\``; return `\`\`\`txt\nTool calls:\n${rows.join('\n')}\n\`\`\``;
} }
function discordPayloadToBridgeText(payload = {}) {
if (typeof payload === 'string') return payload;
if (!payload || typeof payload !== 'object') return '';
const parts = [];
if (payload.content) parts.push(String(payload.content));
(Array.isArray(payload.embeds) ? payload.embeds : []).forEach((embed) => {
const data = embed?.data || embed || {};
const rows = [];
if (data.title) rows.push(String(data.title));
if (data.description) rows.push(String(data.description));
(Array.isArray(data.fields) ? data.fields : []).forEach((field) => {
if (!field) return;
// The bridge stays straight-through for plain Discord content. Embeds are
// the only Discord-specific shape that web chat cannot show directly, so
// flatten only the readable embed text here at the bridge boundary.
rows.push(`${field.name || 'Field'}\n${field.value || ''}`.trim());
});
if (data.footer?.text) rows.push(String(data.footer.text));
if (rows.length) parts.push(rows.join('\n\n'));
});
(Array.isArray(payload.attachments) ? payload.attachments : []).forEach((attachment) => {
const name = attachment?.name || attachment?.filename || 'attachment';
const url = attachment?.url || attachment?.proxyURL || attachment?.proxyUrl || '';
parts.push(url ? `${name}: ${url}` : String(name));
});
return parts.join('\n\n').trim();
}
function discordMessageToBridgeText(message) {
const attachments = message?.attachments?.values
? Array.from(message.attachments.values())
: [];
return discordPayloadToBridgeText({
content: message?.content || '',
embeds: message?.embeds || [],
attachments,
});
}
function createChatBridgeHandlers(deps) { function createChatBridgeHandlers(deps) {
const { const {
logger, logger,
@@ -65,7 +26,6 @@ function createChatBridgeHandlers(deps) {
roverManager, roverManager,
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
sendToChannel,
sendExternalMessage, sendExternalMessage,
sendExternalTyping, sendExternalTyping,
isAdminUser, isAdminUser,
@@ -80,37 +40,22 @@ function createChatBridgeHandlers(deps) {
const guildConfig = getGuildConfig(message.guild.id); const guildConfig = getGuildConfig(message.guild.id);
if (!guildConfig?.channelId) return; if (!guildConfig?.channelId) return;
if (String(message.channelId) !== String(guildConfig.channelId)) return; if (String(message.channelId) !== String(guildConfig.channelId)) return;
if (message.webhookId) return; if (message.author.bot) return;
const isRoverBotMessage = message.author?.bot && client.user?.id && String(message.author.id) === String(client.user.id);
if (message.author.bot && !isRoverBotMessage) return;
const content = (message.content || '').trim(); const content = (message.content || '').trim();
const bridgedText = isRoverBotMessage ? discordMessageToBridgeText(message) : content; if (!content) return;
if (!bridgedText.trim()) return;
const nickname = isRoverBotMessage const nickname = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
? client.user?.username || 'Rover bot'
: message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
const role = isAdminUser(message.author.id) ? 'admin' : 'user'; const role = isAdminUser(message.author.id) ? 'admin' : 'user';
const guildIconUrl = message.guild.iconURL?.({ extension: 'png', size: 64 }) || null; const guildIconUrl = message.guild.iconURL?.({ extension: 'png', size: 64 }) || null;
const userAvatarUrl = message.author.displayAvatarURL?.({ extension: 'png', size: 64 }) || null; const userAvatarUrl = message.author.displayAvatarURL?.({ extension: 'png', size: 64 }) || null;
try { try {
sendExternalMessage({ text: bridgedText, 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, bot: isRoverBotMessage, profileImage: isRoverBotMessage ? userAvatarUrl : null }); 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) { } catch (err) {
logger.warn('Failed to bridge inbound Discord chat', err.message); logger.warn('Failed to bridge inbound Discord chat', err.message);
} }
} }
async function handleBridgeSendRequest(event) {
const payload = event?.payload || {};
const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return;
await Promise.all(guildConfigs.map(async (entry) => {
if (!entry?.channelId) return;
await sendToChannel(entry.channelId, payload.content || '', payload.options || {}, { parse: [] }, false);
}));
}
function handleChatBridgeOutbound(event) { function handleChatBridgeOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload) return; if (!payload) return;
@@ -184,11 +129,7 @@ function createChatBridgeHandlers(deps) {
handleChatBridgeOutbound, handleChatBridgeOutbound,
handleChatTypingOutbound, handleChatTypingOutbound,
handleDiscordTypingStart, handleDiscordTypingStart,
handleBridgeSendRequest,
}; };
} }
module.exports = { module.exports = { createChatBridgeHandlers };
createChatBridgeHandlers,
discordPayloadToBridgeText,
};
@@ -39,7 +39,6 @@ function createIntegrations(deps) {
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
subscribe('chat:message', chat.handleChatBridgeOutbound); subscribe('chat:message', chat.handleChatBridgeOutbound);
subscribe('chat:typing', chat.handleChatTypingOutbound); subscribe('chat:typing', chat.handleChatTypingOutbound);
subscribe('discord.bridgeSend', chat.handleBridgeSendRequest);
return { return {
handleBridgeInbound: chat.handleBridgeInbound, handleBridgeInbound: chat.handleBridgeInbound,