diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 44726828..7d36ce7b 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -173,6 +173,13 @@ export interface BotConfig { * Default (undefined) = passive. */ autoStartOnNewTopic?: boolean; + /** + * 主动开工 — 场景②扩展. When true, bot-originated top-level messages in a + * topic group may also trigger {@link autoStartOnNewTopic}. Default false: + * only human-sent new topic seeds auto-start, preserving the safer legacy + * behavior and avoiding unexpected bot loops. + */ + autoStartOnNewTopicFromBots?: boolean; /** * Per-bot voice-engine override for the voice-summary feature. Merged OVER * the global `voice` block in ~/.botmux/config.json (per-bot wins field by @@ -584,6 +591,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { ? entry.autoStartOnGroupJoinPrompt : undefined, autoStartOnNewTopic: entry.autoStartOnNewTopic === true || undefined, + autoStartOnNewTopicFromBots: entry.autoStartOnNewTopicFromBots === true || undefined, voice, }); } diff --git a/src/core/auto-start.ts b/src/core/auto-start.ts index b2ae33f5..a85be2ce 100644 --- a/src/core/auto-start.ts +++ b/src/core/auto-start.ts @@ -19,6 +19,8 @@ export interface AutoStartPrefs { autoStartOnGroupJoinPrompt: string; /** 场景②: auto-start on every new topic in a topic group (no @ required). */ autoStartOnNewTopic: boolean; + /** 场景②: also auto-start when the new topic seed was sent by another bot. */ + autoStartOnNewTopicFromBots: boolean; } /** @@ -37,6 +39,10 @@ export interface AutoStartPrefs { */ export function shouldAutoStartOnNewTopic(opts: { enabled: boolean; + /** Whether bot-originated topic seeds are allowed to trigger auto-start. */ + includeBotMessages?: boolean; + /** True when the inbound message was sent by another bot/app. */ + fromBot?: boolean; scope: 'thread' | 'chat'; anchor: string; messageId: string; @@ -45,6 +51,7 @@ export function shouldAutoStartOnNewTopic(opts: { }): boolean { return ( opts.enabled && + (!opts.fromBot || opts.includeBotMessages === true) && opts.chatType === 'group' && opts.scope === 'thread' && opts.anchor === opts.messageId && diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index eaa3a25c..387ec135 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -617,6 +617,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { autoStartOnGroupJoin: cardPrefs.autoStartOnGroupJoin, autoStartOnGroupJoinPrompt: cardPrefs.autoStartOnGroupJoinPrompt, autoStartOnNewTopic: cardPrefs.autoStartOnNewTopic, + autoStartOnNewTopicFromBots: cardPrefs.autoStartOnNewTopicFromBots, restrictGrantCommands: grantPrefs.restrictGrantCommands, messageQuotaDefaultLimit: grantPrefs.messageQuotaDefaultLimit, }); @@ -628,14 +629,14 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); let body: { disableStreamingCard?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown; - autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown; + autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown; autoStartOnNewTopicFromBots?: unknown; }; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } const patch: { disableStreamingCard?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; - autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; + autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; autoStartOnNewTopicFromBots?: boolean; } = {}; if (typeof body.disableStreamingCard === 'boolean') patch.disableStreamingCard = body.disableStreamingCard; if (typeof body.writableTerminalLinkInCard === 'boolean') patch.writableTerminalLinkInCard = body.writableTerminalLinkInCard; @@ -643,6 +644,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { if (typeof body.autoStartOnGroupJoin === 'boolean') patch.autoStartOnGroupJoin = body.autoStartOnGroupJoin; if (typeof body.autoStartOnGroupJoinPrompt === 'string') patch.autoStartOnGroupJoinPrompt = body.autoStartOnGroupJoinPrompt; if (typeof body.autoStartOnNewTopic === 'boolean') patch.autoStartOnNewTopic = body.autoStartOnNewTopic; + if (typeof body.autoStartOnNewTopicFromBots === 'boolean') patch.autoStartOnNewTopicFromBots = body.autoStartOnNewTopicFromBots; if (Object.keys(patch).length === 0) return jsonRes(res, 400, { ok: false, error: 'no_valid_fields' }); const r = await cardPrefsStore.updateBotCardPrefs(cachedLarkAppId, patch); diff --git a/src/dashboard.ts b/src/dashboard.ts index 5fee181f..c6f30369 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -793,6 +793,7 @@ const server = createServer(async (req, res) => { autoStartOnGroupJoin: j.autoStartOnGroupJoin === true, autoStartOnGroupJoinPrompt: typeof j.autoStartOnGroupJoinPrompt === 'string' ? j.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: j.autoStartOnNewTopic === true, + autoStartOnNewTopicFromBots: j.autoStartOnNewTopicFromBots === true, restrictGrantCommands: j.restrictGrantCommands === true, messageQuotaDefaultLimit: typeof j.messageQuotaDefaultLimit === 'number' ? j.messageQuotaDefaultLimit : null, }; diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index 753482cd..006377d8 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -270,6 +270,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { function renderAutoStartControls(b: any): string { const onJoin = b.autoStartOnGroupJoin === true; const onTopic = b.autoStartOnNewTopic === true; + const onTopicFromBots = b.autoStartOnNewTopicFromBots === true; const joinPrompt: string = typeof b.autoStartOnGroupJoinPrompt === 'string' ? b.autoStartOnGroupJoinPrompt : ''; return `

${t('botDefaults.sectionAutoStart')}

@@ -293,6 +294,11 @@ export async function renderBotDefaultsPage(root: HTMLElement) { ${t('botDefaults.autoStartTopic')} ${t('botDefaults.autoStartTopicHelp')} +
@@ -448,6 +454,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { cached.autoStartOnGroupJoin = body.autoStartOnGroupJoin; cached.autoStartOnGroupJoinPrompt = body.autoStartOnGroupJoinPrompt; cached.autoStartOnNewTopic = body.autoStartOnNewTopic; + cached.autoStartOnNewTopicFromBots = body.autoStartOnNewTopicFromBots; } } else { statusEl.textContent = `✗ ${body.error ?? r.status}`; @@ -486,6 +493,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { // ── 主动开工 toggles + 场景① prompt ─────────────────────────────────── const autoJoinCb = card.querySelector('input[data-action=toggle-auto-join]'); const autoTopicCb = card.querySelector('input[data-action=toggle-auto-topic]'); + const autoTopicBotsCb = card.querySelector('input[data-action=toggle-auto-topic-bots]'); const autoJoinPromptEl = card.querySelector('textarea[data-input=autoJoinPrompt]'); const autoJoinPromptSaveBtn = card.querySelector('button[data-action=save-auto-join-prompt]'); const autoStartStatusEl = card.querySelector('[data-auto-start-status]'); @@ -496,9 +504,15 @@ export async function renderBotDefaultsPage(root: HTMLElement) { } if (autoTopicCb) { autoTopicCb.addEventListener('change', () => { + if (autoTopicBotsCb) autoTopicBotsCb.disabled = !autoTopicCb.checked; putCardPref({ autoStartOnNewTopic: autoTopicCb.checked }, autoTopicCb, autoStartStatusEl); }); } + if (autoTopicBotsCb) { + autoTopicBotsCb.addEventListener('change', () => { + putCardPref({ autoStartOnNewTopicFromBots: autoTopicBotsCb.checked }, autoTopicBotsCb, autoStartStatusEl); + }); + } if (autoJoinPromptEl && autoJoinPromptSaveBtn) { autoJoinPromptSaveBtn.addEventListener('click', () => { putCardPref({ autoStartOnGroupJoinPrompt: autoJoinPromptEl.value }, autoJoinPromptSaveBtn, autoStartStatusEl); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index b100d47f..bc8e07cc 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -197,6 +197,8 @@ const zh: DashboardMessages = { 'botDefaults.autoStartJoinPromptSave': '保存 prompt', 'botDefaults.autoStartTopic': '话题群新话题自动开工', 'botDefaults.autoStartTopicHelp': '开启后,在话题群里每当有人新开一个话题,机器人就会自动接入该话题、把首条消息当作任务开始处理,无需 @。仅对话题群生效,普通群不受影响。', + 'botDefaults.autoStartTopicBots': '也监听其他机器人发的新话题', + 'botDefaults.autoStartTopicBotsHelp': '默认只监听真人发起的新话题。开启后,其他机器人在话题群里发的顶层消息也会触发自动开工;仍仅在「话题群新话题自动开工」开启时生效。', 'botDefaults.sectionGrant': '授权与额度', 'botDefaults.restrictGrant': '限制被授权人只能纯对话', 'botDefaults.restrictGrantHelp': '开启后,被 /grant 授权的人(owner 自己不受限)只能发普通对话,所有 slash 命令一律拦截:botmux 自带命令、透传命令、/workflow、/introduce、/t 以及 CLI 原生命令(/help 等)。形如 /path/to/file 的内容不会被误判。', @@ -601,6 +603,8 @@ const en: DashboardMessages = { 'botDefaults.autoStartJoinPromptSave': 'Save prompt', 'botDefaults.autoStartTopic': 'Auto-start on new topics in topic groups', 'botDefaults.autoStartTopicHelp': 'When enabled, in a topic group the bot automatically joins each newly opened topic and starts working on its first message, no @ needed. Topic groups only — regular groups are unaffected.', + 'botDefaults.autoStartTopicBots': 'Also listen to new topics from other bots', + 'botDefaults.autoStartTopicBotsHelp': 'By default only human-created new topics are watched. When enabled, top-level messages sent by other bots in topic groups can also trigger auto-start; still requires auto-start on new topics to be enabled.', 'botDefaults.sectionGrant': 'Authorization & Quota', 'botDefaults.restrictGrant': 'Restrict grantees to plain conversation', 'botDefaults.restrictGrantHelp': 'When enabled, /grant-authorized users (the owner is exempt) can only send plain messages; every slash command is blocked: botmux built-in commands, passthrough commands, /workflow, /introduce, /t, and CLI-native commands (/help, etc.). Text like /path/to/file is not misclassified.', diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 2369c1e3..3229703e 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -820,12 +820,32 @@ export async function decideRouting( ): Promise<{ scope: 'thread' | 'chat'; anchor: string }> { const rootId: string | undefined = message.root_id; const threadId: string | undefined = message.thread_id; - if (rootId && threadId) return { scope: 'thread', anchor: rootId }; const chatType: string = message.chat_type ?? 'group'; const messageId: string = message.message_id; const chatId: string = message.chat_id; + // In some Lark topic-group deliveries (observed especially for messages sent + // by another bot), the top-level topic seed already carries root_id/thread_id. + // Two seed shapes have been seen/returned by Lark APIs: + // 1. root_id/thread_id point back to message_id; + // 2. root_id/thread_id are the same `omt_...` topic id, while message_id is + // the actual seed message id (`om_...`). + // Treat both as the seed itself rather than as a reply to an existing root; + // otherwise auto-start-on-new-topic would miss it because the routing anchor + // would not be the seed message_id. + if (rootId && threadId) { + if ( + rootId === messageId || + threadId === messageId || + message.parent_id === messageId || + (rootId === threadId && rootId.startsWith('omt_')) + ) { + return { scope: 'thread', anchor: messageId }; + } + return { scope: 'thread', anchor: rootId }; + } + // 私聊:每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 Lark DM 的话题 // 化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。 if (chatType === 'p2p') { @@ -899,6 +919,49 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin const chatType = (message.chat_type === 'p2p' ? 'p2p' : 'group') as 'group' | 'p2p'; const messageId = message.message_id; + const maybeAutoStartNewTopic = async (opts: { fromBot: boolean }): Promise => { + const routing = await decideRouting(larkAppId, message); + + // Same stale-cache correction as the human-message branch: if the + // cached chat_mode says topic but Lark now reports group, this is not + // a topic-group seed and must not auto-start. + if ( + routing.scope === 'thread' && + routing.anchor === messageId && + !message.thread_id && + chatType === 'group' + ) { + const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true }); + if (freshMode === 'group') { + routing.scope = 'chat'; + routing.anchor = chatId; + } + } + + const ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false; + const botCfg = getBot(larkAppId).config; + const autoTopic = shouldAutoStartOnNewTopic({ + enabled: botCfg.autoStartOnNewTopic === true, + includeBotMessages: botCfg.autoStartOnNewTopicFromBots === true, + fromBot: opts.fromBot, + scope: routing.scope, + anchor: routing.anchor, + messageId, + chatType, + ownsSession, + }); + if (!autoTopic) return false; + + logger.info( + `[auto-start:新话题] ${chatId.substring(0, 12)} 新话题免@自动开工` + + `${opts.fromBot ? '(bot sender)' : ''} msg=${messageId.substring(0, 12)}`, + ); + const ctx: RoutingContext = { chatId, messageId, chatType, larkAppId, ...routing }; + await serializeByAnchor(ctx.anchor, () => handlers.handleNewTopic(data, ctx)) + .catch(err => logger.error(`Error handling auto-start new topic: ${err}`)); + return true; + }; + // Bot-originated messages — bots historically only post inside threads // (their own thread replies). With chat-scope sessions a bot can also // post top-level (its first reply in a chat-scope group), so we still @@ -931,8 +994,13 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin .catch(err => logger.error(`Error handling message event: ${err}`)); return; } - // Foreign bot: only route on @mention of us. - if (!isBotMentioned(larkAppId, message, undefined)) return; + // Foreign bot: normally only route on @mention of us. Optionally, + // let a bot-sent top-level message seed a new topic-group session + // when the receiving bot explicitly opted in. + if (!isBotMentioned(larkAppId, message, undefined)) { + await maybeAutoStartNewTopic({ fromBot: true }); + return; + } const ctx = await decideRouting(larkAppId, message); // Chat-scope foreign-bot @mention without an existing session: gate to // vetted botmux peers (registered in our bot-openids cross-ref). This @@ -1111,6 +1179,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin // the original ignore. Sender is intentionally not gated (D4). const autoTopic = shouldAutoStartOnNewTopic({ enabled: getBot(larkAppId).config.autoStartOnNewTopic === true, + fromBot: false, scope: autoTopicSeedScope, anchor: autoTopicSeedAnchor, messageId, diff --git a/src/services/card-prefs-store.ts b/src/services/card-prefs-store.ts index a49cf0f5..b2d35ea9 100644 --- a/src/services/card-prefs-store.ts +++ b/src/services/card-prefs-store.ts @@ -26,6 +26,8 @@ export interface BotCardPrefs { autoStartOnGroupJoinPrompt: string; /** 主动开工 — 场景②: auto-start on every new topic in a topic group. */ autoStartOnNewTopic: boolean; + /** 主动开工 — 场景②: also listen to new-topic seeds sent by other bots. */ + autoStartOnNewTopicFromBots: boolean; } /** Current card prefs for a bot (booleans default false, prompt defaults '' when unset). */ @@ -39,6 +41,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { autoStartOnGroupJoin: c.autoStartOnGroupJoin === true, autoStartOnGroupJoinPrompt: typeof c.autoStartOnGroupJoinPrompt === 'string' ? c.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: c.autoStartOnNewTopic === true, + autoStartOnNewTopicFromBots: c.autoStartOnNewTopicFromBots === true, }; } catch { return { @@ -48,6 +51,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { autoStartOnGroupJoin: false, autoStartOnGroupJoinPrompt: '', autoStartOnNewTopic: false, + autoStartOnNewTopicFromBots: false, }; } } @@ -84,6 +88,7 @@ export async function updateBotCardPrefs( apply(entry, 'autoStartOnGroupJoin', patch.autoStartOnGroupJoin); applyStr(entry, 'autoStartOnGroupJoinPrompt', patch.autoStartOnGroupJoinPrompt); apply(entry, 'autoStartOnNewTopic', patch.autoStartOnNewTopic); + apply(entry, 'autoStartOnNewTopicFromBots', patch.autoStartOnNewTopicFromBots); return { write: true, result: { @@ -93,6 +98,7 @@ export async function updateBotCardPrefs( autoStartOnGroupJoin: entry.autoStartOnGroupJoin === true, autoStartOnGroupJoinPrompt: typeof entry.autoStartOnGroupJoinPrompt === 'string' ? entry.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: entry.autoStartOnNewTopic === true, + autoStartOnNewTopicFromBots: entry.autoStartOnNewTopicFromBots === true, }, }; }); @@ -117,10 +123,14 @@ export async function updateBotCardPrefs( if (patch.autoStartOnNewTopic !== undefined) { bot.config.autoStartOnNewTopic = patch.autoStartOnNewTopic || undefined; } + if (patch.autoStartOnNewTopicFromBots !== undefined) { + bot.config.autoStartOnNewTopicFromBots = patch.autoStartOnNewTopicFromBots || undefined; + } logger.info( `[card-prefs:${larkAppId}] disableStreamingCard=${r.result.disableStreamingCard} ` + `writableTerminalLinkInCard=${r.result.writableTerminalLinkInCard} privateCard=${r.result.privateCard} ` + `autoStartOnGroupJoin=${r.result.autoStartOnGroupJoin} autoStartOnNewTopic=${r.result.autoStartOnNewTopic} ` + + `autoStartOnNewTopicFromBots=${r.result.autoStartOnNewTopicFromBots} ` + `autoStartOnGroupJoinPrompt.len=${r.result.autoStartOnGroupJoinPrompt.length}`, ); return { ok: true, prefs: r.result }; diff --git a/test/auto-start.test.ts b/test/auto-start.test.ts index 900931d0..5bd60eeb 100644 --- a/test/auto-start.test.ts +++ b/test/auto-start.test.ts @@ -20,8 +20,13 @@ describe('shouldAutoStartOnNewTopic (场景②)', () => { expect(shouldAutoStartOnNewTopic(base)).toBe(true); }); + it('does not fire for bot-sent topic seeds unless explicitly enabled', () => { + expect(shouldAutoStartOnNewTopic({ ...base, fromBot: true })).toBe(false); + expect(shouldAutoStartOnNewTopic({ ...base, fromBot: true, includeBotMessages: true })).toBe(true); + }); + it('FR-8: does not fire when disabled', () => { - expect(shouldAutoStartOnNewTopic({ ...base, enabled: false })).toBe(false); + expect(shouldAutoStartOnNewTopic({ ...base, enabled: false, fromBot: true, includeBotMessages: true })).toBe(false); }); it('FR-7: does not fire for a regular group (chat-scope, anchor = chatId)', () => { diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index d554caf6..4beb3f08 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -1412,9 +1412,15 @@ describe('im.message.receive_v1 — /t force-topic override', () => { describe('im.message.receive_v1 — 主动开工 场景② (autoStartOnNewTopic)', () => { let handlers: ReturnType; - function setupAutoTopicBot(enabled: boolean) { + function setupAutoTopicBot(enabled: boolean, opts?: { fromBots?: boolean }) { mockGetBot.mockReturnValue({ - config: { larkAppId: MY_APP_ID, larkAppSecret: 'secret', cliId: 'claude-code', autoStartOnNewTopic: enabled }, + config: { + larkAppId: MY_APP_ID, + larkAppSecret: 'secret', + cliId: 'claude-code', + autoStartOnNewTopic: enabled, + autoStartOnNewTopicFromBots: opts?.fromBots, + }, botOpenId: MY_OPEN_ID, // A non-empty allowlist that does NOT include the sender → canTalk(sender) // is false, so an un-@ message deterministically returns 'ignore' (the @@ -1508,6 +1514,129 @@ describe('im.message.receive_v1 — 主动开工 场景② (autoStartOnNewTopic) expect(handlers.handleNewTopic).not.toHaveBeenCalled(); expect(handlers.handleThreadReply).not.toHaveBeenCalled(); }); + + it('话题群新话题由其他机器人发送,扩展开关开 → 自动开工', async () => { + setupAutoTopicBot(true, { fromBots: true }); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeBotMessageEvent({ + senderOpenId: OTHER_BOT_OPEN_ID, + senderType: 'bot', + content: JSON.stringify({ text: '机器人创建的新话题' }), + messageId: 'msg-bot-topic-seed', + chatId: 'chat-topic-bot-1', + chatType: 'group', + rootId: undefined, + threadId: null, + }); + event.message.root_id = undefined as any; + + await capturedHandlers['im.message.receive_v1'](event); + await new Promise(r => setTimeout(r, 0)); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-bot-topic-seed', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('话题群新话题由其他机器人发送且带自指 root/thread_id → 自动开工', async () => { + // Lark may deliver a bot-created topic seed with root_id/thread_id already + // pointing to the seed itself. This is still a new topic, not a reply. + setupAutoTopicBot(true, { fromBots: true }); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeBotMessageEvent({ + senderOpenId: OTHER_BOT_OPEN_ID, + senderType: 'bot', + content: JSON.stringify({ text: '机器人创建的新话题' }), + messageId: 'msg-bot-topic-self-root', + rootId: 'msg-bot-topic-self-root', + threadId: 'msg-bot-topic-self-root', + chatId: 'chat-topic-bot-self-root', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await new Promise(r => setTimeout(r, 0)); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'msg-bot-topic-self-root', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('话题群新话题由其他机器人发送且 root/thread_id 为 omt 话题 id → 自动开工', async () => { + // Lark may also deliver a bot-created topic seed with root_id/thread_id set + // to the `omt_...` topic id rather than to the `om_...` seed message id. + setupAutoTopicBot(true, { fromBots: true }); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeBotMessageEvent({ + senderOpenId: OTHER_BOT_OPEN_ID, + senderType: 'bot', + content: JSON.stringify({ text: '机器人创建的新话题' }), + messageId: 'om_bot_topic_seed_with_omt', + rootId: 'omt_bot_topic_seed_with_omt', + threadId: 'omt_bot_topic_seed_with_omt', + chatId: 'chat-topic-bot-omt-root', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await new Promise(r => setTimeout(r, 0)); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'thread', + anchor: 'om_bot_topic_seed_with_omt', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('话题群其他机器人在已有话题内回复(未 @)→ 不自动开工', async () => { + setupAutoTopicBot(true, { fromBots: true }); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeBotMessageEvent({ + senderOpenId: OTHER_BOT_OPEN_ID, + senderType: 'bot', + content: JSON.stringify({ text: '机器人在已有话题内回复' }), + messageId: 'msg-bot-topic-reply', + rootId: 'root-existing-topic', + threadId: 'root-existing-topic', + chatId: 'chat-topic-bot-reply', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await new Promise(r => setTimeout(r, 0)); + + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('话题群新话题由其他机器人发送,扩展开关关 → 不触发', async () => { + setupAutoTopicBot(true, { fromBots: false }); + mockGetChatMode.mockResolvedValue('topic'); + const event = makeBotMessageEvent({ + senderOpenId: OTHER_BOT_OPEN_ID, + senderType: 'bot', + content: JSON.stringify({ text: '机器人创建的新话题' }), + messageId: 'msg-bot-topic-off', + chatId: 'chat-topic-bot-2', + chatType: 'group', + rootId: undefined, + threadId: null, + }); + event.message.root_id = undefined as any; + + await capturedHandlers['im.message.receive_v1'](event); + await new Promise(r => setTimeout(r, 0)); + + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); }); describe('im.message.receive_v1 — /introduce command', () => {