mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 08:50:48 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue