Skip to content
Merged
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
42 changes: 40 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3284,6 +3284,13 @@ interface SessionData {
currentReplyTarget?: { rootMessageId: string; turnId: string; updatedAt: string; quoteOnly?: boolean; substitute?: boolean };
/** Per-turn reply targets(见 Session.replyTargets in types.ts)——排队/并发轮次各自的回复锚点。 */
replyTargets?: Record<string, { rootMessageId?: string; updatedAt: string; quoteOnly?: boolean; substitute?: boolean; senderOpenId?: string }>;
/** Frozen per-turn reply contexts(见 Session.turnReplyContexts in types.ts)。
* `botmux send` 只读其中的 `inThread`:判断本轮 quote 目标当初是否从**顶层**
* 进来,据此拦住「顶层 @ 之后那条消息才被开成话题」时 quote 把回复带进话题。 */
turnReplyContexts?: Record<string, {
target?: { mode?: string; chatId?: string; rootMessageId?: string };
inThread?: boolean;
}>;
codexAppDispatchLedger?: CodexAppDispatchLedgerEntry[];
codexAppGenerationCommits?: unknown;
queued?: boolean;
Expand Down Expand Up @@ -7018,6 +7025,7 @@ import { config } from './config.js';
import { getSessionUsageSnapshot } from './core/cost-calculator.js';
import {
resolveQuoteTarget,
shouldDropAfterTheFactTopicQuote,
validateMentionDecision,
mentionBackAmbiguity,
mentionBackAmbiguityError,
Expand Down Expand Up @@ -8534,7 +8542,7 @@ async function cmdSend(rest: string[]): Promise<void> {
if (!statSync(p).isFile()) { console.error(`不是普通文件: ${p}`); process.exit(1); }
}

const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict } = await import('./im/lark/client.js');
const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict, getMessageThreadId } = 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;
Expand Down Expand Up @@ -8790,6 +8798,36 @@ async function cmdSend(rest: string[]): Promise<void> {
?? frozenTurnDispatch?.quoteTargetId
?? s.quoteTargetId,
});
// 「顶层 @ 之后那条消息才被开成话题」的发送侧半边。飞书的 reply 接口让回复继承
// 被引用消息**此刻**的话题归属(`reply_in_thread:false` 只是不新开话题,逃不出
// 已有话题),所以引用一条事后被开成话题的顶层消息,会把回复带进用户根本没在
// 其中 @ 过 bot 的话题里 —— dispatcher 侧的 fold 只管住了卡片,正文由这里决定。
//
// 只在「该轮确证从顶层进来(inThread === false)且本次真要 quote」时才探测一次
// 飞书;话题内轮次、`--top-level`、`--no-quote`、`--quote`、thread-scope 全部在
// 上面或 `shouldDropAfterTheFactTopicQuote` 里短路,普通热路径不多付这次调用。
// 探测失败一律保持 quote(既有默认行为),绝不因为不确定就改变所有正常回复的落点。
const quotedTurnInThread = quoteTargetId
? (s.turnReplyContexts?.[currentTurnId ?? '']?.inThread
?? s.turnReplyContexts?.[quoteTargetId]?.inThread)
: undefined;
let effectiveQuoteTargetId = quoteTargetId;
if (quoteTargetId && !explicitQuote && quotedTurnInThread === false) {
const probedThreadId = await getMessageThreadId(appId, quoteTargetId).catch(() => undefined);
if (shouldDropAfterTheFactTopicQuote({
quoteTargetId,
quotedTurnInThread,
currentThreadId: probedThreadId,
explicitQuote,
})) {
logger.info(
`[send] quote target ${quoteTargetId.substring(0, 12)} was answered flat at top level but now `
+ `belongs to topic ${String(probedThreadId).substring(0, 12)}; posting flat instead of quoting `
+ 'so the reply does not land in an after-the-fact topic',
);
effectiveQuoteTargetId = undefined;
}
}
let primaryQuotedId: string | null = null;
let vcMeetingListenerReplyReplay = false;
const dispatchPrimary = async (
Expand All @@ -8802,7 +8840,7 @@ async function cmdSend(rest: string[]): Promise<void> {
revalidateVcMeetingManagedSend();
const proposedOutput = {
targetChatId,
...(quoteTargetId ? { quoteTargetId } : {}),
...(effectiveQuoteTargetId ? { quoteTargetId: effectiveQuoteTargetId } : {}),
msgType,
content,
};
Expand Down
63 changes: 62 additions & 1 deletion src/core/reply-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export function beginReplyTargetTurn(
replyRootId: string | undefined,
turnId: string,
nowIso = new Date().toISOString(),
opts?: { quoteOnly?: boolean; substitute?: boolean; senderOpenId?: string; participants?: TurnParticipant[]; participantsIncomplete?: boolean },
opts?: { quoteOnly?: boolean; substitute?: boolean; senderOpenId?: string; participants?: TurnParticipant[]; participantsIncomplete?: boolean; inThread?: boolean; foldedRootId?: string },
): void {
// #597: the frozen per-turn dispatch context — the authoritative reply target
// for THIS turn's Codex App dispatch (steer/queued/opening). Independent of the
Expand All @@ -287,6 +287,13 @@ export function beginReplyTargetTurn(
...(ds.session.quoteTargetSenderIsBot !== undefined
? { replyTargetSenderIsBot: ds.session.quoteTargetSenderIsBot }
: {}),
// Chat-scope only: distinguishes "answered flat AT TOP LEVEL" from
// "answered flat but the inbound was already inside a topic" (a native
// topic seed). Thread-scope turns route off session.rootMessageId and
// never consult this, so recording it there would only be dead metadata.
...(ds.scope === 'chat' && opts?.inThread !== undefined
? { inThread: opts.inThread }
: {}),
};
const overflow = Object.keys(exactContexts).length - 256;
if (overflow > 0) {
Expand Down Expand Up @@ -334,6 +341,23 @@ export function beginReplyTargetTurn(
ds.session.currentReplyTarget = target;
return;
}
// The turn folded into this chat-scope session from a Lark thread whose root
// we deliberately did NOT anchor the visible reply to (an after-the-fact
// topic — see chatSessionAnsweredRootAtTopLevel). Routing and display are
// separate contracts: the reply stays flat, but this session must still be
// discoverable by that root, or a later NON-@ message inside that topic
// misses `findChatReplyAlias` and forks a brand-new thread-scope session
// instead of folding back here (reachable under the never/topic/ambient
// mention modes, which answer un-@'d messages).
if (opts?.foldedRootId) {
const aliases = { ...(ds.replyThreadAliases ?? ds.session.replyThreadAliases ?? {}) };
aliases[opts.foldedRootId] = {
createdAt: aliases[opts.foldedRootId]?.createdAt ?? nowIso,
lastUsedAt: nowIso,
};
ds.replyThreadAliases = aliases;
ds.session.replyThreadAliases = aliases;
}
ds.currentReplyTarget = undefined;
ds.session.currentReplyTarget = undefined;
}
Expand Down Expand Up @@ -471,6 +495,10 @@ export function rehomeReplyTargetState(ds: DaemonSession): void {
...(context.replyTargetSenderIsBot !== undefined
? { replyTargetSenderIsBot: context.replyTargetSenderIsBot }
: {}),
// `inThread` is deliberately NOT carried over: it describes the shape of
// the inbound message in the SOURCE chat, which says nothing about the
// new destination. Readers treat its absence as "unknown" and fall back
// to pre-existing behavior, which is the safe direction here.
};
}
ds.session.turnReplyContexts = contexts;
Expand Down Expand Up @@ -499,3 +527,36 @@ export function rehomeReplyTargetState(ds: DaemonSession): void {
ds.streamCardReplyTargetKey = undefined;
ds.session.streamCardReplyTargetKey = undefined;
}

/**
* True when `rootId` is a message this chat-scope session ALREADY answered as a
* flat turn **that arrived at the group's top level** — its frozen per-turn
* record is `{ target.mode: 'plain', inThread: false }`.
*
* Lives beside `beginReplyTargetTurn`, the sole writer of the record it reads:
* the two halves of this contract must not drift apart, and a predicate defined
* next to its producer can be unit-tested against real records instead of being
* re-implemented (and silently diverging) at the call site.
*
* Used by the regular-group fold to tell apart:
* • 顶层 @ 之后用户才在那条消息上「开启话题」 — Lark then delivers that topic's
* messages as root_id=<原顶层消息> + thread_id=<新建 omt_>. The turn still
* folds into the group chat-scope session, but the visible reply must NOT be
* anchored into a topic the user never @'d the bot in.
* • 用户真正开的原生话题 — must keep its existing topic anchoring.
*
* `mode === 'plain'` alone cannot separate the two: a native-topic seed is
* recorded `plain` as well (its opening message carries thread_id but no
* root_id, so neither the fold nor the shared-topic seeder supplies a
* replyRootId). `inThread` is the load-bearing half. A record written before
* `inThread` existed leaves it undefined — not `false` — so it reads as
* "unknown" and keeps the pre-existing behavior rather than guessing.
*/
export function chatSessionAnsweredRootAtTopLevel(
s: Pick<Session, 'scope' | 'turnReplyContexts'>,
rootId: string,
): boolean {
if (s.scope !== 'chat') return false;
const context = s.turnReplyContexts?.[rootId];
return context?.target?.mode === 'plain' && context.inThread === false;
}
26 changes: 21 additions & 5 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn
import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js';
import { fillNativeTopicId } from './core/native-topic-id.js';
import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js';
import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveInboundReplyTarget, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
import { beginReplyTargetTurn, buildTurnParticipantsFrom, chatSessionAnsweredRootAtTopLevel, fallbackTurnId, isSubstituteTurn, resolveInboundReplyTarget, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
import {
buildBotmuxLarkNativeSessionTitle,
Expand Down Expand Up @@ -17200,6 +17200,9 @@ function deliverPassthroughToExistingSession(
senderOpenId?: string;
senderIsBot: boolean;
substitute: boolean;
/** The inbound message carried a Lark thread_id (see
* FrozenSessionReplyContext.inThread). */
inThread?: boolean;
/** raw input 已写入 worker 后回调(worker 不在线的拒绝分支不触发),供 ingress
* 调用方打接纳标——其后同步收尾(落盘/事件派发)抛错不得再诱导重发,否则
* /compact 这类非幂等 passthrough 会被重发重复执行。 */
Expand All @@ -17224,6 +17227,7 @@ function deliverPassthroughToExistingSession(
senderOpenId: turn.senderOpenId,
participants: passthroughWindow.participants,
participantsIncomplete: passthroughWindow.incomplete,
inThread: turn.inThread,
});
if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) {
ds.session.lastCallerOpenId = turn.senderOpenId;
Expand Down Expand Up @@ -17388,7 +17392,7 @@ async function startInitialPassthroughSession(args: {
sessionStore.updateSession(ds.session);
}
const initialWindow = buildTurnParticipants(larkAppId, senderOpenId, resolvedSenderIsBotTriState, undefined, initialPassthroughSender?.name);
beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete });
beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete, inThread: !!parsed.threadId });
sessionStore.updateSession(ds.session);
const registration = await claimNewDaemonSession(activeSessions, ds);
if (!registration.accepted) {
Expand Down Expand Up @@ -18350,7 +18354,7 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise<v
// Turn key is the reply anchor (== messageId outside session-group births) so
// the per-turn reply context and currentReplyTarget.turnId line up with the
// worker's turn id — current-turn provenance requires that equality.
beginReplyTargetTurn(ds, replyRootId, replyAnchorId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete });
beginReplyTargetTurn(ds, replyRootId, replyAnchorId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete, inThread: !!parsed.threadId, foldedRootId: ctx.foldedRootId });
sessionStore.updateSession(ds.session);
const registration = await claimNewDaemonSession(activeSessions, ds);
if (!registration.accepted) {
Expand Down Expand Up @@ -19435,6 +19439,7 @@ async function handleThreadReplyAdmitted(
senderOpenId: threadSenderOpenId,
senderIsBot: isForeignBot,
substitute: !!substituteTrigger,
inThread: !!parsed.threadId,
onDelivered: () => markIngressAdmitted(ctx),
});
}
Expand Down Expand Up @@ -19706,7 +19711,7 @@ async function handleThreadReplyAdmitted(
// on the double-race (matches the new-topic path's collectPostAtMentions args).
const existingPostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
const existingWindow = buildTurnParticipants(larkAppId, callerOpenId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, undefined, existingPostAt);
beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete });
beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete, inThread: !!parsed.threadId, foldedRootId: ctx.foldedRootId });
if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
ds.session.lastCallerOpenId = callerOpenId;
}
Expand Down Expand Up @@ -20140,7 +20145,7 @@ async function handleThreadReplyAdmitted(
: 'thread';
const autoCreatePostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
const autoCreateWindow = buildTurnParticipants(larkAppId, senderOId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, autoCreateSender?.name, autoCreatePostAt);
beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete });
beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete, inThread: !!parsed.threadId, foldedRootId: ctx.foldedRootId });
sessionStore.updateSession(newDs.session);
const registration = await claimNewDaemonSession(activeSessions, newDs);
if (!registration.accepted) {
Expand Down Expand Up @@ -22347,6 +22352,17 @@ export async function startDaemon(botIndex?: number): Promise<void> {
beforeSessionTurn: (data, ctx) => maybeCatchUpVcMeetingConsumerBeforeTurn(data, ctx),
isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
resolveReplyThreadAlias: (rootId, chatId, appId) => findChatReplyAlias(rootId, chatId, appId),
chatSessionAnsweredRootAtTopLevel: (rootId, chatId, appId) => {
for (const ds of activeSessions.values()) {
if (ds.larkAppId !== appId || ds.scope !== 'chat' || ds.chatId !== chatId) continue;
if (chatSessionAnsweredRootAtTopLevel(ds.session, rootId)) return true;
}
return sessionStore.listSessions().some(s =>
s.status === 'active'
&& s.larkAppId === appId
&& s.chatId === chatId
&& chatSessionAnsweredRootAtTopLevel(s, rootId));
},
// Chat was converted 普通群 → 话题群 while we held a chat-scope session.
// Idle legacy owners are evicted so subsequent inbound messages land on
// fresh thread-scope sessions. Owners with accepted/pending work remain
Expand Down
Loading