From 3b0dd6990d60dcca70bd4b5f837de9b5e90bf819 Mon Sep 17 00:00:00 2001 From: ruanbin Date: Thu, 30 Jul 2026 21:52:47 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(substitute):=20=E6=96=B0=E5=A2=9E=20ex?= =?UTF-8?q?cludedChats=20=E9=BB=91=E5=90=8D=E5=8D=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=8E=E8=A7=A6=E5=8F=91=E9=97=A8=20deny-wins=20=E7=A1=AC?= =?UTF-8?q?=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 SubstituteModeConfig 增加 excludedChats?: string[](per-bot 群聊黑名单), 在 normalizeSubstituteMode 按 chats 同构解析(去空去重、空则不落字段),并把 黑名单判断收进 isSubstituteAllowedChat:命中即短路整段替身触发块,deny-wins 优先于 chats 白名单,且因短路跳过运行态开关 isSubstituteEnabledForChat 而成为 硬关闭。普通群与话题群一视同仁;直接 @机器人 不受影响。 Co-authored-by: TRAE CLI --- src/bot-registry.ts | 5 ++ src/im/lark/event-dispatcher.ts | 6 +- src/services/substitute-mode-normalize.ts | 7 ++ test/event-dispatcher.test.ts | 93 +++++++++++++++++++++++ test/substitute-mode-normalize.test.ts | 40 ++++++++++ 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index dec546543..052b89274 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -814,6 +814,11 @@ export interface SubstituteModeConfig { disclosure?: 'prefix' | 'none'; /** Optional allow-list of chat IDs. When provided, substitute trigger only fires in these chats. */ chats?: string[]; + /** Optional block-list of chat IDs (黑名单). When a chat is listed here the substitute + * trigger never fires there — deny-wins over {@link chats} (a chat in both is blocked) + * and hard (cannot be re-enabled by the per-chat `/substitute on` runtime toggle). + * Applies to regular and topic groups alike. Direct @bot mentions are unaffected. */ + excludedChats?: string[]; /** When true, do not automatically DM the owner a control card for substitute-mode sessions. */ disableControlCard?: boolean; /** How the bot replies to a substitute-mode trigger: diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 6d0d12194..7cc597631 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1127,7 +1127,11 @@ export function resolveSubstituteTrigger( return undefined; } -function isSubstituteAllowedChat(cfg: { chats?: string[] } | undefined, chatId: string): boolean { +function isSubstituteAllowedChat(cfg: { chats?: string[]; excludedChats?: string[] } | undefined, chatId: string): boolean { + // 黑名单先判且 deny-wins:命中即整段替身触发块短路(连带跳过其后的运行态 + // 开关 isSubstituteEnabledForChat),因此配置黑名单 = 硬关闭,/substitute on + // 也翻不回来。对普通群与话题群一视同仁,与白名单同层。 + if (cfg?.excludedChats?.includes(chatId)) return false; if (!cfg?.chats?.length) return true; return cfg.chats.includes(chatId); } diff --git a/src/services/substitute-mode-normalize.ts b/src/services/substitute-mode-normalize.ts index bf9500523..25b131eaa 100644 --- a/src/services/substitute-mode-normalize.ts +++ b/src/services/substitute-mode-normalize.ts @@ -18,6 +18,9 @@ import type { SubstituteModeConfig, SubstituteTarget } from '../bot-registry.js' * - A DISABLED config still persists its target list (as long as it has ≥1 * target), so the dashboard toggle can flip on/off without re-entering * everyone. Only an empty disabled config collapses to undefined (delete). + * - `chats` (allow-list) and `excludedChats` (block-list) are each trimmed, + * de-duplicated, and dropped when empty. They persist on a disabled config + * the same way targets do. */ export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; @@ -44,6 +47,9 @@ export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | un const chats = Array.isArray(rec.chats) ? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))] : []; + const excludedChats = Array.isArray(rec.excludedChats) + ? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))] + : []; const out: SubstituteModeConfig = { enabled, targets, @@ -53,6 +59,7 @@ export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | un topicActiveSessionTrigger: rec.topicActiveSessionTrigger !== false, }; if (chats.length) out.chats = chats; + if (excludedChats.length) out.excludedChats = excludedChats; const replyMode = rec.replyMode === 'quote' ? 'quote' : 'thread'; if (replyMode === 'quote') out.replyMode = 'quote'; if (rec.disableControlCard === true) out.disableControlCard = true; diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index c9a02b92d..d8e048ec2 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -802,6 +802,7 @@ function setupBotState(opts?: { targets: Array<{ openId?: string; userId?: string; unionId?: string; name?: string }>; disclosure?: 'prefix' | 'none'; chats?: string[]; + excludedChats?: string[]; topicGroups?: boolean; topicActiveSessionTrigger?: boolean; }; @@ -3319,6 +3320,98 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleThreadReply).not.toHaveBeenCalled(); }); + it('substituteMode: excludedChats blocklist disables @substitute in listed chats (hard, even with per-chat toggle on)', async () => { + // R2 + R3: a chat on the blocklist never fires substitute, and the hard + // block wins even when the per-chat runtime toggle reports enabled. + setupBotState({ + allowedUsers: [USER_OPEN_ID], + substituteMode: { + enabled: true, + targets: [{ openId: 'ou_sub', name: 'Sub Person' }], + excludedChats: ['chat-blocked'], + }, + }); + mockIsSubstituteEnabledForChat.mockReturnValue(true); // runtime toggle ON — must still be blocked + mockGetChatMode.mockResolvedValue('group'); + mockGetChatInfo.mockResolvedValue({ userCount: 2, botCount: 1 }); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@Sub Person help with this' }), + messageId: 'msg-substitute-blocked', + chatId: 'chat-blocked', + chatType: 'group', + mentions: [{ key: '@_sub', name: 'Sub Person', id: { open_id: 'ou_sub' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('substituteMode: excludedChats wins over chats allow-list (deny-wins)', async () => { + // R4: a chat listed in BOTH allow-list and blocklist is blocked. + setupBotState({ + allowedUsers: [USER_OPEN_ID], + substituteMode: { + enabled: true, + targets: [{ openId: 'ou_sub', name: 'Sub Person' }], + chats: ['chat-both'], + excludedChats: ['chat-both'], + }, + }); + mockGetChatMode.mockResolvedValue('group'); + mockGetChatInfo.mockResolvedValue({ userCount: 2, botCount: 1 }); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@Sub Person help with this' }), + messageId: 'msg-substitute-deny-wins', + chatId: 'chat-both', + chatType: 'group', + mentions: [{ key: '@_sub', name: 'Sub Person', id: { open_id: 'ou_sub' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('substituteMode: direct @bot still answers in a blocklisted chat (R5)', async () => { + // The blocklist only suppresses the substitute trigger. A direct @bot + // mention routes and answers normally — no substituteTrigger rides. + setupBotState({ + allowedUsers: [USER_OPEN_ID], + substituteMode: { + enabled: true, + targets: [{ openId: 'ou_sub', name: 'Sub Person' }], + excludedChats: ['chat-blocked'], + }, + }); + mockGetChatMode.mockResolvedValue('group'); + mockGetChatInfo.mockResolvedValue({ userCount: 2, botCount: 1 }); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA help with this' }), + messageId: 'msg-substitute-blocked-direct-at', + chatId: 'chat-blocked', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + substituteTrigger: undefined, + })); + }); + it('substituteMode: 话题群 @substitute in a topic without a session spawns the topic session (thread-scope, substituteTrigger rides)', async () => { setupBotState({ allowedUsers: [USER_OPEN_ID], diff --git a/test/substitute-mode-normalize.test.ts b/test/substitute-mode-normalize.test.ts index 69473a0aa..a80fe12ff 100644 --- a/test/substitute-mode-normalize.test.ts +++ b/test/substitute-mode-normalize.test.ts @@ -42,6 +42,46 @@ describe('normalizeSubstituteMode', () => { expect(cfg).not.toHaveProperty('chats'); }); + it('normalizes and deduplicates the excludedChats blocklist', () => { + const cfg = normalizeSubstituteMode({ + enabled: true, + targets: [{ openId: 'ou_alice' }], + excludedChats: ['oc_x', ' oc_y ', '', 'oc_x', 'oc_y'], + }); + expect(cfg).toMatchObject({ + enabled: true, + targets: [{ openId: 'ou_alice' }], + excludedChats: ['oc_x', 'oc_y'], + }); + }); + + it('omits excludedChats when the list is empty', () => { + const cfg = normalizeSubstituteMode({ + enabled: true, + targets: [{ openId: 'ou_alice' }], + excludedChats: [], + }); + expect(cfg).not.toHaveProperty('excludedChats'); + }); + + it('omits excludedChats when not an array', () => { + const cfg = normalizeSubstituteMode({ + enabled: true, + targets: [{ openId: 'ou_alice' }], + excludedChats: 'oc_x', + }); + expect(cfg).not.toHaveProperty('excludedChats'); + }); + + it('keeps excludedChats on a disabled config with targets', () => { + const cfg = normalizeSubstituteMode({ + enabled: false, + targets: [{ openId: 'ou_alice' }], + excludedChats: ['oc_x'], + }); + expect(cfg).toMatchObject({ enabled: false, excludedChats: ['oc_x'] }); + }); + it('defaults replyMode to thread and omits it from output', () => { const cfg = normalizeSubstituteMode({ enabled: true, From db987aeae03b8c6d74ceaa89ca5d3ec702ccf54e Mon Sep 17 00:00:00 2001 From: ruanbin Date: Thu, 30 Jul 2026 21:55:02 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(substitute):=20/substitute=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=84=9F=E7=9F=A5=E9=BB=91=E5=90=8D=E5=8D=95=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=81=87=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 黑名单群里 /substitute status|on|off 统一回报 cmd.substitute.blocked(新增 zh/en 文案),不写运行态开关——否则用户会看到「✅ 已开启」却静默不代答。 检查置于 topic_disabled 之后、owner 权限检查之前(屏蔽状态非敏感)。 Co-authored-by: TRAE CLI --- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/im/lark/substitute-command.ts | 8 +++++++ test/substitute-command.test.ts | 37 +++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 50363f6ac..4a02e6226 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -253,6 +253,7 @@ export const messages: Record = { 'cmd.substitute.updated_off': '✅ Substitute mode disabled for this group.', 'cmd.substitute.unsupported': '⚠️ /substitute only works in group chats (regular or topic groups).', 'cmd.substitute.topic_disabled': '⚠️ Substitute mode for topic groups is disabled in this bot configuration; the per-group switch cannot enable it.', + 'cmd.substitute.blocked': '⚠️ This group is on the substitute block-list in the bot configuration; substitute never fires here and /substitute on cannot enable it.', 'cmd.substitute.owner_only': '⚠️ Only owner/allowedUsers can change substitute mode.', 'cmd.substitute.usage': 'Usage: @me /substitute status | on | off', 'cmd.restart.in_progress': '🔄 Restarting {cliName}…', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 99fd0c26a..9c8f251d1 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -256,6 +256,7 @@ export const messages: Record = { 'cmd.substitute.updated_off': '✅ 已关闭当前群替身模式。', 'cmd.substitute.unsupported': '⚠️ /substitute 仅支持群聊(普通群/话题群)。', 'cmd.substitute.topic_disabled': '⚠️ 当前 bot 配置已关闭话题群替身支持,群内开关无法单独开启。', + 'cmd.substitute.blocked': '⚠️ 当前群已被 bot 配置加入替身黑名单,替身不会在此触发,/substitute on 也无法开启。', 'cmd.substitute.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改替身模式开关。', 'cmd.substitute.usage': '用法:@我 /substitute status|on|off', 'cmd.restart.in_progress': '🔄 正在重启 {cliName}...', diff --git a/src/im/lark/substitute-command.ts b/src/im/lark/substitute-command.ts index 9f9b0d284..8ff4425be 100644 --- a/src/im/lark/substitute-command.ts +++ b/src/im/lark/substitute-command.ts @@ -40,6 +40,14 @@ export async function tryHandleSubstituteCommand( await reply(t('cmd.substitute.topic_disabled', undefined, loc)); return true; } + if (getBot(larkAppId).config.substituteMode?.excludedChats?.includes(chatId)) { + // 配置黑名单是硬关闭:dispatcher 里 isSubstituteAllowedChat 命中即短路, + // per-chat /substitute on 翻不回来。若仍回 status_on / updated_on 就是假成功 + // (用户看到“已开启”却静默不代答),所以对 status/on/off 统一回报被屏蔽, + // 且不写运行态开关。先于 owner 权限检查:屏蔽状态非敏感,人人可见。 + await reply(t('cmd.substitute.blocked', undefined, loc)); + return true; + } const arg = match[1]?.trim().toLowerCase() ?? 'status'; if (!arg || arg === 'status') { diff --git a/test/substitute-command.test.ts b/test/substitute-command.test.ts index 915a52048..df58c5ec6 100644 --- a/test/substitute-command.test.ts +++ b/test/substitute-command.test.ts @@ -151,4 +151,41 @@ describe('tryHandleSubstituteCommand', () => { expect(lastReply()).toBe('cmd.substitute.topic_disabled'); expect(mockSetSubstituteEnabledForChat).not.toHaveBeenCalled(); }); + + it('excludedChats blocklist reports blocked instead of a false per-chat success', async () => { + mockGetBot.mockReturnValue({ + config: { + substituteMode: { + enabled: true, + targets: [{ openId: 'ou_sub' }], + topicGroups: true, + excludedChats: ['oc_group'], + }, + }, + }); + + await tryHandleSubstituteCommand(APP, msg('/substitute status'), USER); + expect(lastReply()).toBe('cmd.substitute.blocked'); + + await tryHandleSubstituteCommand(APP, msg('/substitute on'), USER); + expect(lastReply()).toBe('cmd.substitute.blocked'); + expect(mockSetSubstituteEnabledForChat).not.toHaveBeenCalled(); + }); + + it('non-blocklisted chats keep working when a blocklist is configured', async () => { + mockGetBot.mockReturnValue({ + config: { + substituteMode: { + enabled: true, + targets: [{ openId: 'ou_sub' }], + topicGroups: true, + excludedChats: ['oc_other'], + }, + }, + }); + + await tryHandleSubstituteCommand(APP, msg('/substitute on'), USER); + expect(mockSetSubstituteEnabledForChat).toHaveBeenCalledWith(APP, 'oc_group', true); + expect(lastReply()).toBe('cmd.substitute.updated_on'); + }); }); From 09c17dc16dc0746817954bfdc06e17cf9151707e Mon Sep 17 00:00:00 2001 From: ruanbin Date: Thu, 30 Jul 2026 21:57:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(substitute):=20excludedChats=20?= =?UTF-8?q?=E7=BB=8F=20store=20=E4=B8=8E=20IPC=20PUT=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 updateBotSubstituteMode 与 PUT /api/bot-substitute-mode 两处按 chats 同构 解析 excludedChats(去空去重、空则不落字段)。store 侧显式覆盖以与 chats 对称、 防未来重构丢失 spread 透传;IPC handler 构造显式对象故必须补该字段。 Co-authored-by: TRAE CLI --- src/core/dashboard-ipc-server.ts | 4 ++++ src/services/substitute-mode-store.ts | 9 ++++++++- test/dashboard-ipc.test.ts | 4 +++- test/substitute-mode-store.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index dc5ee7f5a..282c5de9e 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -2733,6 +2733,9 @@ ipcRoute('PUT', '/api/bot-substitute-mode', async (req, res) => { const chats = Array.isArray(rec.chats) ? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))] : []; + const excludedChats = Array.isArray(rec.excludedChats) + ? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))] + : []; const r = await substituteModeStore.updateBotSubstituteMode(cachedLarkAppId, { enabled: rec.enabled === true, targets, @@ -2740,6 +2743,7 @@ ipcRoute('PUT', '/api/bot-substitute-mode', async (req, res) => { replyMode: rec.replyMode === 'quote' ? 'quote' : 'thread', disableControlCard: rec.disableControlCard === true, ...(chats.length ? { chats } : {}), + ...(excludedChats.length ? { excludedChats } : {}), // 话题群开关:显式 false 才关(旧客户端不带字段 → normalize 缺省开)。 topicGroups: rec.topicGroups, topicActiveSessionTrigger: rec.topicActiveSessionTrigger, diff --git a/src/services/substitute-mode-store.ts b/src/services/substitute-mode-store.ts index 1d313f0a1..9e176c1a5 100644 --- a/src/services/substitute-mode-store.ts +++ b/src/services/substitute-mode-store.ts @@ -22,7 +22,14 @@ export async function updateBotSubstituteMode( const chats = Array.isArray(rec.chats) ? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))] : []; - const normalized = normalizeSubstituteMode({ ...rec, chats: chats.length ? chats : undefined }); + const excludedChats = Array.isArray(rec.excludedChats) + ? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))] + : []; + const normalized = normalizeSubstituteMode({ + ...rec, + chats: chats.length ? chats : undefined, + excludedChats: excludedChats.length ? excludedChats : undefined, + }); if (rec.enabled === true && (!Array.isArray(rec.targets) || rec.targets.length === 0 || !normalized)) { return { ok: false, reason: 'targets_required' }; } diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 0f34cac10..ab1769712 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -1722,16 +1722,18 @@ describe('PUT /api/bot-substitute-mode', () => { targets: [{ userId: 'u_alice', name: 'Alice' }], disclosure: 'prefix', replyMode: 'quote', + excludedChats: ['oc_x', ' oc_y ', '', 'oc_x'], }), }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ ok: true, - substituteMode: { replyMode: 'quote' }, + substituteMode: { replyMode: 'quote', excludedChats: ['oc_x', 'oc_y'] }, }); expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].substituteMode).toMatchObject({ replyMode: 'quote', + excludedChats: ['oc_x', 'oc_y'], }); } finally { if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; diff --git a/test/substitute-mode-store.test.ts b/test/substitute-mode-store.test.ts index 99db2a33b..f7c05a834 100644 --- a/test/substitute-mode-store.test.ts +++ b/test/substitute-mode-store.test.ts @@ -146,6 +146,30 @@ describe('substitute-mode store', () => { expect(readConfig().substituteMode.chats).toEqual(['oc_a', 'oc_b']); }); + it('persists and normalizes the excludedChats blocklist', async () => { + writeConfig(); + const { registry, store } = await freshModules(); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + + const r = await store.updateBotSubstituteMode('app_default', { + enabled: true, + disclosure: 'prefix', + targets: [{ openId: 'ou_alice', name: 'Alice' }], + excludedChats: ['oc_x', ' oc_y ', '', 'oc_x'], + }); + + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.substituteMode).toMatchObject({ + enabled: true, + targets: [{ openId: 'ou_alice', name: 'Alice' }], + excludedChats: ['oc_x', 'oc_y'], + }); + } + expect(registry.getBot('app_default').config.substituteMode?.excludedChats).toEqual(['oc_x', 'oc_y']); + expect(readConfig().substituteMode.excludedChats).toEqual(['oc_x', 'oc_y']); + }); + it('rejects enabled mode without a matchable target', async () => { writeConfig(); const { registry, store } = await freshModules(); From 5d48609e696e34d7eb27d04cf18f360cb0ee5a49 Mon Sep 17 00:00:00 2001 From: ruanbin Date: Thu, 30 Jul 2026 22:05:26 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(substitute):=20dashboard=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=BB=91=E5=90=8D=E5=8D=95=E7=BE=A4=E8=81=8A=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot Defaults 替身区块在「生效群聊」白名单下新增「黑名单群聊」textarea, 镜像既有白名单全链路(state/mode-sync/save payload),复用 parse/format 助手; save() 参数类型补 excludedChats。新增 zh/en 文案说明硬关闭 + deny-wins 语义。 Co-authored-by: TRAE CLI --- src/dashboard/web/bot-defaults-page.tsx | 20 ++++++++++++++++++-- src/dashboard/web/bot-defaults.ts | 1 + src/dashboard/web/i18n.ts | 6 ++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 657ad9f82..8d404a95e 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -2651,6 +2651,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot const [replyMode, setReplyMode] = useState<'thread' | 'quote'>(initial?.replyMode === 'quote' ? 'quote' : 'thread'); const [controlCard, setControlCard] = useState(initial?.disableControlCard !== true); const [chatsText, setChatsText] = useState(() => formatSubstituteChats(initial?.chats)); + const [excludedChatsText, setExcludedChatsText] = useState(() => formatSubstituteChats(initial?.excludedChats)); // 话题群相关开关缺省开:只有显式 false 才是关(与 normalize 语义一致)。 const [topicGroups, setTopicGroups] = useState(initial?.topicGroups !== false); const [topicActiveSessionTrigger, setTopicActiveSessionTrigger] = useState(initial?.topicActiveSessionTrigger !== false); @@ -2744,13 +2745,14 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot setReplyMode(next?.replyMode === 'quote' ? 'quote' : 'thread'); setControlCard(next?.disableControlCard !== true); setChatsText(formatSubstituteChats(next?.chats)); + setExcludedChatsText(formatSubstituteChats(next?.excludedChats)); setTopicGroups(next?.topicGroups !== false); setTopicActiveSessionTrigger(next?.topicActiveSessionTrigger !== false); const targets = next?.targets ?? []; setTargetRows(targets.length ? targets.map(target => makeTargetDraft(target)) : [makeTargetDraft()]); }, [props.bot.larkAppId, props.bot.substituteMode]); - async function save(body: { enabled: boolean; targets: BotSubstituteTarget[]; disclosure?: 'prefix' | 'none'; chats?: string[]; replyMode?: 'thread' | 'quote'; disableControlCard?: boolean; topicGroups?: boolean; topicActiveSessionTrigger?: boolean }): Promise { + async function save(body: { enabled: boolean; targets: BotSubstituteTarget[]; disclosure?: 'prefix' | 'none'; chats?: string[]; excludedChats?: string[]; replyMode?: 'thread' | 'quote'; disableControlCard?: boolean; topicGroups?: boolean; topicActiveSessionTrigger?: boolean }): Promise { setBusy(true); setStatus(null); try { @@ -2771,6 +2773,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot setReplyMode(next?.replyMode === 'quote' ? 'quote' : 'thread'); setControlCard(next?.disableControlCard !== true); setChatsText(formatSubstituteChats(next?.chats)); + setExcludedChatsText(formatSubstituteChats(next?.excludedChats)); setTopicGroups(next?.topicGroups !== false); setTopicActiveSessionTrigger(next?.topicActiveSessionTrigger !== false); if (resolution.length) { @@ -2843,7 +2846,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot setStatus({ text: `✗ ${tr('botDefaults.substituteTargetsInvalid')}` }); return; } - void save({ enabled, targets, disclosure, chats: parseSubstituteChats(chatsText), replyMode, disableControlCard: !controlCard, topicGroups, topicActiveSessionTrigger }); + void save({ enabled, targets, disclosure, chats: parseSubstituteChats(chatsText), excludedChats: parseSubstituteChats(excludedChatsText), replyMode, disableControlCard: !controlCard, topicGroups, topicActiveSessionTrigger }); } const disclosureOptions: DropdownFieldOption<'prefix' | 'none'>[] = [ @@ -2929,6 +2932,19 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot /> +
+