diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index da258c7474..759dc35739 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -19,6 +19,7 @@ import { import type { GroupChatRoom, GroupHoldStamp } from './group-chat' import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership' import { runGroupContinuationMembers, runGroupRoundMember } from './group-round-members' +import { rejectGroupSlashCommand } from './group-slash' import { harvestStrandedGroupReply } from './group-turns' import { requestForBot } from './routing' import type { Attachment, GroupMember, GroupMessage } from './types' @@ -662,6 +663,11 @@ export function sendToGroupChat( images?: Attachment[] ): null | string { const trimmed = String(text || '').trim() + + if (rejectGroupSlashCommand(trimmed)) { + return null + } + const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : [] if ((!trimmed && !attached.length) || !members.length) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-slash.test.ts b/apps/desktop/src/plugins/hermes-bots/group-slash.test.ts new file mode 100644 index 0000000000..467c3776f5 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-slash.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { createGroupGateway, drain, runTimersInline, scriptedStorage } from './group-test-utils' +import type { Attachment, GroupMember } from './types' + +const { host } = vi.hoisted(() => ({ host: {} as Record })) + +vi.mock('@hermes/plugin-sdk', async () => { + const { pluginSdkMock } = await import('./group-test-utils') + + return pluginSdkMock(host) +}) + +const MEMBERS: GroupMember[] = [ + { name: 'research', title: '' }, + { name: 'builder', title: '' } +] + +const IMAGE: Attachment = { data: 'data:image/png;base64,iVBORw0KGgo=', kind: 'image', name: 'screenshot.png' } + +async function loadRoom() { + vi.resetModules() + const gateway = createGroupGateway() + Object.assign(host, gateway.host) + + const [chat, rounds, shared] = await Promise.all([ + import('./group-chat'), + import('./group-rounds'), + import('./shared') + ]) + + shared.setPluginCtx(scriptedStorage(gateway.storage)) + + return { chat, rounds, gateway } +} + +beforeEach(() => runTimersInline()) +afterEach(() => vi.unstubAllGlobals()) + +it('rejects command-shaped sends visibly without changing the room or reaching a member, even with attachments', async () => { + const { chat, rounds, gateway } = await loadRoom() + const thread = rounds.sendToGroupChat('Team', MEMBERS, 'Ready') + await drain(() => Boolean(chat.$groupChats.get().Team?.running)) + chat.$groupNeedsYou.set({ Team: true }) + + const before = structuredClone(chat.$groupChats.get()) + const needsYou = chat.$groupNeedsYou.get() + const storage = structuredClone(gateway.storage) + gateway.rpc.length = 0 + gateway.calls.length = 0 + gateway.attaches.length = 0 + + for (const text of ['/new', ' /ReSeT ', '/custom-skill argument', '/custom_skill\nargument']) { + for (const target of [null, thread]) { + for (const attachments of [[], [IMAGE]]) { + const draftAttachments = structuredClone(attachments) + vi.mocked(host.notify as ReturnType).mockClear() + + expect(rounds.sendToGroupChat('Team', MEMBERS, text, target, attachments)).toBeNull() + await drain(() => Boolean(chat.$groupChats.get().Team?.running)) + + expect(host.notify).toHaveBeenCalledExactlyOnceWith({ + kind: 'warning', + message: expect.stringMatching(/slash commands.*group chats/i) + }) + expect(chat.$groupChats.get()).toEqual(before) + expect(chat.$groupNeedsYou.get()).toBe(needsYou) + expect(gateway.storage).toEqual(storage) + expect(gateway.rpc).toEqual([]) + expect(gateway.calls).toEqual([]) + expect(gateway.attaches).toEqual([]) + expect(attachments).toEqual(draftAttachments) + } + } + } +}) + +it('sends path-first text and embedded slash commands to the room members with attachments intact', async () => { + const { chat, rounds, gateway } = await loadRoom() + + for (const text of ['/etc/hosts', '/tmp/report.txt inspect this', 'Explain /new', '/']) { + gateway.calls.length = 0 + gateway.attaches.length = 0 + + const thread = rounds.sendToGroupChat('Team', MEMBERS, text, null, [IMAGE]) + expect(thread).toEqual(expect.any(String)) + await drain(() => Boolean(chat.$groupChats.get().Team?.running)) + + expect(chat.$groupChats.get().Team.log).toContainEqual(expect.objectContaining({ text, thread, images: [IMAGE] })) + expect(gateway.calls.map(call => call.profile)).toEqual(MEMBERS.map(member => member.name)) + expect(gateway.calls.every(call => call.prompt.includes(text))).toBe(true) + expect(gateway.attaches.map(call => call.data)).toEqual(MEMBERS.map(() => IMAGE.data)) + expect(host.notify).not.toHaveBeenCalled() + } +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-slash.ts b/apps/desktop/src/plugins/hermes-bots/group-slash.ts new file mode 100644 index 0000000000..59b1560aa6 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-slash.ts @@ -0,0 +1,18 @@ +import { host } from '@hermes/plugin-sdk' + +import { botsText } from './i18n' + +/** Group rooms cannot dispatch slash commands. Keep command-shaped input out + * of member prompts without rejecting paths such as /etc/hosts. */ +export function rejectGroupSlashCommand(text: string): boolean { + if (!/^\/[a-z][\w-]*(?:\s|$)/i.test(text)) { + return false + } + + host.notify({ + kind: 'warning', + message: botsText().group.slashCommandsUnsupported + }) + + return true +} diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index d0bd53e29e..d6b96cf348 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -163,6 +163,7 @@ type BotsMessages = { deleteTitle: string deleteAction: string composerPlaceholder: string + slashCommandsUnsupported: string attachHint: string newThread: string reply: string @@ -387,6 +388,8 @@ const en: BotsMessages = { deleteTitle: 'Delete group chat?', deleteAction: 'Delete', composerPlaceholder: 'Say something — every bot in this group hears the room.', + slashCommandsUnsupported: + 'Slash commands are not supported in group chats. Open an individual bot chat to use them.', attachHint: 'Attach files — every responding bot sees them', newThread: 'New Thread', reply: 'Reply', @@ -604,6 +607,8 @@ const ja: BotsMessages = { deleteTitle: 'グループチャットを削除しますか?', deleteAction: '削除', composerPlaceholder: '何か書いてください — このグループのすべてのボットが部屋の内容を受け取ります。', + slashCommandsUnsupported: + 'グループチャットではスラッシュコマンドを使用できません。個別のボットチャットを開いて使用してください。', attachHint: 'ファイルを添付 — 応答するすべてのボットが見ます', newThread: '新しいスレッド', reply: '返信', @@ -817,6 +822,7 @@ const zh: BotsMessages = { deleteTitle: '删除群聊?', deleteAction: '删除', composerPlaceholder: '说点什么 — 这个群里的每个机器人都会听到。', + slashCommandsUnsupported: '群聊不支持斜杠命令。请打开单个机器人的聊天来使用。', attachHint: '附加文件 — 每个回应的机器人都能看到', newThread: '新帖子', reply: '回复', @@ -1030,6 +1036,7 @@ const zhHant: BotsMessages = { deleteTitle: '刪除群組聊天?', deleteAction: '刪除', composerPlaceholder: '說點什麼 — 這個群組裡的每個機器人都會聽到。', + slashCommandsUnsupported: '群組聊天不支援斜線命令。請開啟個別機器人的聊天來使用。', attachHint: '附加檔案 — 每個回應的機器人都能看到', newThread: '新討論串', reply: '回覆',