diff --git a/server/src/services/chatService/commandResultFormatter.js b/server/src/services/chatService/commandResultFormatter.js new file mode 100644 index 00000000..726487ed --- /dev/null +++ b/server/src/services/chatService/commandResultFormatter.js @@ -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, +}; diff --git a/server/src/services/chatService/handlers.js b/server/src/services/chatService/handlers.js index 9cda1415..c0e34b6d 100644 --- a/server/src/services/chatService/handlers.js +++ b/server/src/services/chatService/handlers.js @@ -55,7 +55,7 @@ function createHandlers({ sendSystemMessage }) { // messages. Running the command after broadcast preserves the user-visible // transcript while keeping permissions and command execution entirely on // the server. - const ranCommand = await runChatTextCommand({ text: clean, socket }); + const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage }); cb({ success: true, command: ranCommand }); return; } catch (err) { diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index d026dc34..ce972622 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -20,6 +20,7 @@ const { publishEvent } = require('../eventBus'); const assignmentService = require('../assignmentService'); const { loadConfig } = require('../../helpers/configLoader'); const { createCommandHandlers } = require('../discordBotService/commands'); +const { commandReplyToText } = require('./commandResultFormatter'); const { buildReplayJobId, buildReplayTitle, @@ -45,7 +46,7 @@ function buildRequesterLabel(socket) { return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown'; } -function createWebReplayTextCommand(socket, replayApi) { +function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) { return function createReplayHandler({ rovers, getReplaySources: getAllReplaySources, @@ -106,12 +107,12 @@ function createWebReplayTextCommand(socket, replayApi) { }, }); 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); return { content: String(text || '').trim(), @@ -124,32 +125,20 @@ function createChatCommandMessage({ socket, text }) { nickname, }, reply: async (payload) => { - const replyPayload = typeof payload === 'string' - ? { content: sanitizeMentions(payload) } - : { - 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; + const response = sanitizeMentions(commandReplyToText(payload)); + if (!response) return null; + return sendSystemMessage(response, { nickname: 'Rover bot', bot: true }); }, }; } -async function runChatTextCommand({ text, socket }) { +async function runChatTextCommand({ text, socket, sendSystemMessage }) { if (!isTextCommand(text)) return false; // ReplayEngineV2 has startup side effects by design. Loading it lazily here // keeps ordinary chatService initialization from changing the service boot // order, while still letting `rs replay` use the existing replay pipeline. const replayApi = require('../replayEngineV2'); - const message = createChatCommandMessage({ socket, text }); + const message = createChatCommandMessage({ socket, text, sendSystemMessage }); const commands = createCommandHandlers({ logger: null, client: null, @@ -189,20 +178,17 @@ async function runChatTextCommand({ text, socket }) { isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), discordConfig, config, - createReplayTextCommand: createWebReplayTextCommand(socket, replayApi), + createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi), }); // Let the shared router perform normal command permission checks. Site chat - // has already broadcast the user's command text; any command reply is posted - // into Discord and returns to web chat through the ordinary bridge inbound path. + // has already broadcast the user's command text, so command replies become a + // local bot chat message instead of being routed through Discord as an + // internal transport. try { await commands.handleCommand(message); } catch (err) { - publishEvent({ - source: 'chatCommand', - type: 'discord.bridgeSend', - payload: { content: sanitizeMentions(`Command failed: ${err.message || 'unknown error'}`) }, - }); + sendSystemMessage(`Command failed: ${sanitizeMentions(err.message || 'unknown error')}`, { nickname: 'Rover bot', bot: true }); } return true; } diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 8a3aaad7..8cc4e780 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -13,6 +13,7 @@ const roverManager = require('../roverManager'); const { getRoster, lockRover, rovers } = roverManager; const { MODES, getMode, setMode } = require('../modeManager'); const { sendExternalMessage, sendExternalTyping } = require('../chatService'); +const { commandReplyToText } = require('../chatService/commandResultFormatter'); const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2'); const { getActiveDrivers } = require('../turnService'); const { getNickname } = require('../nicknameService'); @@ -192,10 +193,54 @@ const commands = createCommandHandlers({ 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) => { try { await integrationHandlers.handleBridgeInbound(message); - await commands.handleCommand(message); + await commands.handleCommand(createBridgeMirroredCommandMessage(message)); } catch (err) { logger.warn('Error handling Discord message', err.message); } diff --git a/server/src/services/discordBotService/integrations/chatBridge.js b/server/src/services/discordBotService/integrations/chatBridge.js index f5b14102..fcd48436 100644 --- a/server/src/services/discordBotService/integrations/chatBridge.js +++ b/server/src/services/discordBotService/integrations/chatBridge.js @@ -19,45 +19,6 @@ function formatToolCallsCodeBlock(toolCalls = []) { 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) { const { logger, @@ -65,7 +26,6 @@ function createChatBridgeHandlers(deps) { roverManager, getGuildConfig, listGuildConfigs, - sendToChannel, sendExternalMessage, sendExternalTyping, isAdminUser, @@ -80,37 +40,22 @@ function createChatBridgeHandlers(deps) { const guildConfig = getGuildConfig(message.guild.id); if (!guildConfig?.channelId) return; if (String(message.channelId) !== String(guildConfig.channelId)) return; - if (message.webhookId) return; - const isRoverBotMessage = message.author?.bot && client.user?.id && String(message.author.id) === String(client.user.id); - if (message.author.bot && !isRoverBotMessage) return; + if (message.author.bot) return; const content = (message.content || '').trim(); - const bridgedText = isRoverBotMessage ? discordMessageToBridgeText(message) : content; - if (!bridgedText.trim()) return; + if (!content) return; - const nickname = isRoverBotMessage - ? client.user?.username || 'Rover bot' - : message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; + 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: 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) { 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) { const payload = event?.payload; if (!payload) return; @@ -184,11 +129,7 @@ function createChatBridgeHandlers(deps) { handleChatBridgeOutbound, handleChatTypingOutbound, handleDiscordTypingStart, - handleBridgeSendRequest, }; } -module.exports = { - createChatBridgeHandlers, - discordPayloadToBridgeText, -}; +module.exports = { createChatBridgeHandlers }; diff --git a/server/src/services/discordBotService/integrations/index.js b/server/src/services/discordBotService/integrations/index.js index 22b3c464..603e2c4e 100644 --- a/server/src/services/discordBotService/integrations/index.js +++ b/server/src/services/discordBotService/integrations/index.js @@ -39,7 +39,6 @@ function createIntegrations(deps) { subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); subscribe('chat:message', chat.handleChatBridgeOutbound); subscribe('chat:typing', chat.handleChatTypingOutbound); - subscribe('discord.bridgeSend', chat.handleBridgeSendRequest); return { handleBridgeInbound: chat.handleBridgeInbound,