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 };
}
}