Skip to content
Open
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
84 changes: 65 additions & 19 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { validateWorkingDir } from './core/working-dir.js';
import { resolveSessionContext } from './core/session-marker.js';
import { resolveBotmuxDataDir } from './core/data-dir.js';
import { dashboardSecretPath } from './core/dashboard-secret.js';
import { acceptedDispatchBotAppIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js';
import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, foldableChatSessionAppIds, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget, threadRootForReachability } from './core/dispatch.js';
import { pickTurnReplyTarget } from './core/reply-target.js';
import { recordDispatchRegistryEntry } from './core/dispatch-registry.js';
import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
Expand Down Expand Up @@ -6766,7 +6766,8 @@ async function cmdSend(rest: string[]): Promise<void> {
}

// Register bots so Lark client works
const { registerBot, loadBotConfigs, findOncallChatForAnyBot } = await import('./bot-registry.js');
const { registerBot, loadBotConfigs, findOncallChatForAnyBot, getBot } = await import('./bot-registry.js');
const { resolveRegularGroupMode } = await import('./services/chat-reply-mode-store.js');
try { for (const cfg of loadBotConfigs()) registerBot(cfg); } catch { /* */ }
if (envPinnedRiffBot) { try { registerBot(envPinnedRiffBot); } catch { /* */ } }

Expand All @@ -6778,6 +6779,37 @@ async function cmdSend(rest: string[]): Promise<void> {
// reply_in_thread, otherwise Lark would force every reply into a fresh
// topic — defeating the whole point of chat-scope routing.
const isChatScope = s.scope === 'chat';
// Compute the actual outbound anchor before the advisory guard. A chat-scope
// sender can still reply into a per-turn topic, so sender scope alone does
// not describe which peer sessions are reachable.
const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId });

// Load the sender-scoped bot identity map once. Besides prose @Name
// injection below, it lets the sub-bot hint recognize peers that already
// have an active session in THIS conversation.
let botEntries: BotMentionEntry[] = [];
let crossRef: Record<string, string> = {};
try {
const dataDir = resolveDataDir();
const botInfoPath = join(dataDir, 'bots-info.json');
const parsedBotEntries = existsSync(botInfoPath)
? JSON.parse(readFileSync(botInfoPath, 'utf-8'))
: [];
botEntries = Array.isArray(parsedBotEntries)
? parsedBotEntries.filter((entry): entry is BotMentionEntry =>
!!entry
&& typeof entry === 'object'
&& typeof entry.larkAppId === 'string'
&& (entry.botName === null || typeof entry.botName === 'string'))
: [];
const crossRefPath = join(dataDir, `bot-openids-${appId}.json`);
const parsedCrossRef = existsSync(crossRefPath)
? JSON.parse(readFileSync(crossRefPath, 'utf-8'))
: {};
crossRef = parsedCrossRef && typeof parsedCrossRef === 'object' && !Array.isArray(parsedCrossRef)
? parsedCrossRef
: {};
} catch { /* best-effort identity map */ }

// ── Footgun guard: orchestrator → sub-bot ──
// A dispatched sub-bot's session lives in its sub-topic; @-ing it from the main
Expand All @@ -6791,18 +6823,42 @@ async function cmdSend(rest: string[]): Promise<void> {
if (existsSync(regPath)) dispatchReg = JSON.parse(readFileSync(regPath, 'utf-8'));
} catch { /* no/!corrupt registry → no guard */ }
const dispatchActiveSeeds = new Set<string>();
let allSessions: SessionData[] = [];
if (Object.keys(dispatchReg).length > 0) {
for (const sess of loadSessions().values()) {
if (sess.status === 'active' && sess.scope !== 'chat' && sess.rootMessageId) {
allSessions = [...loadSessions().values()];
for (const sess of allSessions) {
if (sess.status !== 'active') continue;
if (sess.scope !== 'chat' && sess.rootMessageId) {
dispatchActiveSeeds.add(sess.rootMessageId);
}
}
}
// An active chat-scope session can outlive a /reply-mode switch. Verify the
// target bot's current effective mode before assuming mentions still fold
// back into that old session.
const foldableChatAppIds = foldableChatSessionAppIds({
sessions: allSessions,
targetChatId,
outboundMode: sendTarget.mode,
resolveMode: (larkAppId, chatId) => {
getBot(larkAppId); // unknown target bot must fail closed
return resolveRegularGroupMode(larkAppId, chatId);
},
});
const reachableOpenIds = activeConversationBotOpenIds({
sessions: allSessions,
targetChatId,
outboundRootMessageId: threadRootForReachability(sendTarget),
foldableChatAppIds,
botEntries,
crossRef,
});
// Sub-topic seed if `openId` is a dispatched sub-bot in an active topic that is
// NOT reachable in the current conversation; else null. The bot I'm replying to
// here (quoteTargetSenderOpenId) is reachable, so it's never treated as off-topic.
// NOT reachable in the current conversation; else null. Both the bot I'm
// replying to and any peer with an active session at this conversation anchor
// are reachable, so an unrelated old dispatch topic must not be recommended.
const offTopicSubBotSeed = (openId: string): string | null =>
offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds });
offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, reachableOpenIds, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds });
// Explicit --mention / --mention-back of an off-topic sub-bot → block + point to
// the right command (--anyway overrides). Prose @Name injection is filtered
// (dropped, not blocked) at its own site below.
Expand Down Expand Up @@ -6832,9 +6888,8 @@ async function cmdSend(rest: string[]): Promise<void> {
rootMessageId: s.rootMessageId,
title: s.title,
};
// Dispatch helper: top-level / chat-scope send vs reply-in-thread, single
// decision point. Used for file attachments (always plain in chat scope).
const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId });
// Dispatch helper below uses the same precomputed target as the advisory
// guard, so the hint and actual Lark delivery cannot disagree about scope.
const dispatch = async (
content: string,
msgType: string,
Expand Down Expand Up @@ -7060,16 +7115,7 @@ async function cmdSend(rest: string[]): Promise<void> {
// "获取群组中其他机器人和用户@当前机器人的消息"权限),不再走任何本地
// 转发——botmux 历史上为绕过 Lark 不投递跨 bot 事件搞过 signal-file,
// 那套已经在该权限上线后整体下线。
let botEntries: BotMentionEntry[] = [];
let crossRef: Record<string, string> = {};
try {
const dataDir = resolveDataDir();
const botInfoPath = join(dataDir, 'bots-info.json');
botEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : [];
const crossRefPath = join(dataDir, `bot-openids-${appId}.json`);
crossRef = existsSync(crossRefPath)
? JSON.parse(readFileSync(crossRefPath, 'utf-8'))
: {};
// --no-mention 显式不 @ 任何人:跳过正文 @BotName 的自动注入,否则正文里
// 出现的 @名字 仍会被注入成 <at>,破坏 --no-mention 语义、还可能误触发对方
// bot(正是要避免的循环 @)。botEntries/crossRef 仍需加载供 footer 寻址用。
Expand Down
112 changes: 106 additions & 6 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* (cli.ts) performs the actual sendMessage + replyMessage.
*/

import type { SessionReplyTarget } from './reply-target.js';
export { resolveSendTarget } from './reply-target.js';

export interface DispatchBot {
Expand Down Expand Up @@ -224,6 +225,104 @@ export function findSubBotTopic(input: {
return null;
}

/** A quote reply references a root but does not enter that root's thread. */
export function threadRootForReachability(target: SessionReplyTarget): string | undefined {
return target.mode === 'thread' ? target.rootMessageId : undefined;
}

type ReachabilitySession = {
status: 'active' | 'closed';
scope?: 'thread' | 'chat';
chatId: string;
rootMessageId: string;
larkAppId?: string;
};

/**
* Identify active chat sessions whose bot is still configured to fold a
* mention back into that shared session. Mode lookup failures fail closed.
*/
export function foldableChatSessionAppIds(input: {
sessions: Iterable<ReachabilitySession>;
targetChatId: string;
outboundMode: SessionReplyTarget['mode'];
resolveMode: (larkAppId: string, chatId: string) => 'chat' | 'shared' | 'new-topic' | 'chat-topic' | undefined;
}): Set<string> {
const appIds = new Set<string>();
for (const session of input.sessions) {
if (session.status !== 'active'
|| session.scope !== 'chat'
|| !session.larkAppId
|| session.chatId !== input.targetChatId) continue;
try {
const mode = input.resolveMode(session.larkAppId, input.targetChatId);
// chat-topic reuses the chat session for top-level/quote delivery, but
// deliberately keeps a real Lark topic isolated. new-topic never folds
// a new mention back into a leftover chat session.
if (mode === 'chat'
|| mode === 'shared'
|| (mode === 'chat-topic' && input.outboundMode !== 'thread')) {
appIds.add(session.larkAppId);
}
} catch { /* unknown bot/mode → retain advisory */ }
}
return appIds;
}

/**
* Resolve sender-scoped open_ids for bots that already have an active session
* at the current conversation anchor. These peers are reachable here, so an
* older dispatch record for the same bot must not be presented as the target.
*/
export function activeConversationBotOpenIds(input: {
sessions: Iterable<ReachabilitySession>;
targetChatId: string;
outboundRootMessageId?: string;
foldableChatAppIds?: Set<string>;
botEntries: Array<{ larkAppId: string; botName: string | null }>;
crossRef: Record<string, string>;
}): Set<string> {
const activeAppIds = new Set<string>();
for (const session of input.sessions) {
if (session.status !== 'active'
|| !session.larkAppId
|| session.chatId !== input.targetChatId) continue;
// A chat-scope peer is reachable from any message in the same group:
// mentions inside a topic fold back into that peer's shared chat session.
// A thread-scope peer is reachable only when this send actually lands in
// the same thread root.
const here = session.scope === 'chat'
? input.foldableChatAppIds?.has(session.larkAppId) === true
: !!input.outboundRootMessageId
&& session.rootMessageId === input.outboundRootMessageId;
if (here) activeAppIds.add(session.larkAppId);
}

const openIds = new Set<string>();
const entries = Array.isArray(input.botEntries)
? input.botEntries.filter((entry): entry is { larkAppId: string; botName: string | null } =>
!!entry
&& typeof entry === 'object'
&& typeof entry.larkAppId === 'string'
&& (entry.botName === null || typeof entry.botName === 'string'))
: [];
const crossRef = input.crossRef && typeof input.crossRef === 'object'
? input.crossRef
: {};
for (const entry of entries) {
if (!entry.botName || !activeAppIds.has(entry.larkAppId)) continue;
// crossRef is keyed by display name. When several apps share that name,
// the value cannot prove which app it represents; fail closed and retain
// the old-topic hint instead of silencing it for the wrong bot.
const sameNameEntries = entries.filter(candidate =>
candidate.botName?.toLowerCase() === entry.botName!.toLowerCase());
if (sameNameEntries.length !== 1) continue;
const openId = crossRef[entry.botName];
if (typeof openId === 'string' && openId) openIds.add(openId);
}
return openIds;
}

/**
* Resolve where a `botmux report` should go + who to @, so report-back works
* even when the orchestrator is on a DIFFERENT machine.
Expand Down Expand Up @@ -418,20 +517,21 @@ export function acceptedDispatchBotAppIds(input: {
* a dispatched sub-bot in an active topic that is NOT reachable in the current
* conversation (so @-ing it here would spawn a context-less session), else null.
*
* The bot I'm replying to (`quoteTargetSenderOpenId`) is reachable right here, so
* it's never treated as off-topic — that's the boundary that stops the guard from
* blocking a normal reply to a bot conversing with me. Callers block (explicit
* --mention) or drop (prose injection) on a non-null result, and skip the whole
* check under `--anyway`.
* The bot I'm replying to (`quoteTargetSenderOpenId`) and bots in
* `reachableOpenIds` are reachable right here, so an unrelated older dispatch
* topic is never recommended for them.
*/
export function offTopicSubBotTopic(input: {
mentionOpenId: string;
quoteTargetSenderOpenId?: string;
reachableOpenIds?: Set<string>;
chatId: string;
registry: Record<string, { orchChatId?: string; bots?: string[] }>;
activeSeeds: Set<string>;
}): string | null {
if (!input.mentionOpenId || input.mentionOpenId === input.quoteTargetSenderOpenId) return null;
if (!input.mentionOpenId
|| input.mentionOpenId === input.quoteTargetSenderOpenId
|| input.reachableOpenIds?.has(input.mentionOpenId)) return null;
return findSubBotTopic({
mentionOpenId: input.mentionOpenId,
chatId: input.chatId,
Expand Down
Loading