fix(desktop): reject unsupported group slash commands before delivery

Preserve drafts and attachments through the existing nothing-sent contract. Port the command-shaped guard to the shared send boundary with behavioral coverage and localized feedback.

Co-authored-by: ClintonEmok <54935030+ClintonEmok@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-09-11 05:04:27 -07:00
parent 20f7ef4df5
commit 6fa8518b44
4 changed files with 126 additions and 0 deletions
@@ -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) {
@@ -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<string, unknown> }))
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<typeof vi.fn>).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()
}
})
@@ -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
}
@@ -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: '回覆',