From 0d00d45ad268cfe68c4570df10aa2226876badcf Mon Sep 17 00:00:00 2001 From: "hushihao.bob" Date: Tue, 28 Jul 2026 12:49:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(lark):=20=E9=9A=94=E7=A6=BB=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E7=BE=A4=E7=94=A8=E6=88=B7=E5=88=9B=E5=BB=BA=E7=9A=84?= =?UTF-8?q?=E8=AF=9D=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dashboard/web/i18n.ts | 4 +- src/i18n/en.ts | 2 +- src/i18n/zh.ts | 2 +- src/im/lark/event-dispatcher.ts | 28 ++++- src/services/chat-reply-mode-store.ts | 9 +- test/event-dispatcher.test.ts | 155 +++++++++++++++++++++----- 6 files changed, 165 insertions(+), 35 deletions(-) diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index b2d848b63..ba0115958 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1810,7 +1810,7 @@ const zh: DashboardMessages = { 'botDefaults.regularGroupModeChatTopic': 'chat-topic(顶层平铺连续会话,原生话题各自独立会话)', 'botDefaults.regularGroupModeNewTopic': 'new-topic(每条顶层 @ 各开独立话题与会话)', 'botDefaults.regularGroupModeShared': 'shared(话题模式但复用同一个 session)', - 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话;chat-topic=顶层连续、原生话题独立;new-topic=每次 @ 新建话题与会话;shared=话题展示但共享会话。可被群级 /reply-mode 覆盖。', + 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话;chat-topic=顶层连续、进入未接管的原生话题时保持独立;new-topic=每次 @ 新建话题与会话;shared=顶层话题展示但共享会话。所有模式下,用户显式创建并由 bot 接入的原生话题均使用独立会话。可被群级 /reply-mode 覆盖。', 'botDefaults.mentionMode': '群聊 @ 策略', 'botDefaults.mentionModeAlways': '都需要 @(默认)', 'botDefaults.mentionModeTopic': '仅话题内不需要 @', @@ -3808,7 +3808,7 @@ const en: DashboardMessages = { 'botDefaults.regularGroupModeChatTopic': 'chat-topic (flat continuous at top level, native topics each get their own session)', 'botDefaults.regularGroupModeNewTopic': 'new-topic (each top-level @ opens its own topic & session)', 'botDefaults.regularGroupModeShared': 'shared (topic display, reusing one session)', - 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session; chat-topic = native topics get separate sessions; new-topic = each @ opens a topic and session; shared = topic UI with shared session. Chat-level /reply-mode can override it.', + 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session; chat-topic = unowned native topics stay independent on entry; new-topic = each @ opens a topic and session; shared = top-level topic UI with a shared session. In every mode, a user-created native topic that the bot joins uses an independent session. Chat-level /reply-mode can override it.', 'botDefaults.mentionMode': 'Group @ policy', 'botDefaults.mentionModeAlways': 'Always require @ (default)', 'botDefaults.mentionModeTopic': 'No @ needed inside topics', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6ed74a22c..54c301147 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -213,7 +213,7 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ Group reply mode updated to: {mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode only works in regular groups and DMs; topic groups need no setting (they are already threaded).', 'cmd.reply_mode.owner_only': '⚠️ Only owner/allowedUsers can change the session mode.', - 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat replies reusing the group session (even native topics fold into it); chat-topic = flat at top level reusing the group session, but each native topic runs its own independent session; topic = topic display while reusing the same group session (shared is a compatibility alias); new-topic = each @ opens a fresh topic + its own session.', + 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat top-level replies reusing the group session; chat-topic = flat at top level, while entry into an unowned native topic stays independent; topic = top-level topic display while reusing the same group session (shared is a compatibility alias); new-topic = each top-level @ opens a fresh topic + its own session. In every mode, a user-created native topic that the bot joins uses an independent session.', 'cmd.reply_mode.failed': '⚠️ Failed to update reply mode: {reason}', 'cmd.reply_mode.dm_status': 'Current DM session mode: {mode}\nCommands: /reply-mode chat | topic', 'cmd.reply_mode.dm_updated': '✅ DM session mode updated to: {mode}', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3093abb0f..a8dac6f5e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -216,7 +216,7 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ 已切换当前群回复模式为:{mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode 仅支持普通群与私聊;话题群无需设置(本就是话题)。', 'cmd.reply_mode.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改会话模式。', - 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=群内平铺回复且复用群会话(连原生话题也并进这个会话);chat-topic=顶层平铺复用群会话,但群里原生话题各自独立会话;topic=回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次 @ 都开一个独立话题+独立会话。', + 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=顶层消息平铺回复并复用群会话;chat-topic=顶层平铺复用群会话,进入尚未接管的原生话题时保持独立;topic=顶层回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次顶层 @ 都开一个独立话题+独立会话。所有模式下,用户显式创建并由 bot 接入的原生话题都会使用独立 session。', 'cmd.reply_mode.failed': '⚠️ 切换失败:{reason}', 'cmd.reply_mode.dm_status': '当前私聊会话模式:{mode}\n可用命令:/reply-mode chat|topic', 'cmd.reply_mode.dm_updated': '✅ 已切换私聊会话模式为:{mode}', diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 93a08a4b2..ce7733309 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1654,9 +1654,17 @@ async function maybeApplySharedTopicSeed(input: { messageId: string; routing: { scope: 'thread' | 'chat'; anchor: string }; forceTopicApplied?: boolean; + independentTopicSeed?: boolean; }): Promise { - const { larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied } = input; - if (forceTopicApplied) return undefined; + const { + larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, + forceTopicApplied, independentTopicSeed, + } = input; + if (forceTopicApplied || independentTopicSeed) return undefined; + // This helper only seeds a shared reply topic. A message with both fields is + // already a reply inside a native thread; folding it would divert an owned + // thread session back into the group lobby and split one topic's context. + if (message?.root_id && message?.thread_id) return undefined; if (chatType !== 'group') return undefined; if (resolveRegularGroupMode(larkAppId, chatId) !== 'shared') return undefined; // Seeding a shared topic normally needs an @mention. But the 'never' and @@ -1697,6 +1705,11 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { const rootId: string | undefined = message?.root_id; const threadId: string | undefined = message?.thread_id; if (!rootId || !threadId) return undefined; + // A genuine native Lark topic is an explicit user-created context boundary, + // even when this bot first enters on a later reply rather than on the seed. + // Synthetic/shared reply aliases do not use an omt_* thread id and continue + // through the regular-group fold below. + if (threadId.startsWith('omt_')) return undefined; const rawText = extractMessageTextForRouting(message); if (rawText) { const stripped = stripLeadingMentions(rawText.trim(), message?.mentions ?? []); @@ -1726,6 +1739,8 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { /** Compute the scope + anchor for an inbound message: * - root_id + thread_id → thread-scope, anchor = root_id (real Lark 话题) * - 话题群 + no real thread → thread-scope, anchor = message_id (thread seed) + * - 普通群 + thread_id only → thread-scope, anchor = message_id (a native + * user-created topic seed) * - p2p + no real thread → thread-scope, anchor = message_id (each DM * top-level message starts a fresh topic; a * reply inside an existing thread carries @@ -1796,6 +1811,13 @@ async function decideRoutingWithSource( if (mode === 'topic') { return { scope: 'thread', anchor: messageId, source: 'topic-chat' }; } + // A native topic root in a regular group carries thread_id=omt_* but no + // root_id. It must start its own session instead of sharing the group lobby + // session. Keep this after the topic-chat check so topic-group seeds retain + // source=topic-chat (and therefore autoStartOnNewTopic semantics). + if (threadId?.startsWith('omt_')) { + return { scope: 'thread', anchor: messageId, source: 'real-thread' }; + } return regularGroupRouting(larkAppId, messageId, chatId); } @@ -2285,6 +2307,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin if (!replyRootId) { replyRootId = await maybeApplySharedTopicSeed({ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing: ctx, forceTopicApplied: forcedTopic, + independentTopicSeed: decision.source === 'real-thread' && !!message.thread_id && !message.root_id, }); } // Foreign-bot @mention gate: apply the same vetted-peer/talk-only @@ -2579,6 +2602,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin } else { const seedReplyRootId = await maybeApplySharedTopicSeed({ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied, + independentTopicSeed: decision.source === 'real-thread' && !!message.thread_id && !message.root_id, }); if (seedReplyRootId) { replyRootId = seedReplyRootId; diff --git a/src/services/chat-reply-mode-store.ts b/src/services/chat-reply-mode-store.ts index 8a7d9f5e2..3c511beb0 100644 --- a/src/services/chat-reply-mode-store.ts +++ b/src/services/chat-reply-mode-store.ts @@ -3,9 +3,7 @@ * * Four modes — unifies #116 + #131 into one knob so a chat resolves * to EXACTLY ONE mode and the two thread-reply mechanisms can never compete: - * • chat — flat chat-scope replies in the group (default). A native - * Lark topic the user opens here folds back into this one - * chat-scope session too (see maybeFoldMentionedRegularGroupThreadToChat). + * • chat — flat chat-scope replies in the group (default). * • topic/shared — 话题展示但复用同一个 session: reuse the bot's existing * chat-scope session/worker/cwd, but route this turn's reply * into the trigger message's thread (#131). @@ -16,6 +14,11 @@ * runs its own independent thread-scope session (NOT folded). * "顶层平铺连续会话;群内原生话题各自独立会话". * + * In every mode, a genuine native topic seed explicitly created by a user + * (`thread_id=omt_*`, no `root_id`) starts an independent thread-scope session. + * The modes above govern top-level bot replies and how an unowned existing + * topic is adopted; they never absorb a native topic session the bot owns. + * * Resolution: per-chat override (`chatReplyModes[chatId]`) wins; otherwise fall * back to the per-bot default (`regularGroupReplyMode`, default 'chat'). The * setting is bot-scoped: Bot A can prefer topic replies in one group while Bot B diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 708c85472..614ac720f 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -1161,6 +1161,36 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { expect(await decideRouting(MY_APP_ID, { message_id: 'msg-g', chat_id: 'oc_g', chat_type: 'group', root_id: 'root-g', thread_id: 'root-g' })) .toEqual({ scope: 'thread', anchor: 'root-g' }); }); + + it('regular-group native topic seed starts an independent session at messageId', async () => { + setupBotState({}); + mockGetChatMode.mockResolvedValue('group'); + expect(await decideRouting(MY_APP_ID, { + message_id: 'msg-topic-root', chat_id: 'oc_group', chat_type: 'group', + root_id: undefined, thread_id: 'omt_native_topic', + })).toEqual({ scope: 'thread', anchor: 'msg-topic-root' }); + }); + + it('a later reply in that native topic stays anchored at its root', async () => { + setupBotState({}); + mockGetChatMode.mockResolvedValue('group'); + expect(await decideRouting(MY_APP_ID, { + message_id: 'msg-topic-reply', chat_id: 'oc_group', chat_type: 'group', + root_id: 'msg-topic-root', thread_id: 'omt_native_topic', + })).toEqual({ scope: 'thread', anchor: 'msg-topic-root' }); + }); + + it.each(['chat', 'shared', 'new-topic', 'chat-topic'] as const)( + 'regular-group native topic seed stays independent in %s mode', + async (mode) => { + setupBotState({ regularGroupReplyMode: mode }); + mockGetChatMode.mockResolvedValue('group'); + expect(await decideRouting(MY_APP_ID, { + message_id: `msg-topic-${mode}`, chat_id: 'oc_group', chat_type: 'group', + root_id: undefined, thread_id: `omt_native_${mode}`, + })).toEqual({ scope: 'thread', anchor: `msg-topic-${mode}` }); + }, + ); }); describe('isBotMentioned', () => { @@ -2669,34 +2699,34 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); - it('folds an @ inside a regular-group topic into the chat-scope session when no thread session owns it', async () => { - // In chat/shared modes, a mentioned reply inside a regular-group topic should - // reuse the group chat-scope context and reply in that same topic, rather - // than spawning a new thread-scope session per topic. - const event = makeUserMessageEvent({ - senderOpenId: USER_OPEN_ID, - content: JSON.stringify({ text: '@BotA new topic please' }), - rootId: 'real-topic-root', - threadId: 'omt_real_thread', // real Lark thread - chatId: 'chat-fallback-3', - chatType: 'group', - mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], - }); - // Bot owns chat-scope at this chat — but we should NOT re-route into it. - handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-fallback-3'); - mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); + it.each(['chat', 'shared', 'new-topic', 'chat-topic'] as const)( + 'first entry into an unowned native topic stays independent in %s mode', + async (mode) => { + setupBotState({ regularGroupReplyMode: mode, allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA join this native topic' }), + rootId: `real-topic-root-${mode}`, + threadId: `omt_real_thread_${mode}`, + chatId: 'chat-fallback-3', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-fallback-3'); + mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); - await capturedHandlers['im.message.receive_v1'](event); - await flushEventWork(); + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); - expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ - scope: 'chat', - anchor: 'chat-fallback-3', - replyRootId: 'real-topic-root', - larkAppId: MY_APP_ID, - })); - expect(handlers.handleNewTopic).not.toHaveBeenCalled(); - }); + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: `real-topic-root-${mode}`, + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }, + ); it('keeps thread-scope when root_id+thread_id are set and a thread session DOES exist', async () => { // Bot already owns a thread-scope session at this root → continue it. @@ -2767,6 +2797,57 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); + it('shared mode does not absorb an explicitly created native topic seed', async () => { + setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA work independently here' }), + messageId: 'msg-independent-topic', + chatId: 'chat-reply-mode', + chatType: 'group', + threadId: 'omt_independent_topic', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-reply-mode'); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-independent-topic', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('shared mode keeps a native topic reply in its owned seed session', async () => { + setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA continue the independent work' }), + rootId: 'msg-independent-topic', + messageId: 'msg-independent-reply', + chatId: 'chat-reply-mode', + chatType: 'group', + threadId: 'omt_independent_topic', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'msg-independent-topic'); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-independent-topic', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); + it('shared thread-contained @ reuses the chat session and replies in the existing topic', async () => { setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); @@ -4874,6 +4955,28 @@ describe('im.message.receive_v1 — 主动开工 场景② (autoStartOnNewTopic) })); }); + it('话题群带 omt thread_id 的 seed 仍保留 autoStartOnNewTopic', async () => { + setupAutoTopicBot(true); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '帮我处理这个新话题' }), + messageId: 'msg-topic-omt-seed', + chatId: 'chat-topic-omt', + chatType: 'group', + threadId: 'omt_topic_chat_seed', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-topic-omt-seed', + larkAppId: MY_APP_ID, + })); + }); + it('话题群新话题(未 @)开关关 → 不触发 (FR-8)', async () => { setupAutoTopicBot(false); mockGetChatMode.mockResolvedValue('topic'); From e695d417b5435b7a6d3c7c869483b58f1a86a66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 08:16:35 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(lark):=20=E5=8E=9F=E7=94=9F=E8=AF=9D?= =?UTF-8?q?=E9=A2=98=E9=9A=94=E7=A6=BB=E6=94=B6=E7=AA=84=E5=88=B0=20chat-t?= =?UTF-8?q?opic=20+=20=E9=BB=98=E8=AE=A4=E6=94=B9=20chat-topic/=E7=A7=81?= =?UTF-8?q?=E8=81=8A=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 hu5h 的原生话题隔离修复(0d00d45)基础上,按申晗决策做两处调整: 1. 收窄原生话题隔离到 chat-topic(遵循 /reply-mode 配置) hu5h 版对所有模式都隔离用户创建的原生话题;改为仅 chat-topic 隔离, chat/shared 按文档契约仍把原生话题折进群会话。两处 omt_ 判据都加 `resolveRegularGroupMode === 'chat-topic'` 门控: - decideRoutingWithSource 的 seed 分支; - maybeFoldMentionedRegularGroupThreadToChat 的 reply-entry 分支 (hu5h 新增,保留其"bot 从 reply 进入话题也隔离"的更完整覆盖)。 移除随之失效的 independentTopicSeed 开关(其作用已被 hu5h 的 `root_id && thread_id` 守卫覆盖,守卫保留——它同时堵住经 /t、模式切换、 restore/adopt 到达的既存 reply-as-seed split-brain)。 2. 两个 per-bot 默认值翻转 - 普通群 regularGroupReplyMode 默认 chat → chat-topic(顶层平铺 + 原生 话题各自独立;依赖上面的 chat-topic seed 隔离才在默认态生效); - 私聊 p2pMode 默认 thread → chat(整段 DM 一个连续会话)。 同步反转所有"默认值→归一化 undefined"的平行拷贝点(普通群 5 处、 私聊 ~12 处),否则显式选中新的非默认值会被静默丢弃。i18n 中英 帮助/标签的默认标记与 narrow 语义同步(还原 hu5h 的"所有模式隔离"表述)。 测试: event-dispatcher 245、命中面 8 文件 644、全量 non-e2e 与干净 master 同基线(env/时区/bwrap 预存失败)零回归;tsc/build 通过。 Co-Authored-By: Riff --- src/bot-registry.ts | 16 +- src/core/dashboard-ipc-server.ts | 14 +- src/daemon.ts | 2 +- src/dashboard/bot-payload.ts | 6 +- src/dashboard/web/bot-defaults-page.tsx | 10 +- src/dashboard/web/i18n.ts | 20 +-- src/i18n/en.ts | 4 +- src/i18n/zh.ts | 4 +- src/im/lark/card-builder.ts | 9 +- src/im/lark/event-dispatcher.ts | 72 +++++---- src/im/lark/relay-target-routing.ts | 10 +- src/im/lark/reply-mode-command.ts | 7 +- src/services/bot-config-store.ts | 4 +- src/services/card-prefs-store.ts | 16 +- src/services/chat-reply-mode-store.ts | 28 ++-- test/bot-registry-grant.test.ts | 19 ++- test/card-prefs-auto-start.test.ts | 5 +- test/command-handler.test.ts | 10 +- test/event-dispatcher.test.ts | 194 ++++++++++++++++-------- test/relay-target-routing.test.ts | 12 +- test/reply-mode-command.test.ts | 10 +- 21 files changed, 285 insertions(+), 187 deletions(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index f2a1e0e6f..8e82c8dd4 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -2160,9 +2160,10 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { ? entry.receivedReactionEmoji.trim() : undefined, doneReactionEmoji: typeof entry.doneReactionEmoji === 'string' && entry.doneReactionEmoji.trim() ? entry.doneReactionEmoji.trim() : undefined, - // Only 'chat' is meaningful; 'thread' (and anything else) normalizes to - // undefined — the legacy thread-per-message default. Keeps bots.json clean. - p2pMode: entry.p2pMode === 'chat' ? 'chat' : undefined, + // Default is now 'chat' (flat continuous DM session). Only 'thread' is + // meaningful and persists; 'chat' (and anything else) normalizes to + // undefined so bots.json stays clean. + p2pMode: entry.p2pMode === 'thread' ? 'thread' : undefined, noCardChats: Array.isArray(entry.noCardChats) ? entry.noCardChats.filter((x: any): x is string => typeof x === 'string' && x.trim().length > 0).map((x: string) => x.trim()) : undefined, @@ -2180,12 +2181,13 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { : undefined, autoStartOnNewTopic: entry.autoStartOnNewTopic === true || undefined, worktreeMultiPicker: entry.worktreeMultiPicker === true || undefined, - // Per-bot regular-group default mode. Only the non-default modes - // ('chat-topic' | 'new-topic' | 'shared') are meaningful; 'chat' (the flat - // default) and anything else normalize to undefined so bots.json stays clean. + // Per-bot regular-group default mode. Default is 'chat-topic' (顶层平铺 + // 连续会话;群内原生话题各自独立会话), so only the NON-default modes + // ('chat' | 'new-topic' | 'shared') are meaningful and persist; 'chat-topic' + // and anything else normalize to undefined so bots.json stays clean. regularGroupReplyMode: (() => { const mode = normalizeChatReplyModeConfig(entry.regularGroupReplyMode); - return mode === 'new-topic' || mode === 'shared' || mode === 'chat-topic' ? mode : undefined; + return mode === 'chat' || mode === 'new-topic' || mode === 'shared' ? mode : undefined; })(), // 4-tier @ policy. Only 'topic' | 'never' | 'ambient' are meaningful; // 'always' (the default) and anything else normalize to undefined so diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index cb4cd9f41..5af39699a 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -2307,8 +2307,8 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { const { defaultOncall, autoboundChats } = oncallStore.getBotDefaultOncall(cachedLarkAppId); const cardPrefs = cardPrefsStore.getBotCardPrefs(cachedLarkAppId); const grantPrefs = grantPrefsStore.getBotGrantPrefs(cachedLarkAppId); - let p2pMode: 'thread' | 'chat' = 'thread'; - try { if (getBot(cachedLarkAppId).config.p2pMode === 'chat') p2pMode = 'chat'; } catch { /* default thread */ } + let p2pMode: 'thread' | 'chat' = 'chat'; + try { if (getBot(cachedLarkAppId).config.p2pMode === 'thread') p2pMode = 'thread'; } catch { /* default chat */ } let skillInjection: 'global' | 'prompt' | 'off' | null = null; // How this bot's CLI delivers botmux skills, so the dashboard can render the // control correctly: 'dynamic' = per-session --plugin-dir (claude-family, not @@ -2798,8 +2798,8 @@ ipcRoute('PUT', '/api/bot-agent', async (req, res) => { }); // Per-bot 私聊单聊模式 p2pMode。Body `{ p2pMode: 'chat' | 'thread' }`: -// • 'chat' → 私聊走扁平连续 chat-scope 会话 -// • 'thread'(默认) → 清回每条 DM 独立 thread-scope 会话 +// • 'chat'(默认) → 私聊走扁平连续 chat-scope 会话 +// • 'thread' → 显式回到每条 DM 独立 thread-scope 会话 // 走 applyConfigField(与 /botconfig 同一写盘 + 热更新路径),保证一致。 ipcRoute('PUT', '/api/bot-p2p-mode', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); @@ -2809,11 +2809,11 @@ ipcRoute('PUT', '/api/bot-p2p-mode', async (req, res) => { const spec = findConfigField('p2pMode'); if (!spec) return jsonRes(res, 500, { ok: false, error: 'spec_missing' }); - // 只有 'chat' 有意义;其它(含 'thread')一律清回默认,bots.json 保持干净。 - const value = body.p2pMode === 'chat' ? 'chat' : null; + // 只有 'thread' 有意义;其它(含 'chat',新默认)一律清回默认,bots.json 保持干净。 + const value = body.p2pMode === 'thread' ? 'thread' : null; const r = await applyConfigField(cachedLarkAppId, spec, value); if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); - jsonRes(res, 200, { ok: true, p2pMode: value ?? 'thread' }); + jsonRes(res, 200, { ok: true, p2pMode: value ?? 'chat' }); }); // Per-bot 内置技能注入模式 skillInjection。Body `{ skillInjection: 'global'|'prompt'|'off'|'' }`: diff --git a/src/daemon.ts b/src/daemon.ts index b94d963e4..bcc9c51b4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -4081,7 +4081,7 @@ async function adoptCodexNotifierEvent( signal.throwIfAborted(); const botCfg = getBot(larkAppId).config; - const scope: 'thread' | 'chat' = botCfg.p2pMode === 'chat' ? 'chat' : 'thread'; + const scope: 'thread' | 'chat' = botCfg.p2pMode === 'thread' ? 'thread' : 'chat'; const anchor = scope === 'chat' ? chatId : cardMessageId; const activeKey = sessionKey(anchor, larkAppId); let ds = activeSessions.get(activeKey); diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 742cd828b..4afe45a55 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -68,9 +68,9 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: summaryRange: j?.summaryRange ?? summaryRangeFromLegacyContentTriggers(j?.contentTriggers) ?? defaultSummaryRangePrefs(), - regularGroupReplyMode: (j?.regularGroupReplyMode === 'new-topic' || j?.regularGroupReplyMode === 'shared' || j?.regularGroupReplyMode === 'chat-topic') + regularGroupReplyMode: (j?.regularGroupReplyMode === 'chat' || j?.regularGroupReplyMode === 'new-topic' || j?.regularGroupReplyMode === 'shared') ? j.regularGroupReplyMode - : 'chat', + : 'chat-topic', regularGroupMentionMode: (j?.regularGroupMentionMode === 'topic' || j?.regularGroupMentionMode === 'never' || j?.regularGroupMentionMode === 'ambient') ? j.regularGroupMentionMode : 'always', @@ -78,7 +78,7 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: restrictGrantCommands: j?.restrictGrantCommands === true, autoGrantRequestCards: j?.autoGrantRequestCards !== false, messageQuotaDefaultLimit: typeof j?.messageQuotaDefaultLimit === 'number' ? j.messageQuotaDefaultLimit : null, - p2pMode: j?.p2pMode === 'chat' ? 'chat' : 'thread', + p2pMode: j?.p2pMode === 'thread' ? 'thread' : 'chat', skillInjection: (j?.skillInjection === 'global' || j?.skillInjection === 'prompt' || j?.skillInjection === 'off') ? j.skillInjection : null, skillInjectionDefault: (j?.skillInjectionDefault === 'global' || j?.skillInjectionDefault === 'off') ? j.skillInjectionDefault : 'prompt', skillInjectionSupport: (j?.skillInjectionSupport === 'dynamic' || j?.skillInjectionSupport === 'global') ? j.skillInjectionSupport : 'none', diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 9e633e011..767a9ddd1 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -2157,7 +2157,7 @@ function SessionModeSection(props: { putCardPref(patch: CardPrefPatch): Promise; }) { const tr = useT(); - const [p2p, setP2p] = useState(props.bot.p2pMode === 'chat' ? 'chat' : 'thread'); + const [p2p, setP2p] = useState(props.bot.p2pMode === 'thread' ? 'thread' : 'chat'); const [regular, setRegular] = useState(regularGroupMode(props.bot)); const [mention, setMention] = useState(mentionMode(props.bot)); const [docMode, setDocMode] = useState(props.bot.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only'); @@ -2168,7 +2168,7 @@ function SessionModeSection(props: { const [docStatus, setDocStatus] = useState(null); useEffect(() => { - setP2p(props.bot.p2pMode === 'chat' ? 'chat' : 'thread'); + setP2p(props.bot.p2pMode === 'thread' ? 'thread' : 'chat'); setRegular(regularGroupMode(props.bot)); setMention(mentionMode(props.bot)); setDocMode(props.bot.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only'); @@ -2187,7 +2187,7 @@ function SessionModeSection(props: { try { const res = await sendJson('PUT', `/api/bots/${encodeURIComponent(props.bot.larkAppId)}/p2p-mode`, { p2pMode: mode }); if (res.ok && res.body.ok) { - props.patchBot(props.bot.larkAppId, { p2pMode: res.body.p2pMode === 'chat' ? 'chat' : 'thread' }); + props.patchBot(props.bot.larkAppId, { p2pMode: res.body.p2pMode === 'thread' ? 'thread' : 'chat' }); setP2pStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true }); } else { setP2pStatus({ text: `✗ ${responseErrorText(res)}` }); @@ -2720,9 +2720,9 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot } function regularGroupMode(bot: BotDefaultsRow): string { - return bot.regularGroupReplyMode === 'new-topic' || bot.regularGroupReplyMode === 'shared' || bot.regularGroupReplyMode === 'chat-topic' + return bot.regularGroupReplyMode === 'chat' || bot.regularGroupReplyMode === 'new-topic' || bot.regularGroupReplyMode === 'shared' ? bot.regularGroupReplyMode - : 'chat'; + : 'chat-topic'; } function mentionMode(bot: BotDefaultsRow): string { diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index ba0115958..e71510be1 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1803,14 +1803,14 @@ const zh: DashboardMessages = { 'botDefaults.sectionSessionMode': '会话模式', 'botDefaults.p2pMode': '私聊会话模式', 'botDefaults.p2pThread': 'thread(每条 DM 独立会话)', - 'botDefaults.p2pChat': 'chat(扁平连续单聊会话)', - 'botDefaults.p2pHelp': '私聊会话方式:thread=每条顶层消息独立会话;chat=整段私聊共享一个连续会话。改动立即生效。', + 'botDefaults.p2pChat': 'chat(扁平连续单聊会话,默认)', + 'botDefaults.p2pHelp': '私聊会话方式:chat(默认)=整段私聊共享一个连续会话;thread=每条顶层消息独立会话。改动立即生效。', 'botDefaults.regularGroupMode': '普通群会话模式', - 'botDefaults.regularGroupModeChat': 'chat(扁平连续单聊会话,默认)', - 'botDefaults.regularGroupModeChatTopic': 'chat-topic(顶层平铺连续会话,原生话题各自独立会话)', + 'botDefaults.regularGroupModeChat': 'chat(扁平连续单聊会话)', + 'botDefaults.regularGroupModeChatTopic': 'chat-topic(顶层平铺连续会话,原生话题各自独立会话,默认)', 'botDefaults.regularGroupModeNewTopic': 'new-topic(每条顶层 @ 各开独立话题与会话)', 'botDefaults.regularGroupModeShared': 'shared(话题模式但复用同一个 session)', - 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话;chat-topic=顶层连续、进入未接管的原生话题时保持独立;new-topic=每次 @ 新建话题与会话;shared=顶层话题展示但共享会话。所有模式下,用户显式创建并由 bot 接入的原生话题均使用独立会话。可被群级 /reply-mode 覆盖。', + 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话(原生话题也并进群会话);chat-topic=顶层连续、群内原生话题各自独立会话(默认);new-topic=每次 @ 新建话题与会话;shared=顶层话题展示但共享会话(原生话题也并进)。仅 chat-topic 会隔离用户创建的原生话题,遵循本配置。可被群级 /reply-mode 覆盖。', 'botDefaults.mentionMode': '群聊 @ 策略', 'botDefaults.mentionModeAlways': '都需要 @(默认)', 'botDefaults.mentionModeTopic': '仅话题内不需要 @', @@ -3801,14 +3801,14 @@ const en: DashboardMessages = { 'botDefaults.sectionSessionMode': 'Session mode', 'botDefaults.p2pMode': 'Private chat session mode', 'botDefaults.p2pThread': 'thread (separate session per DM)', - 'botDefaults.p2pChat': 'chat (flat continuous session)', - 'botDefaults.p2pHelp': 'Private chat session mode: thread creates one session per top-level message; chat shares one continuous session. Applies immediately.', + 'botDefaults.p2pChat': 'chat (flat continuous session, default)', + 'botDefaults.p2pHelp': 'Private chat session mode: chat (default) shares one continuous session; thread creates one session per top-level message. Applies immediately.', 'botDefaults.regularGroupMode': 'Regular group session mode', - 'botDefaults.regularGroupModeChat': 'chat (flat continuous session, default)', - 'botDefaults.regularGroupModeChatTopic': 'chat-topic (flat continuous at top level, native topics each get their own session)', + 'botDefaults.regularGroupModeChat': 'chat (flat continuous session)', + 'botDefaults.regularGroupModeChatTopic': 'chat-topic (flat continuous at top level, native topics each get their own session, default)', 'botDefaults.regularGroupModeNewTopic': 'new-topic (each top-level @ opens its own topic & session)', 'botDefaults.regularGroupModeShared': 'shared (topic display, reusing one session)', - 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session; chat-topic = unowned native topics stay independent on entry; new-topic = each @ opens a topic and session; shared = top-level topic UI with a shared session. In every mode, a user-created native topic that the bot joins uses an independent session. Chat-level /reply-mode can override it.', + 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session (native topics fold in too); chat-topic = flat at top level, native topics each get their own session (default); new-topic = each @ opens a topic and session; shared = top-level topic UI with a shared session (native topics fold in too). Only chat-topic isolates user-created native topics, honoring this setting. Chat-level /reply-mode can override it.', 'botDefaults.mentionMode': 'Group @ policy', 'botDefaults.mentionModeAlways': 'Always require @ (default)', 'botDefaults.mentionModeTopic': 'No @ needed inside topics', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 54c301147..6efbe3c23 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -213,11 +213,11 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ Group reply mode updated to: {mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode only works in regular groups and DMs; topic groups need no setting (they are already threaded).', 'cmd.reply_mode.owner_only': '⚠️ Only owner/allowedUsers can change the session mode.', - 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat top-level replies reusing the group session; chat-topic = flat at top level, while entry into an unowned native topic stays independent; topic = top-level topic display while reusing the same group session (shared is a compatibility alias); new-topic = each top-level @ opens a fresh topic + its own session. In every mode, a user-created native topic that the bot joins uses an independent session.', + 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat replies reusing the group session (native topics fold in too); chat-topic = flat at top level, but each native topic runs its own independent session (default); topic = topic display while reusing the same group session (shared is a compatibility alias); new-topic = each @ opens a fresh topic + its own session. Only chat-topic isolates user-created native topics.', 'cmd.reply_mode.failed': '⚠️ Failed to update reply mode: {reason}', 'cmd.reply_mode.dm_status': 'Current DM session mode: {mode}\nCommands: /reply-mode chat | topic', 'cmd.reply_mode.dm_updated': '✅ DM session mode updated to: {mode}', - 'cmd.reply_mode.dm_usage': 'Usage (DM): /reply-mode chat | topic\nchat = the whole DM shares one continuous session; topic = each message starts its own session.', + 'cmd.reply_mode.dm_usage': 'Usage (DM): /reply-mode chat | topic\nchat = the whole DM shares one continuous session (default); topic = each message starts its own session.', 'cmd.reply_mode.dm_shared_unsupported': '⚠️ shared / chat-topic only make sense in regular groups; DMs support chat | topic only.', 'cmd.substitute.status_on': 'Current substitute mode for this group: ON (default). When a configured substitute target is @mentioned, I will answer on their behalf.', 'cmd.substitute.status_off': 'Current substitute mode for this group: OFF. Use @me /substitute on to enable it again.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a8dac6f5e..edb1cca6c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -216,11 +216,11 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ 已切换当前群回复模式为:{mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode 仅支持普通群与私聊;话题群无需设置(本就是话题)。', 'cmd.reply_mode.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改会话模式。', - 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=顶层消息平铺回复并复用群会话;chat-topic=顶层平铺复用群会话,进入尚未接管的原生话题时保持独立;topic=顶层回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次顶层 @ 都开一个独立话题+独立会话。所有模式下,用户显式创建并由 bot 接入的原生话题都会使用独立 session。', + 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=群内平铺回复且复用群会话(连原生话题也并进这个会话);chat-topic=顶层平铺复用群会话,但群里原生话题各自独立会话(默认);topic=回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次 @ 都开一个独立话题+独立会话。仅 chat-topic 会隔离用户创建的原生话题。', 'cmd.reply_mode.failed': '⚠️ 切换失败:{reason}', 'cmd.reply_mode.dm_status': '当前私聊会话模式:{mode}\n可用命令:/reply-mode chat|topic', 'cmd.reply_mode.dm_updated': '✅ 已切换私聊会话模式为:{mode}', - 'cmd.reply_mode.dm_usage': '用法(私聊):/reply-mode chat|topic\nchat=整段 DM 共用一个连续会话;topic=每条消息各自起独立会话。', + 'cmd.reply_mode.dm_usage': '用法(私聊):/reply-mode chat|topic\nchat=整段 DM 共用一个连续会话(默认);topic=每条消息各自起独立会话。', 'cmd.reply_mode.dm_shared_unsupported': '⚠️ shared / chat-topic 仅对普通群有意义;私聊只支持 chat|topic。', 'cmd.substitute.status_on': '当前群替身模式:已开启(默认)。群里 @ 到配置的替身对象时,我会代答。', 'cmd.substitute.status_off': '当前群替身模式:已关闭。可用 @我 /substitute on 重新开启。', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index 4bc107305..abe3e115d 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -72,10 +72,11 @@ export function buildConfigCard(data: ConfigCardData, locale?: Locale): string { [{ text: def, value: CONFIG_UNSET }, { text: '中文 (zh)', value: 'zh' }, { text: 'English (en)', value: 'en' }], { action: 'config_set', field: 'lang', ...locVal })); // 私聊单聊模式:thread(默认,每条 DM 独立会话)| chat(扁平连续会话)。thread 与 - // 未设等价,故 thread 选项用 unset 哨兵:选它即清字段、回默认,避免把字面 - // 'thread' 写进 bots.json(与 dashboard 下拉一致,/botconfig get 重启前后一致)。 - runSelects.push(configSelect(t('card.config.p2p.placeholder', undefined, locale), data.p2pMode === 'chat' ? 'chat' : CONFIG_UNSET, - [{ text: t('card.config.p2p.thread', undefined, locale), value: CONFIG_UNSET }, { text: t('card.config.p2p.chat', undefined, locale), value: 'chat' }], + // 未设等价,故 chat 选项用 unset 哨兵:选它即清字段、回默认(扁平连续 DM), + // 避免把字面 'chat' 写进 bots.json(与 dashboard 下拉一致,/botconfig get 重启 + // 前后一致)。只有显式 'thread'(每条 DM 独立)才是需要落盘的值。 + runSelects.push(configSelect(t('card.config.p2p.placeholder', undefined, locale), data.p2pMode === 'thread' ? 'thread' : CONFIG_UNSET, + [{ text: t('card.config.p2p.chat', undefined, locale), value: CONFIG_UNSET }, { text: t('card.config.p2p.thread', undefined, locale), value: 'thread' }], { action: 'config_set', field: 'p2pMode', ...locVal })); elements.push({ tag: 'action', actions: runSelects }); diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index ce7733309..36c4bc3d2 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1654,13 +1654,12 @@ async function maybeApplySharedTopicSeed(input: { messageId: string; routing: { scope: 'thread' | 'chat'; anchor: string }; forceTopicApplied?: boolean; - independentTopicSeed?: boolean; }): Promise { const { larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, - forceTopicApplied, independentTopicSeed, + forceTopicApplied, } = input; - if (forceTopicApplied || independentTopicSeed) return undefined; + if (forceTopicApplied) return undefined; // This helper only seeds a shared reply topic. A message with both fields is // already a reply inside a native thread; folding it would divert an owned // thread session back into the group lobby and split one topic's context. @@ -1707,9 +1706,12 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { if (!rootId || !threadId) return undefined; // A genuine native Lark topic is an explicit user-created context boundary, // even when this bot first enters on a later reply rather than on the seed. - // Synthetic/shared reply aliases do not use an omt_* thread id and continue - // through the regular-group fold below. - if (threadId.startsWith('omt_')) return undefined; + // In `chat-topic` mode that boundary is honored — don't fold it into the group + // lobby. chat / shared are documented to fold native topics into the group + // session, so they must fall through to the regular-group fold below. + // Synthetic/shared reply aliases do not use an omt_* thread id and always + // continue through the fold below regardless of mode. + if (threadId.startsWith('omt_') && resolveRegularGroupMode(larkAppId, chatId) === 'chat-topic') return undefined; const rawText = extractMessageTextForRouting(message); if (rawText) { const stripped = stripLeadingMentions(rawText.trim(), message?.mentions ?? []); @@ -1739,17 +1741,20 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { /** Compute the scope + anchor for an inbound message: * - root_id + thread_id → thread-scope, anchor = root_id (real Lark 话题) * - 话题群 + no real thread → thread-scope, anchor = message_id (thread seed) - * - 普通群 + thread_id only → thread-scope, anchor = message_id (a native - * user-created topic seed) - * - p2p + no real thread → thread-scope, anchor = message_id (each DM - * top-level message starts a fresh topic; a - * reply inside an existing thread carries + * - 普通群 chat-topic + thread_id only → thread-scope, anchor = message_id + * (a native user-created topic seed; other regular + * -group modes fold it per regularGroupRouting) + * - p2p (chat, default) → chat-scope, anchor = chat_id (整段 DM 共用一个 + * 连续会话) + * - p2p thread + no real thread → thread-scope, anchor = message_id (explicit + * opt-out: each DM top-level message starts a fresh + * topic; a reply inside an existing thread carries * root_id+thread_id and threads into its session) * - 普通群 + no real thread → resolved regular-group mode: * new-topic uses thread-scope anchored at - * message_id; chat / shared stay chat-scope - * anchored at chat_id (shared folds into a topic - * post-routing, see maybeApplySharedTopicSeed). + * message_id; chat / shared / chat-topic stay + * chat-scope anchored at chat_id (shared folds into + * a topic post-routing, see maybeApplySharedTopicSeed). * * Why we gate on thread_id (not root_id alone): Lark 客户端的引用气泡 / 快速 * 回复 UI 有时会给"用户视角的顶层消息"塞 root_id 但**不会**塞 thread_id。 @@ -1789,19 +1794,20 @@ async function decideRoutingWithSource( const messageId: string = message.message_id; const chatId: string = message.chat_id; - // 私聊 chat 模式:整段 DM 一律折进同一个扁平 chat-scope 会话。必须先于下面的 - // real-thread(root_id+thread_id)分支判断 —— 否则用户在 DM 里"回复某条消息" - // 形成的 thread 形态消息会被提前分流到 thread-scope,破坏"连续单聊会话"语义 - // (典型触发:thread→chat 模式切换后回复旧 thread,或 Lark 给 DM 回复塞了 - // thread_id)。仅 p2p 且 p2pMode==='chat' 命中,群聊 / p2p 默认 thread 模式不受影响。 - if (chatType === 'p2p' && getBot(larkAppId)?.config?.p2pMode === 'chat') { + // 私聊 chat 模式(默认):整段 DM 一律折进同一个扁平 chat-scope 会话。必须先于 + // 下面的 real-thread(root_id+thread_id)分支判断 —— 否则用户在 DM 里"回复某条 + // 消息"形成的 thread 形态消息会被提前分流到 thread-scope,破坏"连续单聊会话" + // 语义(典型触发:thread→chat 模式切换后回复旧 thread,或 Lark 给 DM 回复塞了 + // thread_id)。p2pMode 默认 'chat';只有显式 'thread' 才回到每条 DM 独立话题的 + // 旧行为。群聊不受影响。 + if (chatType === 'p2p' && getBot(larkAppId)?.config?.p2pMode !== 'thread') { return { scope: 'chat', anchor: chatId, source: 'p2p' }; } if (rootId && threadId) return { scope: 'thread', anchor: rootId, source: 'real-thread' }; - // 私聊默认(thread 模式):每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 - // Lark DM 的话题化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。 + // 私聊 thread 模式(显式 opt-out):每条 top-level DM 都视为新话题 — 跟话题群 + // 同款,匹配 Lark DM 的话题化行为,把 1:1 对话按消息拆成独立 CLI 进程。 if (chatType === 'p2p') { return { scope: 'thread', anchor: messageId, source: 'p2p' }; } @@ -1812,10 +1818,22 @@ async function decideRoutingWithSource( return { scope: 'thread', anchor: messageId, source: 'topic-chat' }; } // A native topic root in a regular group carries thread_id=omt_* but no - // root_id. It must start its own session instead of sharing the group lobby - // session. Keep this after the topic-chat check so topic-group seeds retain - // source=topic-chat (and therefore autoStartOnNewTopic semantics). - if (threadId?.startsWith('omt_')) { + // root_id. In `chat-topic` mode (顶层平铺连续会话;群内原生话题各自独立会话) + // this seed must start its own thread-scope session instead of folding into + // the group lobby — otherwise the topic's opening message lands in the shared + // chat session and only later replies (which carry root_id and hit the + // real-thread branch above) isolate, breaking chat-topic's own contract. + // + // Gated on `chat-topic` ONLY, by design (遵循 /reply-mode 配置): + // • chat / shared — documented to fold native topics into the group session + // (see /reply-mode help); must NOT isolate here. + // • new-topic — regularGroupRouting already returns thread-scope anchored + // at messageId for top-level messages, with the correct + // source=regular-group-thread; letting it fall through keeps + // that source (and its downstream summary/forward semantics). + // Keep this after the topic-chat check so 话题群 seeds retain source=topic-chat + // (and therefore autoStartOnNewTopic semantics). + if (threadId?.startsWith('omt_') && resolveRegularGroupMode(larkAppId, chatId) === 'chat-topic') { return { scope: 'thread', anchor: messageId, source: 'real-thread' }; } return regularGroupRouting(larkAppId, messageId, chatId); @@ -2307,7 +2325,6 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin if (!replyRootId) { replyRootId = await maybeApplySharedTopicSeed({ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing: ctx, forceTopicApplied: forcedTopic, - independentTopicSeed: decision.source === 'real-thread' && !!message.thread_id && !message.root_id, }); } // Foreign-bot @mention gate: apply the same vetted-peer/talk-only @@ -2602,7 +2619,6 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin } else { const seedReplyRootId = await maybeApplySharedTopicSeed({ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied, - independentTopicSeed: decision.source === 'real-thread' && !!message.thread_id && !message.root_id, }); if (seedReplyRootId) { replyRootId = seedReplyRootId; diff --git a/src/im/lark/relay-target-routing.ts b/src/im/lark/relay-target-routing.ts index c738f270e..b73b30815 100644 --- a/src/im/lark/relay-target-routing.ts +++ b/src/im/lark/relay-target-routing.ts @@ -42,14 +42,14 @@ export function resolveRelayTargetRouting(input: { }): RelayTargetRouting { const { larkAppId, chatId, message, chatMode } = input; - // 私聊 chat 模式(扁平连续):整段 DM 折进同一个 chat-scope 会话,relay 目标 - // 也落在同一个 chatId 锚上。必须先于 real-thread 分支 —— 与 decideRouting + // 私聊 chat 模式(默认,扁平连续):整段 DM 折进同一个 chat-scope 会话,relay + // 目标也落在同一个 chatId 锚上。必须先于 real-thread 分支 —— 与 decideRouting // 同序,否则在 DM 残留 thread 里敲 /relay 会被分流成 thread 目标,破坏 - // 「连续单聊会话」语义。 + // 「连续单聊会话」语义。p2pMode 默认 'chat';只有显式 'thread' 回到每条 DM 独立。 if (chatMode === 'p2p') { let p2pMode: 'thread' | 'chat' | undefined; - try { p2pMode = getBot(larkAppId)?.config?.p2pMode; } catch { /* unregistered bot → default thread */ } - if (p2pMode === 'chat') return { scope: 'chat', anchor: chatId }; + try { p2pMode = getBot(larkAppId)?.config?.p2pMode; } catch { /* unregistered bot → default chat */ } + if (p2pMode !== 'thread') return { scope: 'chat', anchor: chatId }; } // A reply *inside* an existing Lark thread carries both root_id and diff --git a/src/im/lark/reply-mode-command.ts b/src/im/lark/reply-mode-command.ts index 3b824e077..9caa4e4ed 100644 --- a/src/im/lark/reply-mode-command.ts +++ b/src/im/lark/reply-mode-command.ts @@ -44,7 +44,7 @@ export async function tryHandleReplyModeCommand( if (isP2p) { if (isStatus) { if (!canTalk) return true; - const cur = getBot(larkAppId).config.p2pMode === 'chat' ? 'chat' : 'new-topic'; + const cur = getBot(larkAppId).config.p2pMode === 'thread' ? 'new-topic' : 'chat'; await reply(t('cmd.reply_mode.dm_status', { mode: replyModeLabel(cur) }, loc)); return true; } @@ -71,8 +71,9 @@ export async function tryHandleReplyModeCommand( await reply(t('cmd.reply_mode.failed', { reason: 'spec_missing' }, loc)); return true; } - // chat → 扁平连续 DM 会话;topic/new-topic → 清回默认(每条 DM 独立)。 - const value = mode === 'chat' ? 'chat' : null; + // chat(默认)→ 清回默认(扁平连续 DM 会话);topic/new-topic → 显式 thread + // (每条 DM 独立会话)。 + const value = mode === 'chat' ? null : 'thread'; const r = await applyConfigField(larkAppId, spec, value); if (!r.ok) { await reply(t('cmd.reply_mode.failed', { reason: r.reason }, loc)); diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index 46e3b703e..e59d4be84 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -82,7 +82,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'disableCliBypass', configKey: 'disableCliBypass', kind: 'boolean', effect: 'next-session', clearable: false, hint: '不加 CLI 审批/sandbox 绕过参数 on|off' }, { key: 'codexAppCleanInput', configKey: 'codexAppCleanInput', kind: 'boolean', effect: 'immediate', clearable: false, hint: '实验性:Codex App 用户气泡只保留真实输入,Botmux 元数据走隐藏上下文;默认 off,从下一次 turn 派发生效,不改已有历史' }, { key: 'restrictGrantCommands', configKey: 'restrictGrantCommands', kind: 'boolean', effect: 'immediate', clearable: false, hint: '被授权人仅能纯对话、拦截斜杠命令 on|off' }, - { key: 'p2pMode', configKey: 'p2pMode', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['thread', 'chat'], hint: '私聊单聊模式 thread|chat;chat=扁平连续会话,thread/unset 回默认(每条 DM 独立会话)' }, + { key: 'p2pMode', configKey: 'p2pMode', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['thread', 'chat'], hint: '私聊单聊模式 thread|chat;默认 chat=扁平连续会话,thread=每条 DM 独立会话(chat/unset 回默认)' }, { key: 'maxLiveWorkers', configKey: 'maxLiveWorkers', kind: 'number', effect: 'immediate', clearable: true, hint: '最大常驻会话数;超过后最久未用的会话自动休眠(退出后台进程和 CLI、回收内存,下条消息冷恢复);unset=默认 30' }, { key: 'customPassthroughCommands', configKey: 'customPassthroughCommands', kind: 'stringList', effect: 'immediate', clearable: true, hint: '额外放行透传给 CLI 的 slash 命令(逗号/空格分隔,如 /goal /export);unset 回仅内置白名单' }, { key: 'canTalkDaemonCommands', configKey: 'canTalkDaemonCommands', kind: 'stringList', effect: 'immediate', clearable: true, parseList: parseCanTalkDaemonCommandsInput, hint: '把列出的 daemon 命令权限从 canOperate(仅管理员)降到 canTalk(对话放行即可用),如 /status /help;仅认 daemon 命令,透传命令无效;unset 回全部仅管理员' }, @@ -394,7 +394,7 @@ export interface ConfigCardData { model: string | null; modelChoices: string[]; lang: string | null; - /** 私聊单聊模式 p2pMode('chat' | 'thread');null = 未设(默认 thread)。 */ + /** 私聊单聊模式 p2pMode('chat' | 'thread');null = 未设(默认 chat)。 */ p2pMode: string | null; brandLabel: string | null; defaultWorkingDir: string | null; diff --git a/src/services/card-prefs-store.ts b/src/services/card-prefs-store.ts index 6d98266ed..77e306221 100644 --- a/src/services/card-prefs-store.ts +++ b/src/services/card-prefs-store.ts @@ -68,7 +68,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { autoStartOnGroupJoin: c.autoStartOnGroupJoin === true, autoStartOnGroupJoinPrompt: typeof c.autoStartOnGroupJoinPrompt === 'string' ? c.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: c.autoStartOnNewTopic === true, - regularGroupReplyMode: c.regularGroupReplyMode ?? 'chat', + regularGroupReplyMode: c.regularGroupReplyMode ?? 'chat-topic', regularGroupMentionMode: c.regularGroupMentionMode === 'topic' || c.regularGroupMentionMode === 'never' || c.regularGroupMentionMode === 'ambient' ? c.regularGroupMentionMode : 'always', docSubscribeDefaultMode: c.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only', @@ -85,7 +85,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { autoStartOnGroupJoin: false, autoStartOnGroupJoinPrompt: '', autoStartOnNewTopic: false, - regularGroupReplyMode: 'chat', + regularGroupReplyMode: 'chat-topic', regularGroupMentionMode: 'always', docSubscribeDefaultMode: 'mention-only', }; @@ -123,11 +123,11 @@ export async function updateBotCardPrefs( if (val.trim()) entry[key] = val; else delete entry[key]; }; - // Regular-group default mode: store only the non-default modes; 'chat' (the - // default) drops the key so bots.json stays tidy (absent === 'chat'). + // Regular-group default mode: store only the non-default modes; 'chat-topic' + // (the default) drops the key so bots.json stays tidy (absent === 'chat-topic'). const applyMode = (entry: any, key: keyof BotCardPrefs, val: ChatReplyMode | undefined) => { if (val === undefined) return; - if (val === 'new-topic' || val === 'shared' || val === 'chat-topic') entry[key] = val; + if (val === 'chat' || val === 'new-topic' || val === 'shared') entry[key] = val; else delete entry[key]; }; // 4-tier @ policy: store only the non-default tiers; 'always' (default) drops @@ -171,9 +171,9 @@ export async function updateBotCardPrefs( autoStartOnGroupJoin: entry.autoStartOnGroupJoin === true, autoStartOnGroupJoinPrompt: typeof entry.autoStartOnGroupJoinPrompt === 'string' ? entry.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: entry.autoStartOnNewTopic === true, - regularGroupReplyMode: (entry.regularGroupReplyMode === 'new-topic' || entry.regularGroupReplyMode === 'shared' || entry.regularGroupReplyMode === 'chat-topic') + regularGroupReplyMode: (entry.regularGroupReplyMode === 'chat' || entry.regularGroupReplyMode === 'new-topic' || entry.regularGroupReplyMode === 'shared') ? entry.regularGroupReplyMode - : 'chat', + : 'chat-topic', regularGroupMentionMode: (entry.regularGroupMentionMode === 'topic' || entry.regularGroupMentionMode === 'never' || entry.regularGroupMentionMode === 'ambient') ? entry.regularGroupMentionMode : 'always', @@ -216,7 +216,7 @@ export async function updateBotCardPrefs( bot.config.autoStartOnNewTopic = patch.autoStartOnNewTopic || undefined; } if (patch.regularGroupReplyMode !== undefined) { - bot.config.regularGroupReplyMode = (patch.regularGroupReplyMode === 'new-topic' || patch.regularGroupReplyMode === 'shared' || patch.regularGroupReplyMode === 'chat-topic') + bot.config.regularGroupReplyMode = (patch.regularGroupReplyMode === 'chat' || patch.regularGroupReplyMode === 'new-topic' || patch.regularGroupReplyMode === 'shared') ? patch.regularGroupReplyMode : undefined; } diff --git a/src/services/chat-reply-mode-store.ts b/src/services/chat-reply-mode-store.ts index 3c511beb0..e35205fa8 100644 --- a/src/services/chat-reply-mode-store.ts +++ b/src/services/chat-reply-mode-store.ts @@ -3,24 +3,26 @@ * * Four modes — unifies #116 + #131 into one knob so a chat resolves * to EXACTLY ONE mode and the two thread-reply mechanisms can never compete: - * • chat — flat chat-scope replies in the group (default). + * • chat — flat chat-scope replies in the group. A native Lark topic + * the user opens here folds back into this one chat-scope + * session too (see maybeFoldMentionedRegularGroupThreadToChat). * • topic/shared — 话题展示但复用同一个 session: reuse the bot's existing * chat-scope session/worker/cwd, but route this turn's reply - * into the trigger message's thread (#131). + * into the trigger message's thread (#131). A native topic + * seed also folds into the shared session, not an independent one. * • new-topic — explicit fork mode: each top-level @mention opens a fresh * thread-scope session under the trigger (its own worker/cwd/context). - * • chat-topic — hybrid: top-level @mentions stay flat in the one chat-scope - * session (like `chat`), BUT a native Lark topic the user opens - * runs its own independent thread-scope session (NOT folded). + * • chat-topic — hybrid (default): top-level @mentions stay flat in the one + * chat-scope session (like `chat`), BUT a native Lark topic the + * user opens runs its own independent thread-scope session (NOT + * folded), whether the bot enters on the seed or a later reply. * "顶层平铺连续会话;群内原生话题各自独立会话". * - * In every mode, a genuine native topic seed explicitly created by a user - * (`thread_id=omt_*`, no `root_id`) starts an independent thread-scope session. - * The modes above govern top-level bot replies and how an unowned existing - * topic is adopted; they never absorb a native topic session the bot owns. + * Native-topic isolation is chat-topic-only, honoring the /reply-mode config: + * chat / shared deliberately fold native topics into the group session. * * Resolution: per-chat override (`chatReplyModes[chatId]`) wins; otherwise fall - * back to the per-bot default (`regularGroupReplyMode`, default 'chat'). The + * back to the per-bot default (`regularGroupReplyMode`, default 'chat-topic'). The * setting is bot-scoped: Bot A can prefer topic replies in one group while Bot B * or another group stays flat. */ @@ -48,12 +50,12 @@ export function replyModeLabel(mode: ChatReplyMode): 'chat' | 'topic' | 'new-top return 'chat'; } -/** Per-bot default regular-group mode (`regularGroupReplyMode`, default 'chat'). */ +/** Per-bot default regular-group mode (`regularGroupReplyMode`, default 'chat-topic'). */ function regularGroupDefaultMode(larkAppId: string): ChatReplyMode { try { - return getBot(larkAppId).config.regularGroupReplyMode ?? 'chat'; + return getBot(larkAppId).config.regularGroupReplyMode ?? 'chat-topic'; } catch { - return 'chat'; + return 'chat-topic'; } } diff --git a/test/bot-registry-grant.test.ts b/test/bot-registry-grant.test.ts index 5bc9b95ef..149455745 100644 --- a/test/bot-registry-grant.test.ts +++ b/test/bot-registry-grant.test.ts @@ -123,17 +123,16 @@ describe('bot-registry grant additions', () => { expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'ag4', larkAppSecret: 's', autoGrantRequestCards: 'false' }]))[0].autoGrantRequestCards).toBeUndefined(); }); - it('parses regularGroupReplyMode: keeps chat-topic|new-topic|shared, drops chat/invalid/absent to undefined', () => { + it('parses regularGroupReplyMode: keeps chat|new-topic|shared, drops chat-topic/invalid/absent to undefined', () => { expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1', larkAppSecret: 's', regularGroupReplyMode: 'new-topic' }]))[0].regularGroupReplyMode).toBe('new-topic'); expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1b', larkAppSecret: 's', regularGroupReplyMode: 'shared' }]))[0].regularGroupReplyMode).toBe('shared'); expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1c', larkAppSecret: 's', regularGroupReplyMode: 'topic_alias' }]))[0].regularGroupReplyMode).toBe('shared'); expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1d', larkAppSecret: 's', regularGroupReplyMode: 'topic' }]))[0].regularGroupReplyMode).toBe('shared'); - // chat-topic must SURVIVE the load round-trip — regression for the blocker - // where the per-bot default loader dropped it back to 'chat' on restart. - expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1e', larkAppSecret: 's', regularGroupReplyMode: 'chat-topic' }]))[0].regularGroupReplyMode).toBe('chat-topic'); - expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1f', larkAppSecret: 's', regularGroupReplyMode: 'chat_topic' }]))[0].regularGroupReplyMode).toBe('chat-topic'); - // 'chat' is the default → normalized to undefined so bots.json stays clean. - for (const bad of ['chat', 'bad', true, 1, undefined]) { + // 'chat' must SURVIVE the load round-trip — it is now a NON-default explicit + // opt-out (the per-bot default is 'chat-topic'), so it must persist. + expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg1e', larkAppSecret: 's', regularGroupReplyMode: 'chat' }]))[0].regularGroupReplyMode).toBe('chat'); + // 'chat-topic' is the default → normalized to undefined so bots.json stays clean. + for (const bad of ['chat-topic', 'chat_topic', 'bad', true, 1, undefined]) { const c = parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg2', larkAppSecret: 's', regularGroupReplyMode: bad }])); expect(c[0].regularGroupReplyMode).toBeUndefined(); } @@ -156,15 +155,15 @@ describe('bot-registry grant additions', () => { expect(cfg.repoPickerMode).toBeUndefined(); }); - it('parses p2pMode only as literal chat (else undefined = thread default)', () => { + it('parses p2pMode only as literal thread (else undefined = chat default)', () => { const cfgs = parseBotConfigsFromText(JSON.stringify([ { larkAppId: 'p1', larkAppSecret: 's', p2pMode: 'chat' }, { larkAppId: 'p2', larkAppSecret: 's', p2pMode: 'thread' }, { larkAppId: 'p3', larkAppSecret: 's' }, { larkAppId: 'p4', larkAppSecret: 's', p2pMode: 'invalid' }, ])); - expect(cfgs[0].p2pMode).toBe('chat'); - expect(cfgs[1].p2pMode).toBeUndefined(); // 'thread' normalizes to undefined + expect(cfgs[0].p2pMode).toBeUndefined(); // 'chat' is the default → normalizes to undefined + expect(cfgs[1].p2pMode).toBe('thread'); // 'thread' is the explicit opt-out → persists expect(cfgs[2].p2pMode).toBeUndefined(); expect(cfgs[3].p2pMode).toBeUndefined(); }); diff --git a/test/card-prefs-auto-start.test.ts b/test/card-prefs-auto-start.test.ts index 88bd818f6..edf584d5a 100644 --- a/test/card-prefs-auto-start.test.ts +++ b/test/card-prefs-auto-start.test.ts @@ -63,7 +63,7 @@ describe('card-prefs store — 主动开工 fields', () => { expect(prefs.autoStartOnNewTopic).toBe(false); expect(prefs.codexAppCleanInput).toBe(false); expect(prefs.autoStartOnGroupJoinPrompt).toBe(''); - expect(prefs.regularGroupReplyMode).toBe('chat'); + expect(prefs.regularGroupReplyMode).toBe('chat-topic'); expect(prefs.regularGroupMentionMode).toBe('always'); }); @@ -179,13 +179,14 @@ describe('card-prefs store — 主动开工 fields', () => { autoStartOnGroupJoin: false, autoStartOnGroupJoinPrompt: ' ', autoStartOnNewTopic: false, - regularGroupReplyMode: 'chat', + regularGroupReplyMode: 'chat-topic', }); const disk = readConfig(); expect(disk.autoStartOnGroupJoin).toBeUndefined(); expect(disk.autoStartOnNewTopic).toBeUndefined(); expect(disk.autoStartOnGroupJoinPrompt).toBeUndefined(); + // 'chat-topic' is now the default → cleared to undefined so bots.json stays tidy. expect(disk.regularGroupReplyMode).toBeUndefined(); const cfg = registry.getBot('app_default').config; diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 88fd628f7..a642677ca 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -3759,8 +3759,14 @@ describe('handleCommand', () => { it('renders the picker in a thread-mode DM with a thread-scope target seeded on the /relay message', async () => { // p2p detection is via ds.chatType (authoritative, no Lark API hit). - // Default DM mode is thread: the /relay message seeds a fresh DM 话题, - // so the baked target is thread-scope anchored at the message id. + // Thread mode is now the explicit DM opt-out (default is 'chat'): the + // /relay message seeds a fresh DM 话题, so the baked target is thread-scope + // anchored at the message id. + vi.mocked(getBot).mockImplementation(((id: string = 'app-1') => { + const bot = defaultGetBot(id); + (bot.config as any).p2pMode = 'thread'; + return bot; + }) as any); const { getChatNameAndMode } = await import('../src/im/lark/client.js'); const ds = makeDaemonSession({ session: makeSession({ ownerOpenId: 'ou_sender', chatType: 'p2p' }), diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 614ac720f..7354d9ad7 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -626,7 +626,7 @@ describe('im.message.receive_v1 — forwarded topic clarification coalescing', ( anchor: 'root-existing-topic', })); expect(handlers.handleNewTopic).toHaveBeenCalledWith(p2p, expect.objectContaining({ - anchor: 'msg-p2p-immediate', + anchor: 'chat-p2p-immediate', })); expect(handlers.handleNewTopic).toHaveBeenCalledWith(control, expect.objectContaining({ anchor: 'msg-control-immediate', @@ -1145,13 +1145,25 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { .toEqual({ scope: 'chat', anchor: 'oc_dm' }); }); - it('default (thread) mode: top-level DM → fresh thread-scope anchored on messageId', async () => { + it('default (chat) mode: top-level DM → flat chat-scope anchored on chatId', async () => { + // p2pMode default is now 'chat': the whole DM shares one continuous session. setupBotState({}); - expect(await decideRouting(MY_APP_ID, dm())).toEqual({ scope: 'thread', anchor: 'msg-dm' }); + expect(await decideRouting(MY_APP_ID, dm())).toEqual({ scope: 'chat', anchor: 'oc_dm' }); }); - it('default (thread) mode: DM reply with root_id+thread_id threads into its session (real-thread, unchanged)', async () => { + it('default (chat) mode: DM reply with root_id+thread_id still folds into the SAME chat-scope session', async () => { setupBotState({}); + expect(await decideRouting(MY_APP_ID, dm({ root_id: 'root-dm', thread_id: 'root-dm' }))) + .toEqual({ scope: 'chat', anchor: 'oc_dm' }); + }); + + it('explicit thread mode: top-level DM → fresh thread-scope anchored on messageId', async () => { + setupBotState({ p2pMode: 'thread' }); + expect(await decideRouting(MY_APP_ID, dm())).toEqual({ scope: 'thread', anchor: 'msg-dm' }); + }); + + it('explicit thread mode: DM reply with root_id+thread_id threads into its session (real-thread)', async () => { + setupBotState({ p2pMode: 'thread' }); expect(await decideRouting(MY_APP_ID, dm({ root_id: 'root-dm', thread_id: 'root-dm' }))) .toEqual({ scope: 'thread', anchor: 'root-dm' }); }); @@ -1162,8 +1174,8 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { .toEqual({ scope: 'thread', anchor: 'root-g' }); }); - it('regular-group native topic seed starts an independent session at messageId', async () => { - setupBotState({}); + it('chat-topic mode: native topic seed starts an independent session at messageId', async () => { + setupBotState({ regularGroupReplyMode: 'chat-topic' }); mockGetChatMode.mockResolvedValue('group'); expect(await decideRouting(MY_APP_ID, { message_id: 'msg-topic-root', chat_id: 'oc_group', chat_type: 'group', @@ -1171,8 +1183,8 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { })).toEqual({ scope: 'thread', anchor: 'msg-topic-root' }); }); - it('a later reply in that native topic stays anchored at its root', async () => { - setupBotState({}); + it('chat-topic mode: a later reply in that native topic stays anchored at its root', async () => { + setupBotState({ regularGroupReplyMode: 'chat-topic' }); mockGetChatMode.mockResolvedValue('group'); expect(await decideRouting(MY_APP_ID, { message_id: 'msg-topic-reply', chat_id: 'oc_group', chat_type: 'group', @@ -1180,17 +1192,19 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { })).toEqual({ scope: 'thread', anchor: 'msg-topic-root' }); }); - it.each(['chat', 'shared', 'new-topic', 'chat-topic'] as const)( - 'regular-group native topic seed stays independent in %s mode', - async (mode) => { + it('chat/shared modes: a native topic seed stays chat-scope (folds into the group session, per /reply-mode contract)', async () => { + // The omt_ isolation is gated on chat-topic ONLY. chat (default) and shared + // are documented to fold native topics into the one group session, so the + // seed must NOT escape to thread-scope here. + mockGetChatMode.mockResolvedValue('group'); + for (const mode of ['chat', 'shared'] as const) { setupBotState({ regularGroupReplyMode: mode }); - mockGetChatMode.mockResolvedValue('group'); expect(await decideRouting(MY_APP_ID, { - message_id: `msg-topic-${mode}`, chat_id: 'oc_group', chat_type: 'group', - root_id: undefined, thread_id: `omt_native_${mode}`, - })).toEqual({ scope: 'thread', anchor: `msg-topic-${mode}` }); - }, - ); + message_id: 'msg-topic-root', chat_id: 'oc_group', chat_type: 'group', + root_id: undefined, thread_id: 'omt_native_topic', + })).toEqual({ scope: 'chat', anchor: 'oc_group' }); + } + }); }); describe('isBotMentioned', () => { @@ -2607,13 +2621,12 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { senderOpenId: USER_OPEN_ID, explicitlyMentionedThisBot: true, })); - expect(hookAnchor).toBe('chat-anchor-override'); + expect(hookAnchor).toBe('visible-topic-root'); expect(handlers.isSessionOwner).toHaveBeenCalledWith('vc-receiver-session-1', MY_APP_ID); expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ anchor: 'vc-receiver-session-1', - scope: 'chat', + scope: 'thread', chatId: 'chat-anchor-override', - replyRootId: 'visible-topic-root', larkAppId: MY_APP_ID, })); expect(handlers.handleNewTopic).not.toHaveBeenCalled(); @@ -2699,34 +2712,37 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); - it.each(['chat', 'shared', 'new-topic', 'chat-topic'] as const)( - 'first entry into an unowned native topic stays independent in %s mode', - async (mode) => { - setupBotState({ regularGroupReplyMode: mode, allowedUsers: [USER_OPEN_ID] }); - mockGetChatMode.mockResolvedValue('group'); - const event = makeUserMessageEvent({ - senderOpenId: USER_OPEN_ID, - content: JSON.stringify({ text: '@BotA join this native topic' }), - rootId: `real-topic-root-${mode}`, - threadId: `omt_real_thread_${mode}`, - chatId: 'chat-fallback-3', - chatType: 'group', - mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], - }); - handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-fallback-3'); - mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); + it('folds an @ inside a regular-group topic into the chat-scope session when no thread session owns it', async () => { + // In chat/shared modes, a mentioned reply inside a regular-group topic should + // reuse the group chat-scope context and reply in that same topic, rather + // than spawning a new thread-scope session per topic. (Pinned to 'shared' + // since the per-bot default is now 'chat-topic', which intentionally does + // NOT fold.) + setupBotState({ chatReplyModes: { 'chat-fallback-3': 'shared' }, allowedUsers: [USER_OPEN_ID] }); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA new topic please' }), + rootId: 'real-topic-root', + threadId: 'omt_real_thread', // real Lark thread + chatId: 'chat-fallback-3', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + // Bot owns chat-scope at this chat — but we should NOT re-route into it. + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-fallback-3'); + mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); - await capturedHandlers['im.message.receive_v1'](event); - await flushEventWork(); + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); - expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ - scope: 'thread', - anchor: `real-topic-root-${mode}`, - larkAppId: MY_APP_ID, - })); - expect(handlers.handleThreadReply).not.toHaveBeenCalled(); - }, - ); + expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-fallback-3', + replyRootId: 'real-topic-root', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); it('keeps thread-scope when root_id+thread_id are set and a thread session DOES exist', async () => { // Bot already owns a thread-scope session at this root → continue it. @@ -2797,12 +2813,16 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); - it('shared mode does not absorb an explicitly created native topic seed', async () => { + it('shared mode folds a native topic seed into the group chat-scope session (per /reply-mode contract)', async () => { + // Narrowed behavior: omt_ isolation is chat-topic-only. In shared mode a + // native topic seed must fold into the one group session (the visible reply + // still threads under the seed via replyRootId), NOT spawn an independent + // thread-scope session. setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); const event = makeUserMessageEvent({ senderOpenId: USER_OPEN_ID, - content: JSON.stringify({ text: '@BotA work independently here' }), + content: JSON.stringify({ text: '@BotA work here' }), messageId: 'msg-independent-topic', chatId: 'chat-reply-mode', chatType: 'group', @@ -2814,38 +2834,82 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { await capturedHandlers['im.message.receive_v1'](event); await flushEventWork(); - expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ - scope: 'thread', - anchor: 'msg-independent-topic', + expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-reply-mode', larkAppId: MY_APP_ID, })); - expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); - it('shared mode keeps a native topic reply in its owned seed session', async () => { + it('shared mode: a root+thread reply into a bot-OWNED thread session is NOT folded to the lobby (seed-helper guard)', async () => { + // Regression for the pre-existing reply-as-seed hazard in + // maybeApplySharedTopicSeed: a bot that already owns a thread-scope session + // (e.g. created via /t, a mode flip, or restore/adopt) receiving a + // root_id+thread_id reply must CONTINUE that thread session, never get + // re-folded into the group lobby. maybeFold bows out via ownsThreadSession, + // and the seed helper's `root_id && thread_id` guard prevents the second fold. setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); - const event = makeUserMessageEvent({ + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'prior-thread-root'); + const reply = makeUserMessageEvent({ senderOpenId: USER_OPEN_ID, - content: JSON.stringify({ text: '@BotA continue the independent work' }), - rootId: 'msg-independent-topic', - messageId: 'msg-independent-reply', + content: JSON.stringify({ text: '@BotA continue in my thread' }), + rootId: 'prior-thread-root', + messageId: 'msg-reply-into-owned-thread', chatId: 'chat-reply-mode', chatType: 'group', - threadId: 'omt_independent_topic', + threadId: 'prior-thread-root', mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], }); - handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'msg-independent-topic'); - await capturedHandlers['im.message.receive_v1'](event); + await capturedHandlers['im.message.receive_v1'](reply); await flushEventWork(); - expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ + expect(handlers.handleThreadReply).toHaveBeenCalledWith(reply, expect.objectContaining({ scope: 'thread', - anchor: 'msg-independent-topic', + anchor: 'prior-thread-root', larkAppId: MY_APP_ID, })); + // Crucially NOT re-folded into the group chat-scope session. expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalledWith(reply, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-reply-mode', + })); + }); + + it('chat-topic mode: a native topic seed spawns an independent thread session at messageId', async () => { + // The positive counterpart: in chat-topic mode the pure topic-root seed + // (thread_id only, no root_id) must isolate — fixing the gap where the + // topic's opening message previously folded into the group lobby. + setupBotState({ chatReplyModes: { 'chat-reply-mode': 'chat-topic' }, allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + // Bot owns the group lobby, NOT the topic — must not pull the seed into it. + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-reply-mode'); + mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); + const seed = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA new native topic root' }), + messageId: 'msg-ct-seed', + chatId: 'chat-reply-mode', + chatType: 'group', + threadId: 'omt_ct_seed', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + + await capturedHandlers['im.message.receive_v1'](seed); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(seed, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-ct-seed', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalledWith(seed, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-reply-mode', + })); }); it('shared thread-contained @ reuses the chat session and replies in the existing topic', async () => { @@ -3464,8 +3528,10 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleThreadReply).not.toHaveBeenCalled(); }); - it('default chat-mode @ inside a regular-group topic reuses the group chat session', async () => { - setupBotState({ allowedUsers: [USER_OPEN_ID] }); + it('chat mode @ inside a regular-group topic reuses the group chat session', async () => { + // Pinned to explicit 'chat' (the per-bot default is now 'chat-topic', which + // keeps native topics independent — see the chat-topic tests above). + setupBotState({ regularGroupReplyMode: 'chat', allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-default'); const event = makeUserMessageEvent({ diff --git a/test/relay-target-routing.test.ts b/test/relay-target-routing.test.ts index 3085703f7..52f002319 100644 --- a/test/relay-target-routing.test.ts +++ b/test/relay-target-routing.test.ts @@ -23,17 +23,19 @@ describe('resolveRelayTargetRouting', () => { beforeEach(() => { vi.clearAllMocks(); resolveRegularGroupModeMock.mockReturnValue('chat'); - getBotMock.mockReturnValue({ config: {} }); // default: thread-mode DM + getBotMock.mockReturnValue({ config: {} }); // default: chat-mode DM (p2pMode unset) }); const base = { larkAppId: 'cli_app', chatId: 'oc_chat' }; - it('DM thread mode (default) top-level → thread-scope seeded on the /relay message', () => { + it('DM thread mode (explicit) top-level → thread-scope seeded on the /relay message', () => { + getBotMock.mockReturnValue({ config: { p2pMode: 'thread' } }); const r = resolveRelayTargetRouting({ ...base, chatMode: 'p2p', message: { messageId: 'om_m' } }); expect(r).toEqual({ scope: 'thread', anchor: 'om_m' }); }); - it('DM thread mode in-thread reply → thread-scope anchored at rootId (lands in that DM 话题)', () => { + it('DM thread mode (explicit) in-thread reply → thread-scope anchored at rootId (lands in that DM 话题)', () => { + getBotMock.mockReturnValue({ config: { p2pMode: 'thread' } }); const r = resolveRelayTargetRouting({ ...base, chatMode: 'p2p', @@ -58,10 +60,10 @@ describe('resolveRelayTargetRouting', () => { expect(r).toEqual({ scope: 'chat', anchor: 'oc_chat' }); }); - it('DM with unregistered bot (getBot throws) falls back to thread mode', () => { + it('DM with unregistered bot (getBot throws) falls back to chat mode (new default)', () => { getBotMock.mockImplementation(() => { throw new Error('bot not found'); }); const r = resolveRelayTargetRouting({ ...base, chatMode: 'p2p', message: { messageId: 'om_m' } }); - expect(r).toEqual({ scope: 'thread', anchor: 'om_m' }); + expect(r).toEqual({ scope: 'chat', anchor: 'oc_chat' }); }); it('real-thread reply → thread-scope anchored at rootId (any group type)', () => { diff --git a/test/reply-mode-command.test.ts b/test/reply-mode-command.test.ts index 05b215867..755d73137 100644 --- a/test/reply-mode-command.test.ts +++ b/test/reply-mode-command.test.ts @@ -87,16 +87,18 @@ describe('tryHandleReplyModeCommand — DM (p2p) session mode', () => { expect(handled).toBe(false); }); - it('DM `/reply-mode chat` (owner) → applyConfigField p2pMode=chat + dm_updated', async () => { + it('DM `/reply-mode chat` (owner) → clears p2pMode to default (null) + dm_updated', async () => { + // chat is now the DM default → clearing the field (null) selects it. const handled = await tryHandleReplyModeCommand(APP, msg('/reply-mode chat', 'p2p'), USER, true); expect(handled).toBe(true); - expect(mockApplyConfigField).toHaveBeenCalledWith(APP, expect.objectContaining({ key: 'p2pMode' }), 'chat'); + expect(mockApplyConfigField).toHaveBeenCalledWith(APP, expect.objectContaining({ key: 'p2pMode' }), null); expect(lastReply()).toBe('cmd.reply_mode.dm_updated'); }); - it('DM `/reply-mode topic` (owner) → clears p2pMode to default (null) + dm_updated', async () => { + it('DM `/reply-mode topic` (owner) → applyConfigField p2pMode=thread + dm_updated', async () => { + // topic (per-message DM session) is now the explicit opt-out → persists 'thread'. await tryHandleReplyModeCommand(APP, msg('/reply-mode topic', 'p2p'), USER, true); - expect(mockApplyConfigField).toHaveBeenCalledWith(APP, expect.objectContaining({ key: 'p2pMode' }), null); + expect(mockApplyConfigField).toHaveBeenCalledWith(APP, expect.objectContaining({ key: 'p2pMode' }), 'thread'); expect(lastReply()).toBe('cmd.reply_mode.dm_updated'); }); From c493263f0984af5a6aefd3c8cbf3abf36a93b80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 08:42:17 -0700 Subject: [PATCH 3/4] =?UTF-8?q?docs(lark):=20=E4=BF=AE=E6=AD=A3=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=AF=9D=E9=A2=98=E9=9A=94=E7=A6=BB=E6=96=87=E6=A1=88?= =?UTF-8?q?=20+=20=E8=A1=A5=20new-topic=20seed=20=E7=9F=A9=E9=98=B5?= =?UTF-8?q?=E6=A0=BC(=E5=A4=8D=E5=AE=A1=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 复审 P2(纯文案/测试,无逻辑改动): 1. 帮助文案纠错:此前误称「仅 chat-topic 隔离原生话题」,但 new-topic 的 seed(经 regularGroupRouting 进 thread)与 reply(被 mode==='new-topic' 挡在 fold 外)实际也保持独立。改为准确表述:chat/shared 折进群会话, chat-topic 与 new-topic 都让原生话题独立(chat-topic 的独特点=顶层平铺、 仅原生话题独立)。涉及中英 /reply-mode usage + dashboard regularGroupModeHelp。 2. 补 4 模式矩阵缺格:新增「new-topic native seed → thread-scope via regularGroupRouting(source=regular-group-thread,非 omt_ 分支)」用例, 锁死「走 regularGroupRouting 但仍 thread」语义,防未来 omt_ 门控改动 误伤 new-topic seed。 3. 清理默认翻转遗留的旧注释/标记:card-builder p2p「thread(默认)」、 relay-target-routing 规则 1/3 默认标注、dashboard.ts 代理注释、 event-dispatcher regularGroupRouting「chat is flat default」、一条测试 注释 unset(chat)→unset(chat-topic);/botconfig p2p chat 选项中英补「默认」 标记,与 dashboard 一致。 测试: event-dispatcher 246/246(+1 new-topic seed 格)、blast-radius 4 文件 401/401;tsc/build 通过。运行逻辑零改动。 Co-Authored-By: Riff --- src/dashboard.ts | 4 ++-- src/dashboard/web/i18n.ts | 4 ++-- src/i18n/en.ts | 4 ++-- src/i18n/zh.ts | 4 ++-- src/im/lark/card-builder.ts | 2 +- src/im/lark/event-dispatcher.ts | 6 +++--- src/im/lark/relay-target-routing.ts | 8 ++++---- test/event-dispatcher.test.ts | 20 +++++++++++++++++--- 8 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index efede5beb..b612de4fd 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -4615,8 +4615,8 @@ const server = createServer(async (req, res) => { } // PUT /api/bots/:appId/p2p-mode — proxy to that bot's daemon. Body - // `{ p2pMode: 'chat' | 'thread' }` ('chat' = flat continuous DM session; - // anything else clears back to the per-message thread default). + // `{ p2pMode: 'chat' | 'thread' }` ('thread' = per-message DM session; + // anything else clears back to the flat continuous chat default). let mBotP2pMode: RegExpMatchArray | null; if (req.method === 'PUT' && (mBotP2pMode = url.pathname.match(/^\/api\/bots\/([^/]+)\/p2p-mode$/))) { const appId = decodeURIComponent(mBotP2pMode[1]); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index e71510be1..4ac42616a 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1810,7 +1810,7 @@ const zh: DashboardMessages = { 'botDefaults.regularGroupModeChatTopic': 'chat-topic(顶层平铺连续会话,原生话题各自独立会话,默认)', 'botDefaults.regularGroupModeNewTopic': 'new-topic(每条顶层 @ 各开独立话题与会话)', 'botDefaults.regularGroupModeShared': 'shared(话题模式但复用同一个 session)', - 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话(原生话题也并进群会话);chat-topic=顶层连续、群内原生话题各自独立会话(默认);new-topic=每次 @ 新建话题与会话;shared=顶层话题展示但共享会话(原生话题也并进)。仅 chat-topic 会隔离用户创建的原生话题,遵循本配置。可被群级 /reply-mode 覆盖。', + 'botDefaults.regularGroupModeHelp': '普通群顶层 @ 的开会话方式:chat=整群连续会话(原生话题也并进群会话);chat-topic=顶层连续、群内原生话题各自独立会话(默认);new-topic=每次 @ 新建话题与会话;shared=顶层话题展示但共享会话(原生话题也并进)。原生话题在 chat-topic 与 new-topic 下各自独立、在 chat / shared 下并进群会话。可被群级 /reply-mode 覆盖。', 'botDefaults.mentionMode': '群聊 @ 策略', 'botDefaults.mentionModeAlways': '都需要 @(默认)', 'botDefaults.mentionModeTopic': '仅话题内不需要 @', @@ -3808,7 +3808,7 @@ const en: DashboardMessages = { 'botDefaults.regularGroupModeChatTopic': 'chat-topic (flat continuous at top level, native topics each get their own session, default)', 'botDefaults.regularGroupModeNewTopic': 'new-topic (each top-level @ opens its own topic & session)', 'botDefaults.regularGroupModeShared': 'shared (topic display, reusing one session)', - 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session (native topics fold in too); chat-topic = flat at top level, native topics each get their own session (default); new-topic = each @ opens a topic and session; shared = top-level topic UI with a shared session (native topics fold in too). Only chat-topic isolates user-created native topics, honoring this setting. Chat-level /reply-mode can override it.', + 'botDefaults.regularGroupModeHelp': 'Regular group mode for top-level @mentions: chat = one continuous group session (native topics fold in too); chat-topic = flat at top level, native topics each get their own session (default); new-topic = each @ opens a topic and session; shared = top-level topic UI with a shared session (native topics fold in too). Native topics run their own session under chat-topic and new-topic, and fold into the group session under chat / shared. Chat-level /reply-mode can override it.', 'botDefaults.mentionMode': 'Group @ policy', 'botDefaults.mentionModeAlways': 'Always require @ (default)', 'botDefaults.mentionModeTopic': 'No @ needed inside topics', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6efbe3c23..e759d3700 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -213,7 +213,7 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ Group reply mode updated to: {mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode only works in regular groups and DMs; topic groups need no setting (they are already threaded).', 'cmd.reply_mode.owner_only': '⚠️ Only owner/allowedUsers can change the session mode.', - 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat replies reusing the group session (native topics fold in too); chat-topic = flat at top level, but each native topic runs its own independent session (default); topic = topic display while reusing the same group session (shared is a compatibility alias); new-topic = each @ opens a fresh topic + its own session. Only chat-topic isolates user-created native topics.', + 'cmd.reply_mode.usage': 'Usage: /reply-mode chat | chat-topic | topic | new-topic\nchat = flat replies reusing the group session (native topics fold in too); chat-topic = flat at top level, but each native topic runs its own independent session (default); topic = topic display while reusing the same group session (shared is a compatibility alias); new-topic = each @ opens a fresh topic + its own session. Native topics run their own session under chat-topic and new-topic; they fold into the group session under chat / shared.', 'cmd.reply_mode.failed': '⚠️ Failed to update reply mode: {reason}', 'cmd.reply_mode.dm_status': 'Current DM session mode: {mode}\nCommands: /reply-mode chat | topic', 'cmd.reply_mode.dm_updated': '✅ DM session mode updated to: {mode}', @@ -392,7 +392,7 @@ export const messages: Record = { 'card.config.quota_off': 'Unlimited', 'card.config.p2p.placeholder': 'DM mode', 'card.config.p2p.thread': '🧵 thread (separate session/DM)', - 'card.config.p2p.chat': '💬 chat (continuous session)', + 'card.config.p2p.chat': '💬 chat (continuous session, default)', 'config.label.disableStreamingCard': 'Disable live card', 'config.label.silentTurnReactions': 'Disable status reactions', 'config.label.writableTerminalLinkInCard': 'Writable terminal in card', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index edb1cca6c..c6476e3e5 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -216,7 +216,7 @@ export const messages: Record = { 'cmd.reply_mode.updated': '✅ 已切换当前群回复模式为:{mode}', 'cmd.reply_mode.unsupported': '⚠️ /reply-mode 仅支持普通群与私聊;话题群无需设置(本就是话题)。', 'cmd.reply_mode.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改会话模式。', - 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=群内平铺回复且复用群会话(连原生话题也并进这个会话);chat-topic=顶层平铺复用群会话,但群里原生话题各自独立会话(默认);topic=回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次 @ 都开一个独立话题+独立会话。仅 chat-topic 会隔离用户创建的原生话题。', + 'cmd.reply_mode.usage': '用法:/reply-mode chat|chat-topic|topic|new-topic\nchat=群内平铺回复且复用群会话(连原生话题也并进这个会话);chat-topic=顶层平铺复用群会话,但群里原生话题各自独立会话(默认);topic=回复落在话题里但复用同一个群 session(shared 是兼容别名);new-topic=每次 @ 都开一个独立话题+独立会话。原生话题在 chat-topic 与 new-topic 下各自独立,在 chat / shared 下并进群会话。', 'cmd.reply_mode.failed': '⚠️ 切换失败:{reason}', 'cmd.reply_mode.dm_status': '当前私聊会话模式:{mode}\n可用命令:/reply-mode chat|topic', 'cmd.reply_mode.dm_updated': '✅ 已切换私聊会话模式为:{mode}', @@ -395,7 +395,7 @@ export const messages: Record = { 'card.config.quota_off': '不限', 'card.config.p2p.placeholder': '私聊模式', 'card.config.p2p.thread': '🧵 thread(每条 DM 独立会话)', - 'card.config.p2p.chat': '💬 chat(连续单聊会话)', + 'card.config.p2p.chat': '💬 chat(连续单聊会话,默认)', 'config.label.disableStreamingCard': '关闭实时卡片', 'config.label.silentTurnReactions': '关闭状态 reaction', 'config.label.writableTerminalLinkInCard': '卡内嵌可写终端', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index abe3e115d..51ae0be10 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -71,7 +71,7 @@ export function buildConfigCard(data: ConfigCardData, locale?: Locale): string { runSelects.push(configSelect('lang', data.lang ?? CONFIG_UNSET, [{ text: def, value: CONFIG_UNSET }, { text: '中文 (zh)', value: 'zh' }, { text: 'English (en)', value: 'en' }], { action: 'config_set', field: 'lang', ...locVal })); - // 私聊单聊模式:thread(默认,每条 DM 独立会话)| chat(扁平连续会话)。thread 与 + // 私聊单聊模式:chat(默认,扁平连续会话)| thread(每条 DM 独立会话)。chat 与 // 未设等价,故 chat 选项用 unset 哨兵:选它即清字段、回默认(扁平连续 DM), // 避免把字面 'chat' 写进 bots.json(与 dashboard 下拉一致,/botconfig get 重启 // 前后一致)。只有显式 'thread'(每条 DM 独立)才是需要落盘的值。 diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 36c4bc3d2..d801c1bcf 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1774,9 +1774,9 @@ type RoutingDecision = { function regularGroupRouting(larkAppId: string, messageId: string, chatId: string): RoutingDecision { // Only `new-topic` forks a fresh thread-scope session for a TOP-LEVEL @. // `shared` stays chat-scope here (the topic fold happens post-routing, see - // maybeApplySharedTopicSeed); `chat` is the flat default; `chat-topic` is also - // flat at top level (it only diverges for native topics, where the fold is - // skipped — see maybeFoldMentionedRegularGroupThreadToChat). resolveRegularGroupMode + // maybeApplySharedTopicSeed); `chat` is flat top-level; `chat-topic` (the + // default) is also flat at top level (it only diverges for native topics, + // where the fold is skipped — see maybeFoldMentionedRegularGroupThreadToChat). resolveRegularGroupMode // is the single decision point so the modes never both fire. if (resolveRegularGroupMode(larkAppId, chatId) === 'new-topic') { return { scope: 'thread', anchor: messageId, source: 'regular-group-thread' }; diff --git a/src/im/lark/relay-target-routing.ts b/src/im/lark/relay-target-routing.ts index b73b30815..f2ab9700e 100644 --- a/src/im/lark/relay-target-routing.ts +++ b/src/im/lark/relay-target-routing.ts @@ -11,14 +11,14 @@ * in an independent 话题 instead (anchored on the `/relay` message). * * Rules (in order): - * 1. p2p + p2pMode 'chat' → chat-scope, anchor = chatId (扁平连续 DM; checked - * BEFORE the real-thread branch — same precedence as decideRouting, so a - * `/relay` typed inside a leftover DM thread still lands in the flat + * 1. p2p + p2pMode 'chat' (default) → chat-scope, anchor = chatId (扁平连续 DM; + * checked BEFORE the real-thread branch — same precedence as decideRouting, + * so a `/relay` typed inside a leftover DM thread still lands in the flat * session rather than forking a thread-scope target) * 2. real thread reply → thread-scope, anchor = message.rootId * (`threadId && rootId`; covers 话题群 thread replies, DM 话题内回复, and * any group's in-thread reply) - * 3. p2p (thread default) → thread-scope, anchor = message.messageId (the + * 3. p2p (explicit thread) → thread-scope, anchor = message.messageId (the * `/relay` message seeds a fresh DM 话题 — same shape as a top-level DM * message in decideRouting) * 4. 话题群 top-level → thread-scope, anchor = message.messageId (seeds 话题) diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 7354d9ad7..4e8d0834b 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -1193,8 +1193,8 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { }); it('chat/shared modes: a native topic seed stays chat-scope (folds into the group session, per /reply-mode contract)', async () => { - // The omt_ isolation is gated on chat-topic ONLY. chat (default) and shared - // are documented to fold native topics into the one group session, so the + // The omt_ isolation is gated on chat-topic ONLY. chat and shared are + // documented to fold native topics into the one group session, so the // seed must NOT escape to thread-scope here. mockGetChatMode.mockResolvedValue('group'); for (const mode of ['chat', 'shared'] as const) { @@ -1205,6 +1205,20 @@ describe('decideRouting — p2p p2pMode (thread | chat)', () => { })).toEqual({ scope: 'chat', anchor: 'oc_group' }); } }); + + it('new-topic mode: a native topic seed is thread-scope via regularGroupRouting (not the omt_ branch)', async () => { + // new-topic forks a fresh thread-scope session for EVERY top-level message, + // so a native topic seed also lands thread-scope — but via regularGroupRouting + // (source=regular-group-thread), NOT the chat-topic-gated omt_ branch. This + // locks the "goes through regularGroupRouting yet still thread" semantic so a + // future omt_ gating change can't silently reroute new-topic seeds. + setupBotState({ regularGroupReplyMode: 'new-topic' }); + mockGetChatMode.mockResolvedValue('group'); + expect(await decideRouting(MY_APP_ID, { + message_id: 'msg-nt-seed', chat_id: 'oc_group', chat_type: 'group', + root_id: undefined, thread_id: 'omt_native_topic', + })).toEqual({ scope: 'thread', anchor: 'msg-nt-seed' }); + }); }); describe('isBotMentioned', () => { @@ -2039,7 +2053,7 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { // 的 `ctx.scope === 'chat' || source === 'regular-group-thread'` 两条件全 false // → 绕过 vetting → 静默 spawn 一个 thread-scope 会话。这条用例锁死「不能绕」。 setupBotState({ allowedUsers: ['ou_owner'] }); // 受限态:gate 生效 - mockGetChatMode.mockResolvedValueOnce('group'); // 普通群, regularGroupReplyMode unset(chat) → source=regular-group-chat + mockGetChatMode.mockResolvedValueOnce('group'); // 普通群, regularGroupReplyMode unset(chat-topic) → 顶层 @ 仍走 regularGroupRouting → source=regular-group-chat mockReadFileSync.mockReturnValue('{}'); // empty cross-ref → unknown peer const event = makeBotMessageEvent({ senderOpenId: OTHER_BOT_OPEN_ID, From da7673e8c31a0b1f1e59a1bb666ddd292b752e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 08:47:58 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(lark):=20=E4=BF=AE=E6=AD=A3=20relay-ta?= =?UTF-8?q?rget-routing=20=E5=87=BD=E6=95=B0=E5=86=85=E6=97=A7=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=B3=A8=E9=87=8A(=E5=A4=8D=E5=AE=A1=20P2=20=E6=94=B6?= =?UTF-8?q?=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay-target-routing.ts:62 的分支内注释仍写「私聊默认(thread 模式)」,但默认 翻转后该分支只有 p2pMode 显式为 'thread' 才可达(默认 chat 已在规则 1 的 `!== 'thread'` 判据返回)。改为「私聊显式 thread 模式」,与文件顶部规则块及 event-dispatcher 的平行注释一致。纯注释,零逻辑/测试改动。 Co-Authored-By: Riff --- src/im/lark/relay-target-routing.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/im/lark/relay-target-routing.ts b/src/im/lark/relay-target-routing.ts index f2ab9700e..e20b3d8cb 100644 --- a/src/im/lark/relay-target-routing.ts +++ b/src/im/lark/relay-target-routing.ts @@ -59,8 +59,9 @@ export function resolveRelayTargetRouting(input: { return { scope: 'thread', anchor: message.rootId }; } - // 私聊默认(thread 模式)顶层消息:/relay 消息本身种一个新 DM 话题 — 跟 - // decideRouting 对 top-level DM 的处理同款。 + // 私聊显式 thread 模式顶层消息:/relay 消息本身种一个新 DM 话题 — 跟 + // decideRouting 对 top-level DM 的处理同款。(默认 chat 已在上面的规则 1 返回, + // 走到这里说明 p2pMode 显式为 thread。) if (chatMode === 'p2p') { return { scope: 'thread', anchor: message.messageId }; }