mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-13 06:30:45 +08:00
feat: add native image delivery across channels
This commit is contained in:
parent
dbc0a4b730
commit
3ec5f1eca7
31 changed files with 2754 additions and 846 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue