Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -584,6 +591,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
? entry.autoStartOnGroupJoinPrompt
: undefined,
autoStartOnNewTopic: entry.autoStartOnNewTopic === true || undefined,
autoStartOnNewTopicFromBots: entry.autoStartOnNewTopicFromBots === true || undefined,
voice,
});
}
Expand Down
7 changes: 7 additions & 0 deletions src/core/auto-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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 &&
Expand Down
6 changes: 4 additions & 2 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -628,21 +629,22 @@ 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;
if (typeof body.privateCard === 'boolean') patch.privateCard = body.privateCard;
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);
Expand Down
1 change: 1 addition & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
14 changes: 14 additions & 0 deletions src/dashboard/web/bot-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div class="bd-subsection">
<h4 class="bd-subsection-title">${t('botDefaults.sectionAutoStart')}</h4>
Expand All @@ -293,6 +294,11 @@ export async function renderBotDefaultsPage(root: HTMLElement) {
<strong>${t('botDefaults.autoStartTopic')}</strong>
<small>${t('botDefaults.autoStartTopicHelp')}</small>
</label>
<label class="checkbox-row">
<input type="checkbox" data-action="toggle-auto-topic-bots" ${onTopicFromBots ? 'checked' : ''} ${onTopic ? '' : 'disabled'}>
<strong>${t('botDefaults.autoStartTopicBots')}</strong>
<small>${t('botDefaults.autoStartTopicBotsHelp')}</small>
</label>
<div class="actions">
<span class="oncall-status" data-auto-start-status></span>
</div>
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -486,6 +493,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) {
// ── 主动开工 toggles + 场景① prompt ───────────────────────────────────
const autoJoinCb = card.querySelector<HTMLInputElement>('input[data-action=toggle-auto-join]');
const autoTopicCb = card.querySelector<HTMLInputElement>('input[data-action=toggle-auto-topic]');
const autoTopicBotsCb = card.querySelector<HTMLInputElement>('input[data-action=toggle-auto-topic-bots]');
const autoJoinPromptEl = card.querySelector<HTMLTextAreaElement>('textarea[data-input=autoJoinPrompt]');
const autoJoinPromptSaveBtn = card.querySelector<HTMLButtonElement>('button[data-action=save-auto-join-prompt]');
const autoStartStatusEl = card.querySelector<HTMLSpanElement>('[data-auto-start-status]');
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 的内容不会被误判。',
Expand Down Expand Up @@ -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.',
Expand Down
75 changes: 72 additions & 3 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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<boolean> => {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/services/card-prefs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 {
Expand All @@ -48,6 +51,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs {
autoStartOnGroupJoin: false,
autoStartOnGroupJoinPrompt: '',
autoStartOnNewTopic: false,
autoStartOnNewTopicFromBots: false,
};
}
}
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
},
};
});
Expand All @@ -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 };
Expand Down
7 changes: 6 additions & 1 deletion test/auto-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading