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
105 changes: 83 additions & 22 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 @@ -83,7 +83,8 @@ import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js';
import { firstPositional } from './cli/arg-utils.js';
import { isColdResumeDormant, sessionListDisposition } from './cli/session-list-liveness.js';
import { dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateVideoAttachments } from './cli/send-dispatch.js';
import { dispatchDeferredTopicSend, type DeferredScheduleRunData } from './cli/deferred-topic-send.js';
import { dispatchDeferredTopicSend, reusableDeferredTopicRoot, type DeferredScheduleRunData } from './cli/deferred-topic-send.js';
import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
import { resolveDaemonEnv } from './cli/daemon-lifecycle-env.js';
import { buildPm2SpawnCommand } from './cli/pm2-command.js';
import { callDashboard, type DashboardEndpoint, type DashboardResult } from './cli/dashboard-endpoint.js';
Expand Down Expand Up @@ -6766,18 +6767,62 @@ 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 { /* */ } }

const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError } = await import('./im/lark/client.js');
const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict } = await import('./im/lark/client.js');
const appId = s.larkAppId!;
// Effective target chat for top-level mode (defaults to session's chat)
const targetChatId = overrideChatId ?? s.chatId;
// Chat-scope sessions (普通群整群一会话) post to chatId without
// 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 });
const dataDir = resolveDataDir();
const deferredBinding = !sendInto && (!overrideChatId || overrideChatId === s.chatId)
? readDeferredTopicBinding(dataDir, s.sessionId)
: undefined;
const deferredRoot = reusableDeferredTopicRoot({
session: s as SessionData & { larkAppId: string },
binding: deferredBinding,
explicitTopLevel: sendTopLevel,
reuseBoundRootWhenTopLevel: deferredMaterializedByThisCommand,
});
const reachabilityTarget = deferredRoot
? { mode: 'thread' as const, rootMessageId: deferredRoot }
: sendTarget;

// 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 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 @@ -6787,22 +6832,47 @@ async function cmdSend(rest: string[]): Promise<void> {
// `@OtherSubBot` can't slip past after this explicit guard already ran.
let dispatchReg: Record<string, { orchChatId?: string; bots?: string[] }> = {};
try {
const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json');
const regPath = join(dataDir, 'orchestrate-dispatch.json');
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 = await foldableChatSessionAppIds({
sessions: allSessions,
targetChatId,
outboundMode: reachabilityTarget.mode,
resolveMode: (larkAppId, chatId) => {
getBot(larkAppId); // unknown target bot must fail closed
return resolveRegularGroupMode(larkAppId, chatId);
},
resolveChatMode: chatId => getChatModeStrict(appId, chatId),
});
const reachableOpenIds = activeConversationBotOpenIds({
sessions: allSessions,
targetChatId,
outboundRootMessageId: threadRootForReachability(reachabilityTarget),
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 +6902,9 @@ 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 });
// Ordinary delivery uses the nominal target. Deferred delivery gets first
// refusal below; the advisory mirrors its existing binding in
// `reachabilityTarget` above so both paths agree about the effective root.
const dispatch = async (
content: string,
msgType: string,
Expand Down Expand Up @@ -7060,16 +7130,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
35 changes: 29 additions & 6 deletions src/cli/deferred-topic-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ export interface DeferredTopicSendResult {
materializedNow?: boolean;
}

export function deferredTopicBindingMatches(
session: DeferredTopicSendSession,
binding: DeferredTopicBinding,
): boolean {
const run = session.deferredScheduleRun;
return !!run
&& binding.sessionId === session.sessionId
&& binding.turnId === run.turnId
&& binding.chatId === session.chatId
&& binding.larkAppId === session.larkAppId
&& binding.routingAnchor === run.routingAnchor;
}

/** Existing deferred runs reply into their materialized topic before the
* nominal send target is considered. Mirror that precedence for advisory
* reachability without materializing a new topic. */
export function reusableDeferredTopicRoot(input: {
session: DeferredTopicSendSession;
binding: DeferredTopicBinding | undefined;
explicitTopLevel: boolean;
reuseBoundRootWhenTopLevel?: boolean;
}): string | undefined {
const { binding } = input;
if (!binding || !deferredTopicBindingMatches(input.session, binding)) return undefined;
if (input.explicitTopLevel && !input.reuseBoundRootWhenTopLevel) return undefined;
return binding.rootMessageId;
}

/** Route one `botmux send` effect for a lazily-materialized schedule topic.
* The per-session file lock spans the provider request so two concurrent CLI
* processes cannot both create a root. A stable provider UUID (sessionId)
Expand All @@ -50,12 +78,7 @@ export async function dispatchDeferredTopicSend(opts: {
return withDeferredTopicBindingLock(opts.dataDir, opts.session.sessionId, async () => {
const existing = readDeferredTopicBinding(opts.dataDir, opts.session.sessionId);
if (existing) {
if (
existing.turnId !== run.turnId
|| existing.chatId !== opts.session.chatId
|| existing.larkAppId !== opts.session.larkAppId
|| existing.routingAnchor !== run.routingAnchor
) {
if (!deferredTopicBindingMatches(opts.session, existing)) {
throw new Error('deferred topic binding identity mismatch');
}
if (opts.explicitTopLevel && !opts.reuseBoundRootWhenTopLevel) return { handled: false };
Expand Down
133 changes: 127 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,125 @@ 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;
deferredScheduleRun?: unknown;
vcMeetingReceiver?: unknown;
};

/**
* Identify active chat sessions whose bot is still configured to fold a
* mention back into that shared session. Mode lookup failures fail closed.
*/
export async function foldableChatSessionAppIds(input: {
sessions: Iterable<ReachabilitySession>;
targetChatId: string;
outboundMode: SessionReplyTarget['mode'];
resolveMode: (larkAppId: string, chatId: string) => 'chat' | 'shared' | 'new-topic' | 'chat-topic' | undefined;
resolveChatMode: (chatId: string) => Promise<'group' | 'topic' | 'p2p' | 'unknown' | undefined>;
}): Promise<Set<string>> {
const candidates = new Set<string>();
for (const session of input.sessions) {
if (session.status !== 'active'
|| session.scope !== 'chat'
|| !session.larkAppId
|| session.chatId !== input.targetChatId
// These chat-scoped sessions deliberately use isolated routing keys and
// can never be reached through the ordinary (chatId, appId) slot.
|| session.deferredScheduleRun
|| session.vcMeetingReceiver) continue;
candidates.add(session.larkAppId);
}

const appIds = new Set<string>();
if (candidates.size === 0) return appIds;
// Topology belongs to the chat, not to an individual bot. Resolve it once
// through the sending bot's authenticated client.
let chatMode: 'group' | 'topic' | 'p2p' | 'unknown' | undefined;
try {
chatMode = await input.resolveChatMode(input.targetChatId);
} catch { /* lookup failure → retain advisory */ }
if (chatMode !== 'group') return appIds;

for (const larkAppId of candidates) {
try {
const mode = input.resolveMode(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(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 +538,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