dsh-im-ops/src/channels/wecom/wecom-bridge.mjs
2026-08-23 20:02:06 +08:00

1162 lines
42 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 { generateReqId } from '@wecom/aibot-node-sdk';
import {
harnessAnswerForQuestion,
harnessQuestionText,
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-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 {
hasInboundImages,
ImagePromptError,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
trackOutboundArtifactProviderPromise,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
} from '../shared/semantic/delivery.mjs';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
const HELP_TEXT = [
'企业微信机器人已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
'/models 按序号列出所有可用模型',
'/model [序号或完整模型ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2',
'/presetlist 按序号列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
'/stop 停止当前任务',
'/steer 补充指令 纠偏当前任务',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
const MAX_REPLY_BYTES = 18_000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PREFETCHED_IMAGES = 4;
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function bodyOf(frame) {
return frame?.body && typeof frame.body === 'object' ? frame.body : {};
}
function conversationKey(frame) {
const body = bodyOf(frame);
return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
}
function messageText(frame) {
const body = bodyOf(frame);
let text = '';
if (body.msgtype === 'text') {
text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
} else if (body.msgtype === 'voice') {
text = typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
} else if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
text = body.mixed.msg_item
.filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
.map((item) => item.text.content)
.join('\n')
.trim();
}
// Group callbacks retain the leading @bot mention that caused delivery.
// It is routing metadata rather than part of the user's prompt or answer.
return body.chattype === 'group'
? text.replace(/^\s*@\S+(?:\s+|$)/u, '').trim()
: text;
}
function imageContents(frame) {
const body = bodyOf(frame);
if (body.msgtype === 'image') return [body.image];
if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
return body.mixed.msg_item
.filter((item) => item?.msgtype === 'image')
.map((item) => item.image);
}
function imageSource(client, image) {
const url = nonEmptyString(image?.url);
if (!url) return null;
const aeskey = nonEmptyString(image?.aeskey) ?? undefined;
return {
async load({ signal, maxBytes }) {
signal?.throwIfAborted();
if (typeof client?.downloadFile !== 'function') {
throw new Error('Enterprise WeChat image download is unavailable');
}
const result = await client.downloadFile(url, aeskey);
signal?.throwIfAborted();
const raw = result?.buffer;
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
throw new Error('Enterprise WeChat image download returned no data');
}
const data = Buffer.from(raw);
if (Number.isFinite(maxBytes) && data.length > maxBytes) {
throw new ImagePromptError(
'image-too-large',
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
'图片超过 5 MB,请压缩后重试。',
);
}
return { data, name: result?.filename };
},
};
}
export function wecomInboundMessage(frame, client) {
return {
content: messageText(frame),
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
};
}
function prefetchInboundImages(message, signal) {
if (!hasInboundImages(message)) return message;
return {
...message,
images: message.images.map((source) => {
const download = source.load({ signal, maxBytes: MAX_IMAGE_BYTES });
// The conversation queue may not consume this promise immediately. Keep
// an attached rejection handler while preserving the original outcome.
download.catch(() => undefined);
return {
...source,
async load({ signal: loadSignal, maxBytes = MAX_IMAGE_BYTES } = {}) {
loadSignal?.throwIfAborted();
const result = await download;
loadSignal?.throwIfAborted();
const raw = result?.data ?? result?.buffer ?? result;
const size = Buffer.isBuffer(raw) || raw instanceof Uint8Array ? raw.length : 0;
if (size > maxBytes) {
throw new ImagePromptError(
'image-too-large',
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
'图片超过 5 MB,请压缩后重试。',
);
}
return result;
},
};
}),
};
}
function imageQueueFullMessage(message) {
return {
...message,
images: message.images.map((source) => ({
...source,
async load() {
throw new ImagePromptError(
'image-queue-full',
`Enterprise WeChat already has ${MAX_PREFETCHED_IMAGES} prefetched images`,
'当前待处理图片较多,请稍后重新发送。',
);
},
})),
};
}
function interactionReplyText(frame) {
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
}
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
const source = String(text ?? '').trim();
if (!source) return [];
const chunks = [];
let current = '';
let bytes = 0;
for (const character of source) {
const size = Buffer.byteLength(character);
if (current && bytes + size > maxBytes) {
chunks.push(current);
current = character;
bytes = size;
} else {
current += character;
bytes += size;
}
}
if (current) chunks.push(current);
return chunks;
}
function progressText(update) {
if (update?.type === 'text') return update.text;
if (update?.type === 'tool') return `正在使用${update.name}…`;
return update?.text;
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
switch (error?.code) {
case 'artifact-delivery-uncertain':
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
case 'artifact-permission-required':
return `结果文件「${name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。`;
case 'artifact-too-large':
return `结果文件「${name}」超过当前企业微信机器人可发送的文件大小,未发送。`;
case 'artifact-empty':
return `结果文件「${name}」为空,企业微信不允许发送空文件。`;
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
case 'artifact-rate-limited':
return `结果文件「${name}」暂时被企业微信限流,未能发送,请稍后重试。`;
case 'artifact-provider-rejected':
return `结果文件「${name}」已生成,但企业微信拒绝了该文件或文件消息。`;
default:
return `结果文件「${name}」已生成,但暂时未能通过企业微信发送,请稍后重试。`;
}
}
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function waitWithSignal(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortReason(signal));
signal.addEventListener('abort', onAbort, { once: true });
Promise.resolve(promise).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (signal.aborted) onAbort();
});
}
function wecomArtifactError(error, { dispatched = false } = {}) {
if (error?.code?.startsWith?.('artifact-')) return error;
const status = Number(error?.httpStatus ?? error?.status ?? error?.response?.status);
const providerCode = Number(error?.providerCode ?? error?.errcode ?? error?.body?.errcode);
const wrapped = new Error('Enterprise WeChat file delivery failed', { cause: error });
if (status === 401 || status === 403 || providerCode === 48002) {
wrapped.code = 'artifact-permission-required';
} else if (status === 413) {
wrapped.code = 'artifact-too-large';
} else if (status === 429 || providerCode === 45009) {
wrapped.code = 'artifact-rate-limited';
} else if (Number.isFinite(providerCode) && providerCode !== 0) {
wrapped.code = 'artifact-provider-rejected';
} else {
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
}
if (Number.isFinite(status)) wrapped.status = status;
if (Number.isFinite(providerCode)) wrapped.providerCode = providerCode;
return wrapped;
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
}
function providerMessageId(result) {
return nonEmptyString(result?.body?.msgid)
?? nonEmptyString(result?.body?.message_id);
}
function canClaimInteractionReply(frame, pending) {
return pending.questions[pending.index]
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
&& nonEmptyString(interactionReplyText(frame));
}
export function createWecomBridgeStatus() {
return {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
artifactsSent: 0,
artifactSendErrors: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
}
export class WecomHarnessBridge {
#client;
#harness;
#state;
#status;
#logger;
#replyTimeoutMs;
#generateReqId;
#signal;
#fileUploadTimeoutMs;
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
#acceptedMessageIds = new Set();
#approvalTasks = new Set();
#commandTasks = new Set();
#approvals;
#prefetchedImageCount = 0;
constructor({
client,
harness,
state,
status = createWecomBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
generateStreamId = generateReqId,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
signal,
}) {
if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
throw new TypeError('Enterprise WeChat client is required');
}
if (!harness || !state) throw new TypeError('Harness client and state store are required');
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
}
this.#client = client;
this.#harness = harness;
this.#state = state;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#generateReqId = generateStreamId;
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
this.#signal = signal;
this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
}
get status() {
return structuredClone(this.#status);
}
accept(frame) {
if (this.#signal?.aborted) return Promise.resolve();
const body = bodyOf(frame);
const messageId = nonEmptyString(body.msgid);
const senderId = nonEmptyString(body.from?.userid);
const chatId = body.chattype === 'group'
? nonEmptyString(body.chatid)
: senderId;
if (!messageId || !senderId || !chatId
|| !['single', 'group'].includes(body.chattype)
|| this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(frame);
this.#acceptedMessageIds.add(messageId);
if (body.chattype === 'single') {
rememberConnectionTestTarget(this.#state, { chatId });
}
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const commandRunner = isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner) {
let task;
task = this.#processFastCommand(
frame,
messageId,
chatId,
key,
commandMessage,
commandRunner,
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process a command');
return this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。')
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
const approval = this.#approvals.claimReply({
key,
actor: senderId,
messageId,
text: interactionReplyText(frame),
addressed: true,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
? pending.queue
: null,
isQuestionPending: () => this.#pendingInteractions.has(key),
send: (text) => this.#sendImmediate(frame, chatId, text),
});
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 && pending.actor !== senderId) {
return this.#enqueueMessage(frame, messageId, key);
}
if (pending?.submitting || pending?.claimedReplyMessageId) {
return this.#enqueueMessage(frame, messageId, key);
}
if (pending) {
if (canClaimInteractionReply(frame, pending)) {
pending.claimedReplyMessageId = messageId;
}
const previous = pending.queue ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#processInteractionReply(
frame,
messageId,
senderId,
chatId,
key,
pending,
))
.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(frame, messageId, key);
}
#enqueueMessage(frame, messageId, key, {
releaseMessageId = true,
alreadyRecorded = false,
} = {}) {
// WeCom image URLs expire after five minutes, while a conversation turn
// may legally stay queued longer. Start the authenticated SDK download as
// soon as the validated callback is accepted, then consume it in order.
const inboundMessage = wecomInboundMessage(frame, this.#client);
const imageCount = inboundMessage.images.length;
let reservedImages = 0;
let preparedMessage = inboundMessage;
if (imageCount > 0) {
if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
reservedImages = imageCount;
this.#prefetchedImageCount += reservedImages;
preparedMessage = prefetchInboundImages(inboundMessage, this.#signal);
} else {
preparedMessage = imageQueueFullMessage(inboundMessage);
}
}
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
.finally(() => {
this.#prefetchedImageCount -= reservedImages;
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
});
this.#queues.set(key, current);
return current;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),
...[...this.#pendingInteractions.values()].flatMap((pending) => (
pending.queue ? [pending.queue] : []
)),
...this.#approvalTasks,
...this.#commandTasks,
]);
}
async #processFastCommand(frame, messageId, chatId, key, message, 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(message.content, this.#harness, this.#state, key, {
signal: this.#signal,
hasImages: hasInboundImages(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) await this.#sendImmediate(frame, chatId, reply);
}
this.#status.lastError = null;
}
async #sendActive(chatId, text) {
const providerMessageIds = [];
for (const chunk of splitUtf8(text)) {
this.#signal?.throwIfAborted();
const result = await this.#client.sendMessage(
chatId,
{ msgtype: 'markdown', markdown: { content: chunk } },
);
const messageId = providerMessageId(result);
if (messageId) providerMessageIds.push(messageId);
}
return providerMessageIds;
}
async #sendImmediate(frame, chatId, text) {
this.#signal?.throwIfAborted();
const chunks = splitUtf8(text);
if (chunks.length === 0) return;
try {
await this.#client.replyStream(frame, this.#generateReqId('stream'), chunks[0], true);
for (const chunk of chunks.slice(1)) {
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
}
} catch {
await this.#sendActive(chatId, text);
}
}
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false };
}
const receipts = baseReceipt ? [baseReceipt] : [];
let failureNoticeVisible = false;
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#client.uploadMedia !== 'function'
|| typeof this.#client.sendMediaMessage !== 'function') {
const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
this.#signal?.throwIfAborted();
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
let uploaded;
try {
const pending = this.#client.uploadMedia(file.bytes, {
type: 'file',
filename: file.fileName,
});
trackOutboundArtifactProviderPromise(file, pending);
uploaded = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw wecomArtifactError(error);
}
this.#signal?.throwIfAborted();
const mediaId = nonEmptyString(uploaded?.media_id);
if (!mediaId) {
const rejected = new Error('Enterprise WeChat upload returned no media id');
rejected.code = 'artifact-provider-rejected';
throw rejected;
}
let sent;
try {
const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
trackOutboundArtifactProviderPromise(file, pending);
sent = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw wecomArtifactError(error, { dispatched: true });
}
this.#signal?.throwIfAborted();
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
if (Number.isFinite(providerCode) && providerCode !== 0) {
throw wecomArtifactError({ providerCode });
}
const messageId = providerMessageId(sent);
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'wecom-file',
providerMessageIds: messageId ? [messageId] : [],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
} catch (error) {
if (this.#signal?.aborted) throw error;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
this.#logger.warn?.(
`[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let providerMessageIds = [];
try {
providerMessageIds = await this.#sendActive(
chatId,
artifactFailureText(artifact?.fileName, error),
);
failureNoticeVisible = true;
} catch (noticeError) {
if (this.#signal?.aborted) throw noticeError;
this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
}
receipts.push(createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds,
}));
} finally {
releaseOutboundArtifact(artifact);
}
}
return {
receipt: mergeDeliveryReceipts({
deliveryId: replyTo,
presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
receipts,
}),
failureNoticeVisible,
};
}
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
if (this.#signal?.aborted) return;
const body = bodyOf(frame);
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
const chatId = body.chattype === 'group' ? body.chatid : senderId;
if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return;
if (!alreadyRecorded) {
if (this.#state.hasSeen(messageId)) return;
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
const text = message.content;
const hasImages = hasInboundImages(message);
const key = conversationKey(frame);
let streamId = null;
let streamStarted = false;
try {
if (!text && !hasImages) {
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
await this.#state.markSeen(messageId);
return;
}
const command = text.toLowerCase();
if (!hasImages && command === '/help') {
await this.#sendImmediate(frame, chatId, HELP_TEXT);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/new') {
await this.#state.clearSession(key);
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#sendImmediate(frame, chatId, reply);
}
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages
? null
: await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#sendImmediate(frame, chatId, compactCommand.message);
await this.#state.markSeen(messageId);
return;
}
streamId = this.#generateReqId('stream');
try {
await this.#client.replyStream(frame, streamId, '正在思考中…', false);
streamStarted = true;
} catch (error) {
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
}
const content = hasImages
? await promptContentForMessage(message, { signal: this.#signal })
: undefined;
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
control: { owner: this, key },
onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
? async (update) => {
const progress = splitUtf8(progressText(update))[0];
if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
}
: undefined,
onInteraction: (interaction) => this.#handleInteraction(interaction, {
key,
actor: senderId,
chatId,
requiresMention: body.chattype === 'group',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
},
});
this.#signal?.throwIfAborted();
const displayAnswer = answerTextForDelivery(answer, artifacts);
const chunks = splitUtf8(displayAnswer);
let finalSent = false;
let textReceipt = null;
let textSendError = null;
try {
if (streamStarted && chunks.length > 0) {
try {
const providerMessageIds = [];
const streamed = await this.#client.replyStream(frame, streamId, chunks[0], true);
const streamedMessageId = providerMessageId(streamed);
if (streamedMessageId) providerMessageIds.push(streamedMessageId);
for (const chunk of chunks.slice(1)) {
const sent = await this.#client.sendMessage(
chatId,
{ msgtype: 'markdown', markdown: { content: chunk } },
);
const messageId = providerMessageId(sent);
if (messageId) providerMessageIds.push(messageId);
}
finalSent = true;
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'wecom-text',
providerMessageIds,
});
} catch (error) {
this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
}
}
if (!finalSent) {
const providerMessageIds = await this.#sendActive(chatId, displayAnswer);
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'wecom-text',
providerMessageIds,
});
}
} catch (error) {
textSendError = error;
this.#logger.warn?.(
'[dsh-im:wecom] final text delivery failed; continuing with result files:',
error,
);
}
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt?.artifacts?.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
return delivery.receipt;
} catch (error) {
if (error?.code === 'turn-stopped') {
if (streamStarted && streamId) {
await this.#client.replyStream(frame, streamId, '已停止。', true)
.catch(() => undefined);
}
await this.#state.markSeen(messageId);
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
try {
if (streamStarted && streamId) {
await this.#client.replyStream(frame, streamId, errorText, true);
} else {
await this.#sendImmediate(frame, chatId, errorText);
}
await this.#state.markSeen(messageId);
} catch {
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
}
} finally {
await Promise.allSettled([
this.#cancelPendingInteraction(key),
this.#approvals.closeRoute(key),
]);
}
}
async #processInteractionReply(frame, messageId, senderId, chatId, key, expected) {
if (this.#signal?.aborted) return;
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(frame, messageId, chatId);
}
return this.#enqueueMessage(frame, 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(interactionReplyText(frame));
if (!text) {
await this.#sendImmediate(frame, chatId, '请用文字回答当前问题。')
.catch(() => undefined);
return;
}
const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) {
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
.catch(() => undefined);
return;
}
return this.#enqueueMessage(frame, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
if (pending.actor !== senderId) {
return this.#enqueueMessage(frame, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
pending.chatId = chatId;
if (pending.needsPresentation) {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = '企业微信交互问题发送失败。';
this.#logger.error?.('[dsh-im:wecom] 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.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
.catch(() => undefined);
return;
}
return this.#enqueueMessage(frame, 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 = '企业微信交互问题发送失败。';
this.#logger.error?.('[dsh-im:wecom] 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') {
if (this.#pendingInteractions.get(key) === pending) {
this.#clearPendingInteraction(key, pending.interactionId);
}
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
.catch(() => undefined);
return;
}
if (this.#pendingInteractions.get(key) !== pending) return;
pending.submitting = false;
pending.answers.pop();
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#logger.error?.('[dsh-im:wecom] failed to answer a Harness interaction');
await this.#sendImmediate(frame, chatId, '回答提交失败,请重新发送当前问题的答案。')
.catch(() => undefined);
}
}
async #handleInteraction(interaction, {
key,
actor,
chatId,
requiresMention,
}) {
if (interaction?.kind === 'approval') {
return this.#approvals.handleRequested(interaction, {
key,
actor,
requiresMention,
send: (text) => this.#sendActive(chatId, text),
});
}
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-im:wecom] ignored an invalid Harness question interaction');
return;
}
if (interaction.recovered === true) {
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'Enterprise WeChat safely cancelled an interaction left by an earlier client.',
details: {},
},
});
await this.#sendActive(
chatId,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
).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) {
this.#logger.warn?.('[dsh-im:wecom] cancelled a second pending Harness question');
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'Enterprise WeChat is already handling another user interaction.',
details: {},
},
});
return;
}
const pending = {
kind: 'question',
interactionId,
sessionId: interaction.sessionId,
interaction,
actor,
requiresMention,
questions,
answers: [],
index: 0,
chatId,
queue: null,
claimedReplyMessageId: null,
submitting: false,
needsPresentation: true,
presentationPromise: null,
};
this.#pendingInteractions.set(key, pending);
this.#interactionKeys.set(pending.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.#sendActive(
pending.chatId,
harnessQuestionText(
question,
pending.index,
pending.questions.length,
{ requiresMention: pending.requiresMention },
),
).then(() => {
pending.needsPresentation = false;
}).finally(() => {
if (pending.presentationPromise === presentation) {
pending.presentationPromise = null;
}
});
pending.presentationPromise = presentation;
return presentation;
}
async #discardResolvedInteractionReply(frame, messageId, chatId) {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT).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 Enterprise WeChat interaction ended before the user answered.',
details: {},
},
}, { signal: AbortSignal.timeout(5_000) });
} catch (error) {
if (error?.code !== 'interaction-not-pending') {
this.#logger.warn?.('[dsh-im:wecom] failed to cancel a pending Harness interaction');
}
}
}
}