fix: preserve quoted message context across channels

This commit is contained in:
xmanrui 2026-09-02 01:27:35 +08:00
parent c2be2389b0
commit 94e114b9b3
46 changed files with 4242 additions and 321 deletions

View file

@ -2,9 +2,11 @@ import {
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
extractWeixinFiles,
extractWeixinImages,
extractWeixinReplyReference,
extractWeixinText,
splitWeixinText,
weixinMessageId,
weixinMessageTimestampMs,
} from './weixin-api.mjs';
import {
harnessAnswerForQuestion,
@ -38,7 +40,6 @@ import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
@ -47,10 +48,15 @@ import {
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
hasReplyReference,
promptContentForInboundMessage,
} from '../shared/semantic/reply-reference.mjs';
import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
@ -67,6 +73,10 @@ const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
const TYPING_RETRY_DELAY_MS = 60_000;
const WEIXIN_SEND_DIAGNOSTIC = Symbol('weixin-send-diagnostic');
const WEIXIN_REPLY_HISTORY_PAGE_SIZE = 100;
const WEIXIN_REPLY_HISTORY_MAX_PAGES = 3;
const WEIXIN_REPLY_HISTORY_TIMEOUT_MS = 5_000;
const WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS = 15_000;
const HELP_TEXT = () => [
t('微信已连接 DeepSeek Harness。'),
@ -182,7 +192,20 @@ function weixinSendFailureOptions(error) {
return undefined;
}
export function weixinInboundMessage(message, api) {
export function weixinInboundMessage(message, api, state, { loadReplyContent } = {}) {
const toUserId = nonEmptyString(message?.from_user_id);
const replyTo = extractWeixinReplyReference(message, {
resolveContent: (reference) => state?.recentOutboundTextFor?.({
toUserId,
...reference,
}),
...(typeof loadReplyContent === 'function' ? {
loadContent: (reference, options) => loadReplyContent({
toUserId,
...reference,
}, options),
} : {}),
});
return {
content: extractWeixinText(message) ?? '',
images: typeof api?.inboundImages === 'function'
@ -191,6 +214,7 @@ export function weixinInboundMessage(message, api) {
files: typeof api?.inboundFiles === 'function'
? api.inboundFiles(message)
: extractWeixinFiles(message),
...(replyTo ? { replyTo } : {}),
};
}
@ -375,7 +399,8 @@ export class WeixinHarnessBridge {
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
: this.#batchInputs.handle(key, commandText, {
plainText: isNativeWeixinText(message),
plainText: isNativeWeixinText(message)
&& !extractWeixinReplyReference(message),
});
if (result.handled) {
if (result.kind === 'submit') {
@ -489,7 +514,7 @@ export class WeixinHarnessBridge {
batchSubmission = null,
} = {}) {
const preparedMessage = prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
this.#inboundMessage(message),
{ signal: this.#signal },
);
const previous = this.#queues.get(key) ?? Promise.resolve();
@ -508,6 +533,54 @@ export class WeixinHarnessBridge {
return current;
}
#inboundMessage(message) {
return weixinInboundMessage(message, this.#api, this.#state, {
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
});
}
async #loadReplyContent(reference, { signal: callerSignal } = {}) {
const indexed = this.#state.recentOutboundTextFor?.(reference);
if (indexed) return { content: indexed };
const quotedAt = [
weixinMessageTimestampMs(reference?.messageId),
Number(reference?.createTimeMs),
Number(reference?.updateTimeMs),
].find(Number.isSafeInteger);
if (quotedAt === undefined) return { unavailableReason: 'not-delivered' };
const sender = nonEmptyString(reference?.toUserId);
const sessionId = sender ? this.#state.sessionFor(conversationKey(sender)) : null;
const session = typeof sessionId === 'string' && sessionId
? this.#harness.workspaceSession?.(sessionId)
: null;
if (typeof session?.readHistory !== 'function') {
return { unavailableReason: 'not-delivered' };
}
const text = await recoverAssistantTextByTimestamp({
session,
quotedAt,
signal: callerSignal,
pageSize: WEIXIN_REPLY_HISTORY_PAGE_SIZE,
maxPages: WEIXIN_REPLY_HISTORY_MAX_PAGES,
timeoutMs: WEIXIN_REPLY_HISTORY_TIMEOUT_MS,
toleranceMs: WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS,
});
if (!text) return { unavailableReason: 'not-delivered' };
const messageId = nonEmptyString(reference?.messageId);
try {
await this.#state.rememberOutboundMessage?.({
toUserId: sender,
text,
sentAt: quotedAt,
completedAt: quotedAt,
providerMessageIds: messageId ? [messageId] : [],
});
} catch (error) {
this.#logger.warn?.('[dsh-weixin] failed to remember a recovered quote:', error);
}
return { content: text };
}
#finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
let task;
task = Promise.resolve().then(async () => {
@ -644,11 +717,12 @@ export class WeixinHarnessBridge {
let batchSettled = batchSubmission === null;
let promptRecorded = false;
try {
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
const promptMessage = preparedMessage ?? this.#inboundMessage(message);
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
if (!text && !hasImages && !hasFiles) {
const hasReply = hasReplyReference(promptMessage);
if (!text && !hasImages && !hasFiles && !hasReply) {
await this.#send(sender, t('目前支持文字、图片、文件,以及微信已转成文字的语音消息。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
@ -701,8 +775,8 @@ export class WeixinHarnessBridge {
let artifacts = [];
await this.#startTyping(sender, contextToken);
try {
let content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal })
let content = hasImages || hasReply
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
: undefined;
const snapshot = this.#acceptedMessageIds.get(messageId);
let contextEnhanced = false;
@ -1154,6 +1228,7 @@ export class WeixinHarnessBridge {
const chunks = splitWeixinText(text, this.#maxMessageChars);
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const chunk = chunks[chunkIndex];
const sentAt = Date.now();
try {
const result = await this.#api.sendText({
baseUrl: this.#baseUrl,
@ -1164,7 +1239,19 @@ export class WeixinHarnessBridge {
runId,
signal: this.#signal,
});
providerMessageIds.push(...providerMessageIdsFor(result));
const chunkMessageIds = providerMessageIdsFor(result);
providerMessageIds.push(...chunkMessageIds);
try {
await this.#state.rememberOutboundMessage?.({
toUserId,
text: chunk,
sentAt,
completedAt: Date.now(),
providerMessageIds: chunkMessageIds,
});
} catch (error) {
this.#logger.warn?.('[dsh-weixin] failed to remember an outbound message:', error);
}
} catch (error) {
throw weixinSendError(error, {
baseUrl: this.#baseUrl,