diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 709413d17..6c5ca83b8 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1019,9 +1019,14 @@ 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; + const { larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied, independentTopicSeed } = input; if (forceTopicApplied) return undefined; + // A user explicitly created this Lark topic in an ordinary group. It owns a + // fresh botmux session even when the group's configured reply mode is + // `shared`; shared folding is only for bot-opened aliases/top-level turns. + if (independentTopicSeed) return undefined; if (chatType !== 'group') return undefined; if (resolveRegularGroupMode(larkAppId, chatId) !== 'shared') return undefined; // Seeding a shared topic normally needs an @mention. But under the 'never' @@ -1040,7 +1045,9 @@ async function maybeApplySharedTopicSeed(input: { } /** Compute the scope + anchor for an inbound message: - * - root_id + thread_id → thread-scope, anchor = root_id (real Lark 话题) + * - thread_id → thread-scope: replies anchor at root_id, while + * a user-created topic seed (no root_id yet) + * anchors at its own message_id * - 话题群 + no real thread → thread-scope, anchor = message_id (thread seed) * - p2p + no real thread → thread-scope, anchor = message_id (each DM * top-level message starts a fresh topic; a @@ -1097,7 +1104,13 @@ async function decideRoutingWithSource( return { scope: 'chat', anchor: chatId, source: 'p2p' }; } - if (rootId && threadId) return { scope: 'thread', anchor: rootId, source: 'real-thread' }; + // Existing topic replies are mode-independent and always route by their + // message root. A thread_id-only seed still needs chat-mode classification + // below: topic chats must retain source=topic-chat for auto-start semantics, + // while ordinary-group user-created topics become real-thread sessions. + if (rootId && threadId) { + return { scope: 'thread', anchor: rootId, source: 'real-thread' }; + } // 私聊默认(thread 模式):每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 // Lark DM 的话题化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。 @@ -1110,6 +1123,9 @@ async function decideRoutingWithSource( if (mode === 'topic') { return { scope: 'thread', anchor: messageId, source: 'topic-chat' }; } + if (threadId?.startsWith('omt_')) { + return { scope: 'thread', anchor: messageId, source: 'real-thread' }; + } return regularGroupRouting(larkAppId, messageId, chatId); } @@ -1398,7 +1414,17 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin let ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false; const seedReplyRootId = await maybeApplySharedTopicSeed({ - larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied, + 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/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index cc0764c4d..fc1f6c24c 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -261,6 +261,28 @@ 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('ordinary-group user-created topic seed with thread_id but no root_id gets an isolated session', async () => { + setupBotState({}); + expect(await decideRouting(MY_APP_ID, { + message_id: 'msg-user-topic-seed', + chat_id: 'oc_regular_group', + chat_type: 'group', + root_id: undefined, + thread_id: 'omt_user_topic', + })).toEqual({ scope: 'thread', anchor: 'msg-user-topic-seed' }); + }); + + it('later replies in that user-created topic reuse the seed session anchor', async () => { + setupBotState({}); + expect(await decideRouting(MY_APP_ID, { + message_id: 'msg-user-topic-reply', + chat_id: 'oc_regular_group', + chat_type: 'group', + root_id: 'msg-user-topic-seed', + thread_id: 'omt_user_topic', + })).toEqual({ scope: 'thread', anchor: 'msg-user-topic-seed' }); + }); }); describe('isBotMentioned', () => { @@ -1136,6 +1158,35 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleThreadReply).not.toHaveBeenCalled(); }); + it('starts an isolated session for a user-created topic seed in 普通群 even when chat-scope session exists', async () => { + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA isolated user topic' }), + messageId: 'user-topic-seed', + rootId: undefined, + threadId: 'omt_user_created', + chatId: 'chat-with-flat-session', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + // The group already has a flat chat-scope session, but not one at the + // user-created topic's message root. + handlers.isSessionOwner.mockImplementation( + (anchor: string) => anchor === 'chat-with-flat-session', + ); + mockListChatBotMembers.mockResolvedValue([{ openId: MY_OPEN_ID, name: 'BotA' }]); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'user-topic-seed', + 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. const event = makeUserMessageEvent({ @@ -1205,6 +1256,32 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); + it('shared mode does not absorb a user-created independent 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 independent user topic' }), + messageId: 'msg-independent-shared', + threadId: 'omt_independent_shared', + chatId: 'chat-reply-mode', + chatType: 'group', + 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-shared', + replyRootId: undefined, + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + it('shared thread-contained @ starts a fresh alias on the current message id', async () => { setupBotState({ chatReplyModes: { 'chat-reply-mode': 'shared' }, allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); @@ -2195,6 +2272,28 @@ describe('im.message.receive_v1 — 主动开工 场景② (autoStartOnNewTopic) })); }); + it('话题群新话题 seed 带 omt_* thread_id 且无 root_id 时仍免 @ 自动开工', async () => { + setupAutoTopicBot(true); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '带 thread id 的话题根消息' }), + messageId: 'msg-topic-thread-seed', + threadId: 'omt_topic_chat_seed', + chatId: 'chat-topic-thread-seed', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-topic-thread-seed', + larkAppId: MY_APP_ID, + })); + }); + it('话题群新话题(未 @)开关关 → 不触发 (FR-8)', async () => { setupAutoTopicBot(false); mockGetChatMode.mockResolvedValue('topic');