dsh-im-ops/src/channels/weixin/weixin-bridge.mjs
2026-09-01 10:46:41 +08:00

1382 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
extractWeixinFiles,
extractWeixinImages,
extractWeixinText,
splitWeixinText,
weixinMessageId,
} from './weixin-api.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import {
BatchInputManager,
batchInputBusyMessage,
isBatchInputCommand,
} from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
} from '../shared/control-command.mjs';
import {
isModelCommand,
runModelCommand,
} from '../shared/model-command.mjs';
import {
isPresetCommand,
runPresetCommand,
} from '../shared/preset-command.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
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 HELP_TEXT = () => [
t('微信已连接 DeepSeek Harness。'),
'',
t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
t('/presetlist 或 /presets 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),
t('/preset --default 跟随 Host 默认'),
t('/stop 停止当前任务'),
t('/steer 补充指令 纠偏当前任务'),
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/version 查看插件版本'),
t('/help 显示本帮助'),
].join('\n');
function conversationKey(userId) {
return `p2p:${userId}`;
}
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeDiagnosticToken(value) {
const token = value === undefined || value === null ? '' : String(value).trim();
return /^-?[A-Za-z0-9_.:-]{1,160}$/u.test(token) ? token : '-';
}
function weixinApiHost(baseUrl) {
try {
return safeDiagnosticToken(new URL(baseUrl).hostname.toLowerCase());
} catch {
return '-';
}
}
function createWeixinSendDiagnostic({
baseUrl,
text,
chunk,
chunkIndex,
chunkCount,
maxMessageChars,
contextToken,
runId,
error,
}) {
const status = Number(error?.status ?? error?.httpStatus);
const http = Number.isInteger(status)
? String(status)
: error?.code === 'send-rejected' ? '2xx' : '-';
return [
'endpoint=sendmessage',
`host=${weixinApiHost(baseUrl)}`,
`chunk=${chunkIndex + 1}/${chunkCount}`,
`chunkChars=${chunk.length}`,
`chunkUtf8Bytes=${Buffer.byteLength(chunk, 'utf8')}`,
`totalChars=${text.length}`,
`totalUtf8Bytes=${Buffer.byteLength(text, 'utf8')}`,
`limitChars=${maxMessageChars}`,
`contextToken=${contextToken ? 'yes' : 'no'}`,
`runId=${runId ? 'yes' : 'no'}`,
`http=${http}`,
`provider=${safeDiagnosticToken(error?.providerCode)}`,
`cause=${safeDiagnosticToken(error?.code ?? error?.name)}`,
].join(' ');
}
function weixinSendError(error, details) {
const diagnostic = createWeixinSendDiagnostic({ ...details, error });
const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
const code = safeDiagnosticToken(error?.code);
wrapped.code = code === '-' ? 'weixin-send-failed' : code;
const status = Number(error?.status ?? error?.httpStatus);
if (Number.isInteger(status)) wrapped.status = status;
const providerCode = safeDiagnosticToken(error?.providerCode);
if (providerCode !== '-') wrapped.providerCode = providerCode;
wrapped[WEIXIN_SEND_DIAGNOSTIC] = diagnostic;
return wrapped;
}
function weixinSendFailureOptions(error) {
let current = error;
for (let depth = 0; current && depth < 5; depth += 1) {
const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
if (typeof diagnostic === 'string' && diagnostic) {
return {
reason: 'weixin-send-failed',
userMessage: [
t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
t('微信发送诊断:{diagnostic}', { diagnostic }),
].join('\n'),
};
}
current = current.cause;
}
return undefined;
}
export function weixinInboundMessage(message, api) {
return {
content: extractWeixinText(message) ?? '',
images: typeof api?.inboundImages === 'function'
? api.inboundImages(message)
: extractWeixinImages(message),
files: typeof api?.inboundFiles === 'function'
? api.inboundFiles(message)
: extractWeixinFiles(message),
};
}
function hasWeixinImageItems(message) {
return Array.isArray(message?.item_list)
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
}
function hasWeixinFileItems(message) {
return Array.isArray(message?.item_list)
&& message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
}
function isNativeWeixinText(message) {
return Array.isArray(message?.item_list)
&& message.item_list.length > 0
&& message.item_list.every((item) => (
item?.type === 1 && typeof item.text_item?.text === 'string'
))
&& Boolean(nonEmptyString(extractWeixinText(message)));
}
function canClaimInteractionReply(message, pending) {
return pending.questions[pending.index]
&& nonEmptyString(message?.from_user_id) === pending.actor
&& !hasWeixinImageItems(message)
&& !hasWeixinFileItems(message)
&& nonEmptyString(extractWeixinText(message));
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过当前微信会话可发送的文件大小,未发送。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被微信限流,未能发送,请稍后重试。', { name });
case 'artifact-provider-rejected':
return t('结果文件「{name}」已生成,但微信拒绝了该文件消息。', { name });
case 'artifact-invalid':
case 'artifact-changed':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
default:
return t('结果文件「{name}」已生成,但暂时未能通过微信发送,请稍后重试。', { name });
}
}
export function createWeixinBridgeStatus() {
return {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
export class WeixinHarnessBridge {
#api;
#baseUrl;
#token;
#ownerUserId;
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
#maxMessageChars;
#typingKeepaliveMs;
#signal;
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
// Keep the accepted configuration through the existing queue/reply lifecycle.
#acceptedMessageIds = new Map();
#approvalTasks = new Set();
#commandTasks = new Set();
#approvals;
#batchInputs = new BatchInputManager();
#typingTicket = null;
#typingTarget = null;
#typingTimer = null;
#typingGeneration = 0;
#typingTail = Promise.resolve();
#typingClosed = false;
#typingRetryAt = 0;
#typingTicketStale = false;
constructor({
api,
baseUrl,
token,
ownerUserId,
harness,
state,
contextEnhancement,
accessPolicy,
status = createWeixinBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
typingKeepaliveMs = DEFAULT_TYPING_KEEPALIVE_MS,
signal,
}) {
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
if (!harness || !state) throw new TypeError('Harness client and state store are required');
if (!Number.isFinite(typingKeepaliveMs) || typingKeepaliveMs <= 0) {
throw new TypeError('typingKeepaliveMs must be a positive number');
}
this.#api = api;
this.#baseUrl = baseUrl;
this.#token = token;
this.#ownerUserId = ownerUserId;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#maxMessageChars = maxMessageChars;
this.#typingKeepaliveMs = typingKeepaliveMs;
this.#signal = signal;
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
}
get status() {
return structuredClone(this.#status);
}
accept(message) {
if (this.#signal?.aborted) return Promise.resolve();
if (message?.message_type === 2) return Promise.resolve();
const messageId = weixinMessageId(message);
const sender = nonEmptyString(message?.from_user_id);
if (!messageId || !sender || this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const access = this.#accessPolicy
? evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'direct',
senderIds: sender,
text: commandText,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
})
: sender === this.#ownerUserId
? { allowed: true, reason: 'legacy-owner' }
: { allowed: false, reason: 'sender-not-allowed' };
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(messageId, sender, message, access);
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
'direct',
));
if (sender === this.#ownerUserId) {
rememberConnectionTestTarget(this.#state, { toUserId: sender });
}
const key = conversationKey(sender);
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
const pending = this.#pendingInteractions.get(key);
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (batchCommand || batchStatus.phase === 'collecting') {
const exactBatchStart = /^\/batch$/iu.test(commandText);
const result = exactBatchStart
&& batchStatus.phase === 'idle'
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
: this.#batchInputs.handle(key, commandText, {
plainText: isNativeWeixinText(message),
});
if (result.handled) {
if (result.kind === 'submit') {
return this.#enqueueMessage({
...message,
item_list: [{ type: 1, text_item: { text: result.prompt } }],
}, messageId, key, { batchSubmission: result });
}
return this.#finishBatchResult(
message,
messageId,
key,
sender,
contextToken,
runId,
result,
);
}
}
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasWeixinFileItems(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner) {
let task;
task = this.#processFastCommand(
message,
messageId,
key,
sender,
contextToken,
runId,
commandText,
commandRunner,
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
error,
);
return this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
const approval = this.#approvals.claimReply({
key,
actor: sender,
messageId,
text: hasWeixinImageItems(message) || hasWeixinFileItems(message)
? ''
: extractWeixinText(message),
addressed: true,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
? pending.queue
: null,
isQuestionPending: () => this.#pendingInteractions.has(key),
send: (text) => this.#send(sender, text, contextToken, runId),
});
if (approval) {
let task;
task = approval.process(async () => {
if (this.#state.hasSeen(messageId)) return false;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
return true;
})
.finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#approvalTasks.delete(task);
});
this.#approvalTasks.add(task);
return task;
}
if (pending?.submitting || pending?.claimedReplyMessageId) {
return this.#enqueueMessage(message, messageId, key);
}
if (pending) {
if (canClaimInteractionReply(message, pending)) {
pending.claimedReplyMessageId = messageId;
}
const previous = pending.queue ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#processInteractionReply(message, messageId, key, pending))
.catch((error) => this.#handleInteractionFailure(message, messageId, error))
.finally(() => {
this.#acceptedMessageIds.delete(messageId);
if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
if (pending.queue === current) pending.queue = null;
});
pending.queue = current;
return current;
}
return this.#enqueueMessage(message, messageId, key);
}
#enqueueMessage(message, messageId, key, {
releaseMessageId = true,
alreadyRecorded = false,
batchSubmission = null,
} = {}) {
const preparedMessage = prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
);
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(message, key, {
alreadyRecorded,
preparedMessage,
batchSubmission,
}))
.finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
});
this.#queues.set(key, current);
return current;
}
#finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (result.message) {
await this.#sendOutOfBand(key, sender, result.message, contextToken, runId);
}
this.#status.lastError = null;
}).catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
error,
);
await this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
#finishAccessDecision(messageId, sender, message, access) {
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(sender, t(COMMAND_PERMISSION_DENIED_MESSAGE), contextToken, runId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-weixin] failed to apply inbound access policy:', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),
...[...this.#pendingInteractions.values()].flatMap((pending) => (
pending.queue ? [pending.queue] : []
)),
...this.#approvalTasks,
...this.#commandTasks,
this.#typingTail,
]);
}
async close() {
this.#typingClosed = true;
await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
}
async #processFastCommand(
message,
messageId,
key,
sender,
contextToken,
runId,
text,
runner,
) {
this.#signal?.throwIfAborted();
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
isDirect: true,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
});
if (result?.stopped) {
await Promise.allSettled([
this.#cancelPendingInteraction(key),
this.#approvals.closeRoute(key),
]);
}
for (const reply of result?.messages ?? [result?.message]) {
if (!reply) continue;
if (result?.stopped) {
await this.#send(sender, reply, contextToken, runId);
} else {
await this.#sendOutOfBand(key, sender, reply, contextToken, runId);
}
}
this.#status.lastError = null;
}
async #process(message, key, {
alreadyRecorded = false,
preparedMessage,
batchSubmission = null,
} = {}) {
this.#signal?.throwIfAborted();
const messageId = weixinMessageId(message);
const sender = nonEmptyString(message?.from_user_id);
if (!messageId || !sender) return;
if (!alreadyRecorded) {
if (this.#state.hasSeen(messageId)) return;
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
let batchSettled = batchSubmission === null;
let promptRecorded = false;
try {
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
if (!text && !hasImages && !hasFiles) {
await this.#send(sender, t('目前支持文字、图片、文件,以及微信已转成文字的语音消息。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const command = text.trim().toLowerCase();
if (!hasImages && !hasFiles && command === '/help') {
await this.#send(sender, HELP_TEXT(), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sender, t('微信与 DeepSeek Harness 连接正常。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#send(sender, t('已开启新会话。请发送你的问题。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages || hasFiles
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#send(sender, reply, contextToken, runId);
}
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages || hasFiles
? null
: await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#send(sender, compactCommand.message, contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
let answer;
let artifacts = [];
await this.#startTyping(sender, contextToken);
try {
let content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined;
const snapshot = this.#acceptedMessageIds.get(messageId);
if (snapshot) {
content = enhanceContextContent(content ?? text, snapshot, () => ({
channel: 'weixin',
senderId: sender,
}));
}
await this.#state.markSeen(messageId);
promptRecorded = true;
({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
...(content !== undefined ? { content } : { text }),
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
control: { owner: this, key },
onUpdate: () => this.#resumeTyping(key, sender, contextToken),
onInteraction: (interaction) => this.#handleInteraction(interaction, {
key,
actor: sender,
contextToken,
runId,
}),
onInteractionResolved: async (resolution) => {
await this.#handleInteractionResolved(resolution);
await this.#resumeTyping(key, sender, contextToken);
},
files: promptMessage.files,
},
}));
if (batchSubmission) {
this.#batchInputs.complete(key, batchSubmission.token);
batchSettled = true;
}
} finally {
await Promise.allSettled([
this.#stopTyping(),
this.#cancelPendingInteraction(key),
this.#approvals.closeRoute(key),
]);
}
const answerText = typeof answer === 'string' && answer.trim()
? answer
: artifacts.length > 0 ? t('结果文件已生成。') : answer;
let textDeliveryError = null;
let textReceipt = null;
try {
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'weixin-text',
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
});
} catch (error) {
textDeliveryError = channelDeliveryFailure(error);
}
const delivery = await this.#deliverArtifacts(
sender,
messageId,
artifacts,
contextToken,
runId,
textReceipt,
);
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
if (textDeliveryError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(
this.#status,
textDeliveryError,
weixinSendFailureOptions(textDeliveryError),
);
}
if (!promptRecorded) await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return delivery.receipt;
} catch (error) {
let batchFailureMessage = null;
if (!batchSettled && batchSubmission) {
if (error?.code === 'turn-stopped') {
this.#batchInputs.complete(key, batchSubmission.token);
} else {
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
}
batchSettled = true;
}
if (error?.code === 'turn-stopped') {
if (!promptRecorded) await this.#state.markSeen(messageId);
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error);
const imageDiagnostic = imagePromptDiagnostic(error);
const sendFailureOptions = weixinSendFailureOptions(error);
const failure = setLastMessageFailure(this.#status, error, {
userMessage: userMessage ?? sendFailureOptions?.userMessage,
reason: imageDiagnostic?.reason ?? sendFailureOptions?.reason,
});
this.#logger.error?.(
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
error,
);
try {
await this.#send(
sender,
batchFailureMessage
? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
: messageFailureText(failure),
contextToken,
runId,
);
if (!promptRecorded) await this.#state.markSeen(messageId);
} catch (sendError) {
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
}
}
}
async #processInteractionReply(message, messageId, key, expected) {
this.#signal?.throwIfAborted();
const current = this.#pendingInteractions.get(key);
const claimed = expected.claimedReplyMessageId === messageId;
if (!current || current !== expected || current.submitting) {
if (claimed && (!current || current !== expected)) {
return this.#discardResolvedInteractionReply(message, messageId);
}
return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
}
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
const text = nonEmptyString(extractWeixinText(message));
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
if (!text || hasWeixinImageItems(message)) {
await this.#send(
expected.actor,
t('请用文字回答当前问题。'),
contextToken,
runId,
);
return;
}
const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) {
await this.#send(
expected.actor,
INTERACTION_RESOLVED_TEXT(),
contextToken,
runId,
);
return;
}
return this.#enqueueMessage(message, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
pending.contextToken = contextToken;
pending.runId = runId;
if (pending.needsPresentation) {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = t('微信交互问题发送失败。');
this.#logger.error?.('[dsh-weixin] failed to retry an interaction question');
pending.interaction.reconnect?.();
return;
}
const presentedPending = this.#pendingInteractions.get(key);
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
if (claimed && (!presentedPending || presentedPending !== expected)) {
await this.#send(
expected.actor,
INTERACTION_RESOLVED_TEXT(),
contextToken,
runId,
).catch(() => undefined);
return;
}
return this.#enqueueMessage(message, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
}
const question = pending.questions[pending.index];
if (!question) return;
pending.answers.push(harnessAnswerForQuestion(question, text));
pending.index += 1;
if (pending.index < pending.questions.length) {
if (pending.claimedReplyMessageId === messageId) {
pending.claimedReplyMessageId = null;
}
pending.needsPresentation = true;
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = t('微信交互问题发送失败。');
this.#logger.error?.('[dsh-weixin] failed to send the next interaction question');
pending.interaction.reconnect?.();
}
return;
}
pending.submitting = true;
try {
await pending.interaction.respond({
ok: true,
value: {
sessionId: pending.sessionId,
answer: { answers: pending.answers },
},
});
this.#clearPendingInteraction(key, pending.interactionId);
this.#status.lastError = null;
} catch (error) {
if (this.#signal?.aborted) return;
if (error?.code === 'interaction-not-pending') {
this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(
pending.actor,
INTERACTION_RESOLVED_TEXT(),
pending.contextToken,
pending.runId,
).catch(() => undefined);
return;
}
if (this.#pendingInteractions.get(key) !== pending) return;
pending.submitting = false;
pending.answers.pop();
pending.index -= 1;
this.#status.lastError = t('回答提交失败。');
this.#logger.error?.('[dsh-weixin] failed to answer a Harness interaction');
await this.#send(
pending.actor,
t('回答提交失败,请重新发送当前问题的答案。'),
pending.contextToken,
pending.runId,
).catch(() => undefined);
}
}
async #handleInteraction(interaction, {
key,
actor,
contextToken,
runId,
}) {
if (interaction?.kind === 'approval') {
return this.#approvals.handleRequested(interaction, {
key,
actor,
send: (text) => this.#send(actor, text, contextToken, runId),
});
}
if (interaction?.kind !== 'question') return;
const questions = interaction?.payload?.questions;
const interactionId = typeof interaction?.interactionId === 'string'
? interaction.interactionId
: interaction?.rpcId;
if (typeof interaction?.rpcId !== 'string'
|| typeof interactionId !== 'string'
|| typeof interaction.sessionId !== 'string'
|| !Array.isArray(questions)
|| questions.length === 0
|| questions.some((question) => !validHarnessQuestion(question))) {
this.#logger.warn?.('[dsh-weixin] ignored an invalid Harness question interaction');
return;
}
if (interaction.recovered === true) {
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'Weixin safely cancelled an interaction left by an earlier client.',
details: {},
},
});
await this.#send(
actor,
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
contextToken,
runId,
).catch(() => undefined);
return;
}
const existing = this.#pendingInteractions.get(key);
if (existing?.interactionId === interactionId) {
existing.interaction = interaction;
if (existing.needsPresentation) await this.#presentInteraction(existing);
return;
}
if (this.#interactionKeys.has(interactionId)) return;
if (existing) {
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'Weixin is already handling another user interaction.',
details: {},
},
});
return;
}
const pending = {
kind: 'question',
interactionId,
sessionId: interaction.sessionId,
interaction,
actor,
questions,
answers: [],
index: 0,
contextToken,
runId,
queue: null,
claimedReplyMessageId: null,
presentationPromise: null,
submitting: false,
needsPresentation: true,
};
this.#pendingInteractions.set(key, pending);
this.#interactionKeys.set(interactionId, key);
await this.#presentInteraction(pending);
}
async #handleInteractionResolved(resolution) {
if (resolution?.kind === 'approval') {
await this.#approvals.handleResolved(resolution);
return;
}
const interactionId = resolution?.interactionId;
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
const key = this.#interactionKeys.get(interactionId);
if (!key) return;
this.#clearPendingInteraction(key, interactionId);
}
#presentInteraction(pending) {
if (!pending.needsPresentation) return Promise.resolve();
if (pending.presentationPromise) return pending.presentationPromise;
const question = pending.questions[pending.index];
if (!question) return Promise.resolve();
const presentation = this.#send(
pending.actor,
harnessQuestionText(question, pending.index, pending.questions.length),
pending.contextToken,
pending.runId,
).then(() => {
pending.needsPresentation = false;
}).finally(() => {
if (pending.presentationPromise === presentation) pending.presentationPromise = null;
});
pending.presentationPromise = presentation;
return presentation;
}
async #discardResolvedInteractionReply(message, messageId) {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(
nonEmptyString(message?.from_user_id),
INTERACTION_RESOLVED_TEXT(),
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);
}
#takePendingInteraction(key, interactionId) {
const pending = this.#pendingInteractions.get(key);
if (!pending
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
this.#pendingInteractions.delete(key);
this.#interactionKeys.delete(pending.interactionId);
return pending;
}
#clearPendingInteraction(key, interactionId) {
return this.#takePendingInteraction(key, interactionId) !== null;
}
async #cancelPendingInteraction(key) {
const pending = this.#takePendingInteraction(key);
if (!pending || pending.kind !== 'question') return;
try {
await pending.interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'The Weixin interaction ended before the user answered.',
details: {},
},
}, { signal: AbortSignal.timeout(5_000) });
} catch (error) {
if (error?.code !== 'interaction-not-pending') {
this.#logger.warn?.('[dsh-weixin] failed to cancel a pending Harness interaction');
}
}
}
async #handleInteractionFailure(message, messageId, error) {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process an interaction reply [${failure.referenceId}]:`,
error,
);
if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined);
}
await this.#send(
nonEmptyString(message?.from_user_id),
messageFailureText(failure),
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);
}
async #send(toUserId, text, contextToken, runId) {
await this.#stopTyping();
const providerMessageIds = [];
const chunks = splitWeixinText(text, this.#maxMessageChars);
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const chunk = chunks[chunkIndex];
try {
const result = await this.#api.sendText({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId,
text: chunk,
contextToken,
runId,
signal: this.#signal,
});
providerMessageIds.push(...providerMessageIdsFor(result));
} catch (error) {
throw weixinSendError(error, {
baseUrl: this.#baseUrl,
text,
chunk,
chunkIndex,
chunkCount: chunks.length,
maxMessageChars: this.#maxMessageChars,
contextToken,
runId,
});
}
}
return providerMessageIds;
}
async #sendOutOfBand(key, toUserId, text, contextToken, runId) {
try {
return await this.#send(toUserId, text, contextToken, runId);
} finally {
if (this.#queues.has(key)) {
await this.#resumeTyping(key, toUserId, contextToken);
}
}
}
#queueTyping(operation) {
const task = this.#typingTail
.catch(() => undefined)
.then(operation);
this.#typingTail = task.catch(() => undefined);
return task;
}
async #startTyping(toUserId, contextToken) {
const target = nonEmptyString(toUserId);
if (!target
|| this.#typingClosed
|| this.#signal?.aborted
|| Date.now() < this.#typingRetryAt
|| typeof this.#api.getConfig !== 'function'
|| typeof this.#api.sendTyping !== 'function') return false;
if (this.#typingTarget === target) return true;
const generation = ++this.#typingGeneration;
try {
return await this.#queueTyping(async () => {
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
let ticket = this.#typingTicket;
if (!ticket) {
const config = await this.#api.getConfig({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId: target,
contextToken,
signal: this.#signal,
});
ticket = nonEmptyString(config?.typingTicket);
if (!ticket) {
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
return false;
}
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
this.#typingTicket = ticket;
this.#typingRetryAt = 0;
}
this.#typingTarget = target;
await this.#api.sendTyping({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId: target,
typingTicket: ticket,
status: 1,
signal: this.#signal,
});
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
this.#typingTicketStale = false;
this.#typingRetryAt = 0;
this.#scheduleTyping(generation);
return true;
});
} catch (error) {
if (generation === this.#typingGeneration) {
this.#clearTypingTimer();
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
}
if (!this.#signal?.aborted && !this.#typingClosed) {
this.#logger.warn?.('[dsh-weixin] typing indicator failed:', error);
}
return false;
}
}
async #stopTyping({ signal = AbortSignal.timeout(5_000) } = {}) {
++this.#typingGeneration;
this.#clearTypingTimer();
const target = this.#typingTarget;
const ticket = this.#typingTicket;
const stale = this.#typingTicketStale;
this.#typingTarget = null;
if (typeof this.#api.sendTyping !== 'function') return false;
try {
return await this.#queueTyping(async () => {
if (!target || !ticket) return false;
try {
await this.#api.sendTyping({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId: target,
typingTicket: ticket,
status: 2,
signal,
});
} finally {
if (stale && this.#typingTicket === ticket) this.#typingTicket = null;
this.#typingTicketStale = false;
}
return true;
});
} catch (error) {
if (!signal?.aborted) {
this.#logger.warn?.('[dsh-weixin] typing cancellation failed:', error);
}
return false;
}
}
#scheduleTyping(generation) {
this.#clearTypingTimer();
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) return;
const timer = setTimeout(() => {
if (this.#typingTimer === timer) this.#typingTimer = null;
void this.#queueTyping(async () => {
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) {
return false;
}
await this.#api.sendTyping({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId: this.#typingTarget,
typingTicket: this.#typingTicket,
status: 1,
signal: this.#signal,
});
return true;
}).then((sent) => {
if (sent) this.#scheduleTyping(generation);
}).catch((error) => {
if (generation !== this.#typingGeneration) return;
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
if (!this.#signal?.aborted && !this.#typingClosed) {
this.#logger.warn?.('[dsh-weixin] typing keepalive failed:', error);
}
});
}, this.#typingKeepaliveMs);
timer.unref?.();
this.#typingTimer = timer;
}
#clearTypingTimer() {
if (this.#typingTimer) clearTimeout(this.#typingTimer);
this.#typingTimer = null;
}
#resumeTyping(key, toUserId, contextToken) {
if (this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)) {
return Promise.resolve(false);
}
return this.#startTyping(toUserId, contextToken);
}
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
const sendArtifact = (method, file) => this.#api[method]({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId,
file,
contextToken,
runId,
signal: this.#signal,
});
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
channelKey: 'weixin',
signal: this.#signal,
sendImage: typeof this.#api.sendImage === 'function'
? (file) => sendArtifact('sendImage', file)
: undefined,
sendFile: typeof this.#api.sendFile === 'function'
? (file) => sendArtifact('sendFile', file)
: undefined,
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#send(
toUserId,
messageFailureText(failure),
contextToken,
runId,
),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return {
receipt: delivery.receipt,
userVisible: delivery.userVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
}