mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better commanding and bridging slopping
This commit is contained in:
@@ -55,12 +55,11 @@ 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, sendSystemMessage });
|
const ranCommand = await runChatTextCommand({ text: clean, socket });
|
||||||
cb({ success: true, command: ranCommand });
|
cb({ success: true, command: ranCommand });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
|
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
|
||||||
sendSystemMessage(`Command failed: ${err.message || 'unknown error'}`, { nickname: 'Rover bot', bot: true });
|
|
||||||
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
|
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,39 +41,11 @@ function sanitizeMentions(text) {
|
|||||||
.replace(/@here/gi, '[here]');
|
.replace(/@here/gi, '[here]');
|
||||||
}
|
}
|
||||||
|
|
||||||
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 have structured fields. Chat is plain text, so flattening
|
|
||||||
// name/value pairs keeps the command result readable without creating any
|
|
||||||
// new web-specific UI or payload contract.
|
|
||||||
lines.push(`${field.name || 'Field'}\n${field.value || ''}`.trim());
|
|
||||||
});
|
|
||||||
if (data.footer?.text) lines.push(String(data.footer.text));
|
|
||||||
return lines.filter(Boolean).join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function replyPayloadToText(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 text = embedToText(embed);
|
|
||||||
if (text) parts.push(text);
|
|
||||||
});
|
|
||||||
return parts.join('\n\n').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequesterLabel(socket) {
|
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, sendSystemMessage, replayApi) {
|
function createWebReplayTextCommand(socket, replayApi) {
|
||||||
return function createReplayHandler({
|
return function createReplayHandler({
|
||||||
rovers,
|
rovers,
|
||||||
getReplaySources: getAllReplaySources,
|
getReplaySources: getAllReplaySources,
|
||||||
@@ -134,12 +106,12 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] });
|
const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] });
|
||||||
sendSystemMessage(`Replay accepted: ${title}`, { nickname: 'Rover bot', bot: true });
|
await message.reply({ content: `Replay accepted: ${title}` });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
|
function createChatCommandMessage({ socket, text }) {
|
||||||
const nickname = buildRequesterLabel(socket);
|
const nickname = buildRequesterLabel(socket);
|
||||||
return {
|
return {
|
||||||
content: String(text || '').trim(),
|
content: String(text || '').trim(),
|
||||||
@@ -152,20 +124,32 @@ function createChatCommandMessage({ socket, text, sendSystemMessage }) {
|
|||||||
nickname,
|
nickname,
|
||||||
},
|
},
|
||||||
reply: async (payload) => {
|
reply: async (payload) => {
|
||||||
const response = sanitizeMentions(replyPayloadToText(payload));
|
const replyPayload = typeof payload === 'string'
|
||||||
if (!response) return null;
|
? { content: sanitizeMentions(payload) }
|
||||||
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, sendSystemMessage }) {
|
async function runChatTextCommand({ text, socket }) {
|
||||||
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, sendSystemMessage });
|
const message = createChatCommandMessage({ socket, text });
|
||||||
const commands = createCommandHandlers({
|
const commands = createCommandHandlers({
|
||||||
logger: null,
|
logger: null,
|
||||||
client: null,
|
client: null,
|
||||||
@@ -205,13 +189,21 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
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, sendSystemMessage, replayApi),
|
createReplayTextCommand: createWebReplayTextCommand(socket, replayApi),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Let the shared router perform normal command permission checks. Returning
|
// Let the shared router perform normal command permission checks. Site chat
|
||||||
// true tells chatService that the text was consumed as a command and should
|
// has already broadcast the user's command text; any command reply is posted
|
||||||
// not be broadcast as a regular user chat message.
|
// into Discord and returns to web chat through the ordinary bridge inbound path.
|
||||||
await commands.handleCommand(message);
|
try {
|
||||||
|
await commands.handleCommand(message);
|
||||||
|
} catch (err) {
|
||||||
|
publishEvent({
|
||||||
|
source: 'chatCommand',
|
||||||
|
type: 'discord.bridgeSend',
|
||||||
|
payload: { content: sanitizeMentions(`Command failed: ${err.message || 'unknown error'}`) },
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,8 +194,8 @@ const integrationHandlers = integrations.register();
|
|||||||
|
|
||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
try {
|
try {
|
||||||
await commands.handleCommand(message);
|
|
||||||
await integrationHandlers.handleBridgeInbound(message);
|
await integrationHandlers.handleBridgeInbound(message);
|
||||||
|
await commands.handleCommand(message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Error handling Discord message', err.message);
|
logger.warn('Error handling Discord message', err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,45 @@ 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,
|
||||||
@@ -26,6 +65,7 @@ function createChatBridgeHandlers(deps) {
|
|||||||
roverManager,
|
roverManager,
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
listGuildConfigs,
|
listGuildConfigs,
|
||||||
|
sendToChannel,
|
||||||
sendExternalMessage,
|
sendExternalMessage,
|
||||||
sendExternalTyping,
|
sendExternalTyping,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
@@ -40,23 +80,37 @@ 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.author.bot) 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;
|
||||||
const content = (message.content || '').trim();
|
const content = (message.content || '').trim();
|
||||||
const lower = content.toLowerCase();
|
const bridgedText = isRoverBotMessage ? discordMessageToBridgeText(message) : content;
|
||||||
if (lower.startsWith('rs') || lower === 'ts' || lower.startsWith('ts ')) return;
|
if (!bridgedText.trim()) return;
|
||||||
|
|
||||||
const nickname = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
const nickname = isRoverBotMessage
|
||||||
|
? 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: 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 });
|
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 });
|
||||||
} 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;
|
||||||
@@ -130,7 +184,11 @@ function createChatBridgeHandlers(deps) {
|
|||||||
handleChatBridgeOutbound,
|
handleChatBridgeOutbound,
|
||||||
handleChatTypingOutbound,
|
handleChatTypingOutbound,
|
||||||
handleDiscordTypingStart,
|
handleDiscordTypingStart,
|
||||||
|
handleBridgeSendRequest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createChatBridgeHandlers };
|
module.exports = {
|
||||||
|
createChatBridgeHandlers,
|
||||||
|
discordPayloadToBridgeText,
|
||||||
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user