Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,9 +1019,14 @@ async function maybeApplySharedTopicSeed(input: {
messageId: string;
routing: { scope: 'thread' | 'chat'; anchor: string };
forceTopicApplied?: boolean;
independentTopicSeed?: boolean;
}): Promise<string | undefined> {
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'
Expand All @@ -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
Expand Down Expand Up @@ -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 进程里。
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions test/event-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down