feat: add native image delivery across channels

This commit is contained in:
xmanrui 2026-08-24 03:25:38 +08:00
parent dbc0a4b730
commit 3ec5f1eca7
31 changed files with 2754 additions and 846 deletions

View file

@ -540,6 +540,93 @@ export function createDingtalkApi({
return true;
}
async function sendArtifact({
clientId,
clientSecret,
target,
file,
signal,
}, { uploadType, messageKey, createMessageParams }) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A DingTalk file is required');
}
const normalizedTarget = normalizeFileTarget(target);
let token;
try {
token = await accessToken({ clientId, clientSecret, signal });
} 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 dingtalkArtifactError(error, { fallback });
}
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
uploadUrl.searchParams.set('access_token', token);
uploadUrl.searchParams.set('type', uploadType);
const form = new FormData();
form.append(
'media',
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
let uploaded;
try {
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
} 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 dingtalkArtifactError(error, { fallback });
}
const uploadRejection = rejectedProviderResponse(uploaded);
const mediaId = nonEmptyString(uploaded?.media_id);
if (uploadRejection || !mediaId) {
throw dingtalkArtifactError(new DingtalkApiError(
'upload-rejected',
'钉钉服务拒绝了文件上传。',
{ providerCode: uploadRejection ?? 'missing-media-id' },
));
}
signal?.throwIfAborted();
const messageBody = {
robotCode: normalizedTarget.robotCode,
msgKey: messageKey,
msgParam: JSON.stringify(createMessageParams(mediaId, file)),
...(normalizedTarget.type === 'group'
? { openConversationId: normalizedTarget.openConversationId }
: { userIds: [normalizedTarget.userId] }),
};
const pathname = normalizedTarget.type === 'group'
? 'v1.0/robot/groupMessages/send'
: 'v1.0/robot/oToMessages/batchSend';
let response;
try {
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
body: messageBody,
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '文件消息发送',
});
} catch (error) {
throw classifyDingtalkFinalDeliveryError(error, signal);
}
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw dingtalkArtifactError(new DingtalkApiError(
'send-rejected',
'钉钉服务拒绝了文件消息。',
{ providerCode: sendRejection },
));
}
return response;
}
return Object.freeze({
async beginRegistration({ signal } = {}) {
const initialized = assertRegistrationOk(await requestJson(
@ -842,89 +929,24 @@ export function createDingtalkApi({
return true;
},
async sendFile({ clientId, clientSecret, target, file, signal }) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A DingTalk file is required');
}
const normalizedTarget = normalizeFileTarget(target);
const fileType = dingtalkFileType(file.fileName);
let token;
try {
token = await accessToken({ clientId, clientSecret, signal });
} 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 dingtalkArtifactError(error, { fallback });
}
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
uploadUrl.searchParams.set('access_token', token);
uploadUrl.searchParams.set('type', 'file');
const form = new FormData();
form.append(
'media',
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
let uploaded;
try {
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
} 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 dingtalkArtifactError(error, { fallback });
}
const uploadRejection = rejectedProviderResponse(uploaded);
if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
throw dingtalkArtifactError(new DingtalkApiError(
'upload-rejected',
'钉钉服务拒绝了文件上传。',
{ providerCode: uploadRejection ?? 'missing-media-id' },
));
}
signal?.throwIfAborted();
const messageBody = {
robotCode: normalizedTarget.robotCode,
msgKey: 'sampleFile',
msgParam: JSON.stringify({
mediaId: uploaded.media_id,
async sendFile(request) {
return sendArtifact(request, {
uploadType: 'file',
messageKey: 'sampleFile',
createMessageParams: (mediaId, file) => ({
mediaId,
fileName: file.fileName,
fileType,
fileType: dingtalkFileType(file.fileName),
}),
...(normalizedTarget.type === 'group'
? { openConversationId: normalizedTarget.openConversationId }
: { userIds: [normalizedTarget.userId] }),
};
const pathname = normalizedTarget.type === 'group'
? 'v1.0/robot/groupMessages/send'
: 'v1.0/robot/oToMessages/batchSend';
let response;
try {
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
body: messageBody,
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '文件消息发送',
});
} catch (error) {
throw classifyDingtalkFinalDeliveryError(error, signal);
}
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw dingtalkArtifactError(new DingtalkApiError(
'send-rejected',
'钉钉服务拒绝了文件消息。',
{ providerCode: sendRejection },
));
}
return response;
});
},
async sendImage(request) {
return sendArtifact(request, {
uploadType: 'image',
messageKey: 'sampleImageMsg',
createMessageParams: (mediaId) => ({ photoURL: mediaId }),
});
},
clearAccessToken(clientId) {

View file

@ -35,14 +35,9 @@ import {
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
@ -1084,70 +1079,39 @@ export class DingtalkHarnessBridge {
}
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, 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('DingTalk 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({
clientId: this.#clientId,
clientSecret: this.#clientSecret,
target,
file,
signal: this.#signal,
});
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'dingtalk-file',
providerMessageIds: dingtalkFileProviderIds(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-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let noticeSent = false;
const providerMessageIds = await this.#send(
sessionWebhook,
artifactFailureText(artifact?.fileName, error),
).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 ? 'dingtalk-text-and-files' : 'dingtalk-files',
receipts,
});
return { receipt, userVisible };
const sendArtifact = async (method, file) => dingtalkFileProviderIds(
await this.#api[method]({
clientId: this.#clientId,
clientSecret: this.#clientSecret,
target,
file,
signal: this.#signal,
}),
);
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
channelKey: 'dingtalk',
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,
sendFailureNotice: (artifact, error) => this.#send(
sessionWebhook,
artifactFailureText(artifact?.fileName, error),
),
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 };
}
}

View file

@ -38,14 +38,9 @@ import {
} from '../shared/preset-command.mjs';
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
} from '../shared/semantic/delivery.mjs';
import {
MENU_PAGE_SIZE,
@ -1641,64 +1636,50 @@ export class FeishuHarnessBridge {
}
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
const receipts = baseReceipt ? [baseReceipt] : [];
let failureNoticeVisible = false;
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#channel?.sendFile !== 'function') {
const unavailable = new Error('Feishu file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
receipts.push(await this.#channel.sendFile(chatId, file, {
replyTo,
signal: this.#signal,
}));
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-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let noticeMessageId = null;
try {
noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
failureNoticeVisible = true;
} catch {
this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
}
receipts.push(createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
}));
} finally {
releaseOutboundArtifact(artifact);
}
}
if (receipts.length === 0) {
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
aggregatePresentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
channelKey: 'feishu',
signal: this.#signal,
sendImage: typeof this.#channel?.sendImage === 'function'
? (file) => this.#channel.sendImage(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFile: typeof this.#channel?.sendFile === 'function'
? (file) => this.#channel.sendFile(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFailureNotice: async (artifact, error) => ({
messageId: await this.#send(
chatId,
artifactFailureText(artifact?.fileName, error),
),
}),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
if (!delivery.receipt) {
return {
receipt: createDeliveryReceipt({
deliveryId: replyTo,
presentation: 'feishu-files',
}),
failureNoticeVisible,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
const receipt = receipts.length === 1
? receipts[0]
: mergeDeliveryReceipts({
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
receipts,
});
return { receipt, failureNoticeVisible };
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
async #answerWithStream(event, key, message) {

View file

@ -114,9 +114,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
});
}
function deliveryUuid(file, chatId) {
function deliveryUuid(file, chatId, messageType) {
const seed = `${file.deliveryKey}\u0000${chatId}`;
const digest = createHash('sha256')
.update(`${file.deliveryKey}\u0000${chatId}`)
.update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
.digest('hex')
.slice(0, 40);
return `dshim_${digest}`;
@ -230,6 +231,29 @@ export class VerifiedFeishuChannel {
}
async sendFile(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'file',
presentation: 'feishu-file',
});
}
async sendImage(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'image',
presentation: 'feishu-image',
});
}
async #sendArtifact(chatId, file, {
replyTo,
signal,
messageType,
presentation,
}) {
signal?.throwIfAborted();
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
if (!file || typeof file !== 'object'
@ -243,13 +267,20 @@ export class VerifiedFeishuChannel {
try {
uploaded = await waitForFileOperation((operationSignal) => {
operationSignal.throwIfAborted();
const pending = this.#client.im.v1.file.create({
data: {
file_type: 'stream',
file_name: file.fileName,
file: file.bytes,
},
});
const pending = messageType === 'image'
? this.#client.im.v1.image.create({
data: {
image_type: 'message',
image: file.bytes,
},
})
: this.#client.im.v1.file.create({
data: {
file_type: 'stream',
file_name: file.fileName,
file: file.bytes,
},
});
trackOutboundArtifactProviderPromise(file, pending);
return pending;
}, {
@ -262,21 +293,25 @@ export class VerifiedFeishuChannel {
throw fileDeliveryError('upload', error);
}
signal?.throwIfAborted();
const fileKey = uploaded?.file_key;
if (typeof fileKey !== 'string' || !fileKey) {
const resourceKey = messageType === 'image'
? uploaded?.image_key ?? uploaded?.data?.image_key
: uploaded?.file_key ?? uploaded?.data?.file_key;
if (typeof resourceKey !== 'string' || !resourceKey) {
throw fileDeliveryError('upload', undefined, uploaded?.code);
}
const uuid = deliveryUuid(file, chatId);
const content = JSON.stringify({ file_key: fileKey });
const uuid = deliveryUuid(file, chatId, messageType);
const content = JSON.stringify(messageType === 'image'
? { image_key: resourceKey }
: { file_key: resourceKey });
const request = replyTo
? {
path: { message_id: replyTo },
data: { msg_type: 'file', content, uuid },
data: { msg_type: messageType, content, uuid },
}
: {
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, msg_type: 'file', content, uuid },
data: { receive_id: chatId, msg_type: messageType, content, uuid },
};
const send = () => {
const pending = replyTo
@ -300,7 +335,7 @@ export class VerifiedFeishuChannel {
operationSignal.throwIfAborted();
// Feishu documents 230049 as an uncertain asynchronous send result.
// Reuse the same file_key and UUID once so the provider can deduplicate.
// Reuse the same resource key and UUID once so the provider can deduplicate.
if (Number(result?.code) === 230049) {
result = await send();
operationSignal.throwIfAborted();
@ -324,7 +359,7 @@ export class VerifiedFeishuChannel {
}
return createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'feishu-file',
presentation,
providerMessageIds: [messageId],
artifacts: [{
artifactId: file.artifactId,

View file

@ -32,14 +32,11 @@ import {
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
trackOutboundArtifactProviderPromise,
} from '../shared/semantic/artifact.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
@ -210,7 +207,7 @@ function qqArtifactError(error, { dispatched = false } = {}) {
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
else if (status === 413) wrapped.code = 'artifact-too-large';
else if (status === 429) wrapped.code = 'artifact-rate-limited';
else if (status === 400 || status === 404) {
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
wrapped.code = 'artifact-provider-rejected';
} else {
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
@ -245,6 +242,68 @@ function waitWithSignal(promise, signal) {
});
}
/** Send one materialized artifact through QQ's native image message. */
export async function sendQqImage(
bot,
target,
file,
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
) {
signal?.throwIfAborted();
if (typeof bot?.sendImage !== 'function') {
const unavailable = new Error('QQ image delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
try {
const timeout = AbortSignal.timeout(timeoutMs);
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const pending = bot.sendImage(
target,
{ buffer: file.bytes },
{ onProgress: () => signal?.throwIfAborted() },
);
trackOutboundArtifactProviderPromise(file, pending);
return await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw qqArtifactError(error, { dispatched: true });
}
}
async function sendQqFile(
bot,
target,
file,
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
) {
signal?.throwIfAborted();
if (typeof bot?.sendFile !== 'function') {
const unavailable = new Error('QQ file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
try {
const timeout = AbortSignal.timeout(timeoutMs);
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const pending = bot.sendFile(
target,
{ buffer: file.bytes },
{
fileName: file.fileName,
onProgress: () => signal?.throwIfAborted(),
},
);
trackOutboundArtifactProviderPromise(file, pending);
return await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw qqArtifactError(error, { dispatched: true });
}
}
function canClaimInteractionReply(message, pending) {
return pending.questions[pending.index]
&& nonEmptyString(message?.senderId) === pending.actor
@ -491,80 +550,35 @@ export class QqHarnessBridge {
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.#bot.sendFile !== 'function') {
const unavailable = new Error('QQ file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
this.#signal?.throwIfAborted();
let result;
try {
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
const pending = this.#bot.sendFile(
target,
{ buffer: file.bytes },
{
fileName: file.fileName,
onProgress: () => this.#signal?.throwIfAborted(),
},
);
trackOutboundArtifactProviderPromise(file, pending);
result = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw qqArtifactError(error, { dispatched: true });
}
this.#signal?.throwIfAborted();
const messageId = nonEmptyString(result?.message?.id);
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'qq-file',
providerMessageIds: messageId ? [messageId] : [],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
} catch (rawError) {
if (this.#signal?.aborted) throw rawError;
const error = qqArtifactError(rawError);
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
this.#logger.warn?.(
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
);
let providerMessageIds = [];
try {
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
failureNoticeVisible = true;
providerMessageIds = providerMessageIdsFor(notice);
} catch (noticeError) {
if (this.#signal?.aborted) throw noticeError;
this.#logger.warn?.('[dsh-im:qq] 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 ? 'qq-text-and-files' : 'qq-files',
receipts,
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
alwaysMerge: true,
channelKey: 'qq',
signal: this.#signal,
sendImage: (file) => sendQqImage(this.#bot, target, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
failureNoticeVisible,
sendFile: (file) => sendQqFile(this.#bot, target, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error),
),
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,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}

View file

@ -0,0 +1,170 @@
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from './artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from './delivery.mjs';
function unavailableError() {
const error = new Error('Native file delivery is unavailable');
error.code = 'artifact-provider-unavailable';
return error;
}
function isAbort(error, signal) {
return signal?.aborted
|| error?.name === 'AbortError'
|| error?.code === 'ABORT_ERR';
}
function providerIds(value) {
if (Array.isArray(value)) {
return [...new Set(value
.filter((candidate) => (
(typeof candidate === 'string' && candidate.trim())
|| Number.isSafeInteger(candidate)
))
.map(String))];
}
return providerMessageIdsFor(value);
}
async function sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
}) {
if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
try {
return {
presentation: 'image',
result: await sendImage(file),
};
} catch (error) {
if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
throw error;
}
}
}
signal?.throwIfAborted();
if (typeof sendFile !== 'function') throw unavailableError();
return {
presentation: 'file',
result: await sendFile(file),
};
}
/**
* Deliver registered artifacts with one shared image-first policy while keeping
* provider protocol details inside the channel-supplied send closures.
*/
export async function deliverOutboundArtifacts({
artifacts = [],
baseReceipt = null,
deliveryId,
aggregatePresentation,
alwaysMerge = false,
channelKey,
signal,
sendFile,
sendImage,
sendFailureNotice,
logger,
}) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt);
let failureNoticeVisible = false;
let artifactsSent = 0;
let artifactSendErrors = 0;
let artifactIndex = 0;
try {
while (artifactIndex < artifacts.length) {
const artifact = artifacts[artifactIndex];
artifactIndex += 1;
try {
signal?.throwIfAborted();
const file = await materializeOutboundArtifact(artifact, { signal });
signal?.throwIfAborted();
const sent = await sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
});
signal?.throwIfAborted();
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: `${channelKey}-${sent.presentation}`,
providerMessageIds: providerIds(sent.result),
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
artifactsSent += 1;
userVisible = true;
} catch (error) {
if (isAbort(error, signal)) throw error;
artifactSendErrors += 1;
logger?.warn?.(
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
);
let messageIds = [];
if (typeof sendFailureNotice === 'function') {
try {
signal?.throwIfAborted();
const notice = await sendFailureNotice(artifact, error);
signal?.throwIfAborted();
messageIds = providerIds(notice);
failureNoticeVisible = true;
} catch (noticeError) {
if (isAbort(noticeError, signal)) throw noticeError;
logger?.warn?.(
`[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
);
}
}
const failureReceipt = createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds: messageIds,
});
receipts.push(failureReceipt);
if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
userVisible = true;
}
} finally {
releaseOutboundArtifact(artifact);
}
}
} finally {
while (artifactIndex < artifacts.length) {
releaseOutboundArtifact(artifacts[artifactIndex]);
artifactIndex += 1;
}
}
let receipt = null;
if (receipts.length === 1 && !alwaysMerge) {
[receipt] = receipts;
} else if (receipts.length > 0) {
receipt = mergeDeliveryReceipts({
deliveryId: deliveryId
?? baseReceipt?.deliveryId
?? artifacts[0]?.deliveryKey,
presentation: aggregatePresentation
?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
receipts,
});
}
return {
receipt,
userVisible,
failureNoticeVisible,
artifactsSent,
artifactSendErrors,
};
}

View file

@ -576,7 +576,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
};
const definition = Object.freeze({
name: OUTBOUND_ARTIFACT_TOOL,
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
parameters: {
type: 'object',
additionalProperties: false,
@ -654,7 +654,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
ctx.systemPrompt.section({
name: 'dsh-im:return-file',
order: 115,
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
});
return true;
}

View file

@ -34,6 +34,8 @@ export function providerMessageIdsFor(value) {
value.message?.ts,
value.key?.id,
value.data?.message_id,
value.body?.msgid,
value.body?.message_id,
];
const id = candidates.find((candidate) => (
(typeof candidate === 'string' && candidate.trim())

View file

@ -32,14 +32,9 @@ import {
harnessQuestionText,
validHarnessQuestion,
} from './harness-question.mjs';
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from './semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from './semantic/delivery.mjs';
@ -339,73 +334,29 @@ export class TextHarnessBridge {
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt);
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#bot.sendFile !== 'function') {
const unavailable = new Error('Native file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
this.#signal?.throwIfAborted();
const result = await this.#bot.sendFile(target, file);
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: `${this.#descriptor.key}-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-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let providerMessageIds = [];
let noticeSent = false;
try {
const notice = await this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
);
providerMessageIds = providerMessageIdsFor(notice);
noticeSent = true;
} catch {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
);
}
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
? `${this.#descriptor.key}-text-and-files`
: `${this.#descriptor.key}-files`,
receipts,
});
return { receipt, userVisible };
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: this.#descriptor.key,
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: typeof this.#bot.sendFile === 'function'
? (file) => this.#bot.sendFile(target, file)
: undefined,
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
),
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 };
}
async #process(message, messageId, senderId, conversationKey, {

View file

@ -35,33 +35,33 @@ function preserveProviderMetadata(target, source) {
return target;
}
function telegramArtifactProviderError(cause) {
function telegramArtifactProviderError(cause, mediaLabel = 'document') {
const providerCode = Number(cause?.providerCode);
const status = Number(cause?.status);
const message = cleanString(cause?.message) ?? '';
let code = 'artifact-provider-rejected';
let summary = 'Telegram rejected the document.';
let summary = `Telegram rejected the ${mediaLabel}.`;
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
code = 'artifact-permission-required';
summary = 'Telegram denied permission to send the document.';
summary = `Telegram denied permission to send the ${mediaLabel}.`;
} else if (providerCode === 413 || status === 413
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
code = 'artifact-too-large';
summary = 'The document exceeds Telegram\'s size limit.';
summary = `The ${mediaLabel} exceeds Telegram's size limit.`;
} else if (providerCode === 429 || status === 429) {
code = 'artifact-rate-limited';
summary = 'Telegram rate-limited document delivery.';
summary = `Telegram rate-limited ${mediaLabel} delivery.`;
} else if (providerCode >= 500 || status >= 500) {
code = 'artifact-delivery-uncertain';
summary = 'Telegram document delivery result is uncertain.';
summary = `Telegram ${mediaLabel} delivery result is uncertain.`;
}
const error = new Error(summary, { cause });
error.code = code;
return preserveProviderMetadata(error, cause);
}
function uncertainTelegramDelivery(cause) {
const error = new Error('Telegram document delivery result is uncertain', { cause });
function uncertainTelegramDelivery(cause, mediaLabel = 'document') {
const error = new Error(`Telegram ${mediaLabel} delivery result is uncertain`, { cause });
error.code = 'artifact-delivery-uncertain';
return preserveProviderMetadata(error, cause);
}
@ -184,15 +184,41 @@ export class TelegramApi {
}
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
return this.#sendArtifact('sendDocument', 'document', 'document', {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
});
}
async sendPhoto({ chatId, file, replyToMessageId, messageThreadId, signal }) {
return this.#sendArtifact('sendPhoto', 'photo', 'photo', {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
});
}
async #sendArtifact(method, fieldName, mediaLabel, {
chatId,
file,
replyToMessageId,
messageThreadId,
signal,
}) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A Telegram document is required');
throw new TypeError(`A Telegram ${mediaLabel} is required`);
}
const payload = new FormData();
payload.append('chat_id', String(chatId));
payload.append(
'document',
fieldName,
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
@ -206,7 +232,7 @@ export class TelegramApi {
if (signal?.aborted) throw abortReason(signal);
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
try {
return await this.#call('sendDocument', payload, {
return await this.#call(method, payload, {
signal: uploadSignal,
timeoutMs: this.#fileUploadTimeoutMs,
multipart: true,
@ -214,9 +240,9 @@ export class TelegramApi {
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
if (error?.code?.startsWith?.('telegram-')) {
throw telegramArtifactProviderError(error);
throw telegramArtifactProviderError(error, mediaLabel);
}
throw uncertainTelegramDelivery(error);
throw uncertainTelegramDelivery(error, mediaLabel);
}
}

View file

@ -211,6 +211,16 @@ export class TelegramBotClient {
});
}
sendImage(target, file) {
return this.#api.sendPhoto({
chatId: target.chatId,
file,
replyToMessageId: target.replyToMessageId,
messageThreadId: target.messageThreadId,
signal: this.#signal,
});
}
async openStream(target) {
const stream = createEditableMessageStream({
limit: 4_000,

View file

@ -31,15 +31,10 @@ import {
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.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;
@ -353,6 +348,71 @@ function wecomArtifactError(error, { dispatched = false } = {}) {
return wrapped;
}
async function sendWecomMedia(
client,
chatId,
file,
mediaType,
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
) {
signal?.throwIfAborted();
if (typeof client?.uploadMedia !== 'function'
|| typeof client?.sendMediaMessage !== 'function') {
const unavailable = new Error(`Enterprise WeChat ${mediaType} delivery is unavailable`);
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const timeout = AbortSignal.timeout(timeoutMs);
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let uploaded;
try {
const pending = client.uploadMedia(file.bytes, {
type: mediaType,
filename: file.fileName,
});
trackOutboundArtifactProviderPromise(file, pending);
uploaded = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw wecomArtifactError(error);
}
signal?.throwIfAborted();
const mediaId = nonEmptyString(uploaded?.media_id);
if (!mediaId) {
const rejected = new Error(`Enterprise WeChat ${mediaType} upload returned no media id`);
rejected.code = 'artifact-provider-rejected';
throw rejected;
}
let sent;
try {
const pending = client.sendMediaMessage(chatId, mediaType, mediaId);
trackOutboundArtifactProviderPromise(file, pending);
sent = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw wecomArtifactError(error, { dispatched: true });
}
signal?.throwIfAborted();
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
if (Number.isFinite(providerCode) && providerCode !== 0) {
throw wecomArtifactError({ providerCode });
}
return sent;
}
/** Send one materialized artifact through Enterprise WeChat's native image message. */
export function sendWecomImage(client, chatId, file, options) {
return sendWecomMedia(client, chatId, file, 'image', options);
}
function sendWecomFile(client, chatId, file, options) {
return sendWecomMedia(client, chatId, file, 'file', options);
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
@ -650,98 +710,35 @@ export class WecomHarnessBridge {
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,
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
alwaysMerge: true,
channelKey: 'wecom',
signal: this.#signal,
sendImage: (file) => sendWecomImage(this.#client, chatId, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
failureNoticeVisible,
sendFile: (file) => sendWecomFile(this.#client, chatId, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFailureNotice: (artifact, error) => this.#sendActive(
chatId,
artifactFailureText(artifact?.fileName, error),
),
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,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}

View file

@ -457,6 +457,123 @@ function validateLoginResponse(value) {
export function createWeixinApi({ fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
async function sendArtifact({
baseUrl,
token,
toUserId,
file,
contextToken,
runId,
signal,
}, { mediaType, createItem }) {
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: mediaType,
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 clientIdSeed = mediaType === 3 ? deliverySeed : `${deliverySeed}\u0000${mediaType}`;
const clientId = `dsh-weixin-${createHash('sha256')
.update(clientIdSeed)
.digest('hex')
.slice(0, 32)}`;
const media = {
encrypt_query_param: downloadParam,
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
encrypt_type: 1,
};
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: [createItem({ file, media, ciphertextSize: ciphertext.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 };
}
return Object.freeze({
inboundImages(message) {
return extractWeixinImages(message, { fetchImpl });
@ -549,115 +666,31 @@ 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(),
async sendFile(request) {
return sendArtifact(request, {
mediaType: 3,
createItem: ({ file, media }) => ({
type: 4,
file_item: {
media,
file_name: file.fileName,
len: String(file.bytes.byteLength),
},
});
} 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(),
}),
});
},
async sendImage(request) {
return sendArtifact(request, {
mediaType: 1,
createItem: ({ media, ciphertextSize }) => ({
type: 2,
image_item: {
media,
mid_size: ciphertextSize,
},
});
} 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 }) {

View file

@ -38,14 +38,9 @@ import {
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
@ -869,73 +864,40 @@ export class WeixinHarnessBridge {
}
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 };
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,
sendFailureNotice: (artifact, error) => this.#send(
toUserId,
artifactFailureText(artifact?.fileName, error),
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 };
}
}

View file

@ -313,12 +313,31 @@ function waitWithSignal(promise, signal) {
function uncertainArtifactDelivery(error) {
if (error?.code === 'artifact-delivery-uncertain') return error;
const uncertain = new Error('WhatsApp could not confirm result file delivery.');
const uncertain = new Error('WhatsApp could not confirm artifact delivery.');
uncertain.code = 'artifact-delivery-uncertain';
uncertain.cause = error;
return uncertain;
}
function whatsappArtifactError(error) {
if (error?.code?.startsWith?.('artifact-')) return error;
const status = error?.output?.statusCode
?? error?.data?.statusCode
?? error?.statusCode;
let code;
if (status === 401 || status === 403) code = 'artifact-permission-required';
else if (status === 413) code = 'artifact-too-large';
else if (status === 429) code = 'artifact-rate-limited';
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
code = 'artifact-provider-rejected';
}
if (!code) return uncertainArtifactDelivery(error);
const wrapped = new Error('WhatsApp rejected artifact delivery.');
wrapped.code = code;
wrapped.cause = error;
return wrapped;
}
export class WhatsappBotClient {
#socket;
#outboundIds;
@ -354,14 +373,32 @@ export class WhatsappBotClient {
}
async sendFile(target, file) {
return this.#sendArtifact(target, file, {
document: file.bytes,
mimetype: file.mediaType ?? 'application/octet-stream',
fileName: file.fileName,
}, 'file');
}
async sendImage(target, file) {
return this.#sendArtifact(target, file, {
image: file.bytes,
mimetype: file.mediaType ?? 'image/jpeg',
}, 'image');
}
async #sendArtifact(target, file, content, presentation) {
this.#signal?.throwIfAborted();
await this.#stopTyping(target.jid);
this.#signal?.throwIfAborted();
const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
? file.deliveryKey
: file.artifactId;
const messageId = typeof deliverySeed === 'string' && deliverySeed
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 20).toUpperCase()
const messageIdSeed = presentation === 'image'
? `${deliverySeed}:image`
: deliverySeed;
const messageId = typeof messageIdSeed === 'string' && messageIdSeed
? createHash('sha256').update(messageIdSeed).digest('hex').slice(0, 20).toUpperCase()
: undefined;
const options = {
...(target.quoted ? { quoted: target.quoted } : {}),
@ -375,11 +412,7 @@ export class WhatsappBotClient {
try {
const pending = this.#socket.sendMessage(
target.jid,
{
document: file.bytes,
mimetype: file.mediaType ?? 'application/octet-stream',
fileName: file.fileName,
},
content,
options,
);
trackOutboundArtifactProviderPromise(file, pending);
@ -390,7 +423,7 @@ export class WhatsappBotClient {
result = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw uncertainArtifactDelivery(error);
throw whatsappArtifactError(error);
}
this.#signal?.throwIfAborted();
this.#outboundIds.remember(result?.key?.id);