feat: add native result-file delivery

This commit is contained in:
xmanrui 2026-08-23 20:02:06 +08:00
parent 2803bbcbab
commit d472dd5a75
65 changed files with 10205 additions and 255 deletions

View file

@ -1,4 +1,10 @@
import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from 'node:crypto';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
@ -13,6 +19,7 @@ const ILINK_APP_ID = 'bot';
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
const LOGIN_STATUSES = new Set([
'wait',
'scaned',
@ -30,6 +37,7 @@ export class WeixinApiError extends Error {
this.name = 'WeixinApiError';
this.code = code;
this.status = options.status;
this.providerCode = options.providerCode;
}
}
@ -37,6 +45,70 @@ function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeProviderCode(value) {
const code = value === undefined || value === null ? null : String(value).trim();
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
}
function preserveArtifactMetadata(target, source) {
if (Number.isInteger(source?.status)) target.status = source.status;
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
return target;
}
function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
if (cause?.code?.startsWith?.('artifact-')) return cause;
const status = Number(cause?.status);
const providerCode = safeProviderCode(cause?.providerCode);
const providerText = providerCode ?? '';
let code = fallback;
let message = 'Weixin could not prepare the file for delivery.';
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|| /(?:permission|forbidden|unauthor|access.?denied)/i.test(providerText)) {
code = 'artifact-permission-required';
message = 'Weixin denied permission to send the file.';
} else if (status === 413 || providerCode === '413'
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
code = 'artifact-too-large';
message = 'The file exceeds Weixin\'s size limit.';
} else if (status === 429 || providerCode === '429'
|| /(?:rate.?limit|too.?many)/i.test(providerText)) {
code = 'artifact-rate-limited';
message = 'Weixin rate-limited file delivery.';
} else if (fallback === 'artifact-provider-rejected') {
message = 'Weixin rejected the file message.';
}
const error = new Error(message, { cause });
error.code = code;
return preserveArtifactMetadata(error, cause);
}
function uncertainWeixinDelivery(cause) {
const error = new Error('Weixin file delivery result is uncertain', { cause });
error.code = 'artifact-delivery-uncertain';
return preserveArtifactMetadata(error, cause);
}
function rejectedProviderResponse(value) {
if (!value || typeof value !== 'object') return null;
for (const field of ['ret', 'errcode']) {
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
return safeProviderCode(value[field]) ?? 'rejected';
}
}
return null;
}
function classifyWeixinFinalDeliveryError(error, signal) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
if (error?.code === 'network-error' || error?.code === 'timeout'
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
return uncertainWeixinDelivery(error);
}
return weixinArtifactError(error);
}
function strictBase64(value) {
const text = nonEmptyString(value);
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
@ -188,6 +260,89 @@ function baseInfo() {
};
}
function aesEcbPaddedSize(size) {
return Math.ceil((size + 1) / 16) * 16;
}
function trustedWeixinCdnUploadUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new WeixinApiError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
}
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|| (url.port && url.port !== '443') || url.pathname !== '/c2c/upload'
|| url.username || url.password) {
throw new WeixinApiError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
}
url.hash = '';
return url;
}
function weixinCdnUploadUrl(response, fileKey) {
const fullUrl = nonEmptyString(response?.upload_full_url);
if (fullUrl) return trustedWeixinCdnUploadUrl(fullUrl);
const uploadParam = nonEmptyString(response?.upload_param);
if (!uploadParam) {
throw new WeixinApiError('missing-upload-url', '微信服务没有返回文件上传地址。');
}
const url = new URL(`${WEIXIN_CDN_BASE_URL}/upload`);
url.searchParams.set('encrypted_query_param', uploadParam);
url.searchParams.set('filekey', fileKey);
return trustedWeixinCdnUploadUrl(url);
}
function encryptWeixinUpload(bytes, key) {
const cipher = createCipheriv('aes-128-ecb', key, null);
return Buffer.concat([cipher.update(bytes), cipher.final()]);
}
async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
let lastError;
for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
signal?.throwIfAborted();
try {
const response = await fetchImpl(url, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: ciphertext,
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000),
redirect: 'error',
});
if (response.status >= 400 && response.status < 500) {
throw new WeixinApiError(
'upload-rejected',
`微信文件上传被拒绝(HTTP ${response.status})。`,
{ status: response.status },
);
}
if (response.status !== 200) {
throw new WeixinApiError(
'upload-failed',
`微信文件上传失败(HTTP ${response.status})。`,
{ status: response.status },
);
}
const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
await response.body?.cancel?.().catch(() => undefined);
if (!downloadParam) {
throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
}
return downloadParam;
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (error instanceof WeixinApiError
&& (error.code === 'upload-rejected' || error.status < 500)) throw error;
lastError = error;
}
}
if (lastError instanceof WeixinApiError) throw lastError;
throw new WeixinApiError('upload-failed', '微信文件上传失败。', { cause: lastError });
}
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
return new DOMException('The operation was aborted', 'AbortError');
@ -349,6 +504,117 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
return true;
},
async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
const recipient = nonEmptyString(toUserId);
if (!recipient || !file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('toUserId and a file are required');
}
signal?.throwIfAborted();
const fileKey = randomBytes(16).toString('hex');
const aesKey = randomBytes(16);
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
let upload;
try {
upload = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/getuploadurl',
token,
signal,
body: {
filekey: fileKey,
media_type: 3,
to_user_id: recipient,
rawsize: file.bytes.byteLength,
rawfilemd5: rawMd5,
filesize: aesEcbPaddedSize(file.bytes.byteLength),
no_need_thumb: true,
aeskey: aesKey.toString('hex'),
base_info: baseInfo(),
},
});
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw weixinArtifactError(error, { fallback });
}
const uploadRejection = rejectedProviderResponse(upload);
if (uploadRejection) {
throw weixinArtifactError(new WeixinApiError(
'upload-url-rejected',
'微信服务拒绝了文件上传请求。',
{ providerCode: uploadRejection },
));
}
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
let downloadParam;
try {
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw weixinArtifactError(error, { fallback });
}
signal?.throwIfAborted();
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
?? randomUUID();
const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
let response;
try {
response = await requestJson(fetchImpl, {
method: 'POST',
baseUrl,
endpoint: 'ilink/bot/sendmessage',
token,
signal,
body: {
msg: {
from_user_id: '',
to_user_id: recipient,
client_id: clientId,
message_type: 2,
message_state: 2,
item_list: [{
type: 4,
file_item: {
media: {
encrypt_query_param: downloadParam,
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
encrypt_type: 1,
},
file_name: file.fileName,
len: String(file.bytes.byteLength),
},
}],
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
},
base_info: baseInfo(),
},
});
} catch (error) {
throw classifyWeixinFinalDeliveryError(error, signal);
}
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw weixinArtifactError(new WeixinApiError(
'send-rejected',
'微信服务拒绝了文件消息。',
{ providerCode: sendRejection },
));
}
return { messageId: clientId };
},
async notifyStart({ baseUrl, token, signal }) {
const response = await requestJson(fetchImpl, {
method: 'POST',

View file

@ -32,6 +32,16 @@ import {
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
@ -89,6 +99,28 @@ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
};
}
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-rate-limited':
return `结果文件「${name}」暂时被微信限流,未能发送,请稍后重试。`;
case 'artifact-provider-rejected':
return `结果文件「${name}」已生成,但微信拒绝了该文件消息。`;
case 'artifact-invalid':
case 'artifact-changed':
case 'artifact-unavailable':
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
default:
return `结果文件「${name}」已生成,但暂时未能通过微信发送,请稍后重试。`;
}
}
export function createWeixinBridgeStatus() {
return {
messagesReceived: 0,
@ -394,8 +426,9 @@ export class WeixinHarnessBridge {
? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined;
let answer;
let artifacts = [];
try {
({ answer } = await askInWorkspaceSession({
({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
@ -421,12 +454,35 @@ export class WeixinHarnessBridge {
this.#approvals.closeRoute(key),
]);
}
await this.#send(sender, answer, contextToken, runId);
const answerText = typeof answer === 'string' && answer.trim()
? answer
: artifacts.length > 0 ? '结果文件已生成。' : 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 = error;
}
const delivery = await this.#deliverArtifacts(
sender,
messageId,
artifacts,
contextToken,
runId,
textReceipt,
);
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
this.#status.lastMessageError = null;
return delivery.receipt;
} catch (error) {
if (error?.code === 'turn-stopped') {
await this.#state.markSeen(messageId);
@ -762,15 +818,90 @@ export class WeixinHarnessBridge {
}
async #send(toUserId, text, contextToken, runId) {
const providerMessageIds = [];
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
await this.#api.sendText({
const result = await this.#api.sendText({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId,
text: chunk,
contextToken,
runId,
signal: this.#signal,
});
providerMessageIds.push(...providerMessageIdsFor(result));
}
return providerMessageIds;
}
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt);
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#api.sendFile !== 'function') {
const unavailable = new Error('Weixin file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
const result = await this.#api.sendFile({
baseUrl: this.#baseUrl,
token: this.#token,
toUserId,
file,
contextToken,
runId,
signal: this.#signal,
});
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'weixin-file',
providerMessageIds: providerMessageIdsFor(result),
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
userVisible = true;
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-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let noticeSent = false;
const providerMessageIds = await this.#send(
toUserId,
artifactFailureText(artifact?.fileName, error),
contextToken,
runId,
).then((ids) => {
noticeSent = true;
return ids;
}).catch(() => []);
const failureReceipt = createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds,
});
receipts.push(failureReceipt);
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
} finally {
releaseOutboundArtifact(artifact);
}
}
const receipt = receipts.length === 0
? null
: receipts.length === 1
? receipts[0]
: mergeDeliveryReceipts({
deliveryId: replyTo,
presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
receipts,
});
return { receipt, userVisible };
}
}