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', () => {