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:
@@ -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: '回覆',
|
||||
|
||||
Reference in New Issue
Block a user