dsh-im-ops/src/channels/dingtalk/dingtalk-api.mjs

961 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
const DEFAULT_TIMEOUT_MS = 15_000;
const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
export class DingtalkApiError extends Error {
constructor(code, message, options = {}) {
super(message, options);
this.name = 'DingtalkApiError';
this.code = code;
this.status = options.status;
this.providerCode = options.providerCode;
}
}
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 dingtalkArtifactError(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 = 'DingTalk could not prepare the file for delivery.';
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|| /(?:permission|forbidden|unauthor|access.?denied|\.auth(?:\.|$))/i.test(providerText)) {
code = 'artifact-permission-required';
message = 'DingTalk 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 DingTalk\'s size limit.';
} else if (status === 429 || providerCode === '429'
|| /(?:rate.?limit|too.?many|throttl)/i.test(providerText)) {
code = 'artifact-rate-limited';
message = 'DingTalk rate-limited file delivery.';
} else if (fallback === 'artifact-provider-rejected') {
message = 'DingTalk rejected the file message.';
}
const error = new Error(message, { cause });
error.code = code;
return preserveArtifactMetadata(error, cause);
}
function uncertainDingtalkDelivery(cause) {
const error = new Error('DingTalk 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 ['errcode', 'code']) {
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
return safeProviderCode(value[field]) ?? 'rejected';
}
}
return null;
}
function classifyDingtalkFinalDeliveryError(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 uncertainDingtalkDelivery(error);
}
return dingtalkArtifactError(error);
}
function secureDingtalkDownloadUrl(value) {
let url;
try {
url = new URL(value);
} catch (error) {
throw new DingtalkApiError('invalid-image-download', '钉钉服务返回了无效的图片下载地址。', { cause: error });
}
if (url.protocol === 'http:' && (!url.port || url.port === '80')) {
url.protocol = 'https:';
url.port = '';
}
return url;
}
function isDingtalkHost(hostname) {
const normalized = hostname.toLowerCase().replace(/\.$/, '');
return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com');
}
function normalizeTrustedUrl(value, { label, requireSubdomain = true } = {}) {
let url;
try {
url = new URL(value);
} catch {
throw new DingtalkApiError('invalid-url', `${label ?? '钉钉服务'}返回了无效地址。`);
}
const normalizedHost = url.hostname.toLowerCase().replace(/\.$/, '');
const trustedHost = requireSubdomain
? normalizedHost !== 'dingtalk.com' && isDingtalkHost(normalizedHost)
: isDingtalkHost(normalizedHost);
if (url.protocol !== 'https:' || !trustedHost || (url.port && url.port !== '443')) {
throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`);
}
if (url.username || url.password) {
throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`);
}
return url;
}
export function normalizeDingtalkSessionWebhook(value) {
const text = nonEmptyString(value);
if (!text) throw new DingtalkApiError('invalid-session-webhook', '钉钉消息没有可用的回复地址。');
const url = normalizeTrustedUrl(text, { label: '钉钉回复', requireSubdomain: false });
url.hash = '';
return url.toString();
}
export function splitDingtalkText(value, maxChars = 4_000) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) return [];
if (!Number.isInteger(maxChars) || maxChars < 1) throw new TypeError('maxChars must be a positive integer');
if (text.length <= maxChars) return [text];
const chunks = [];
let remaining = text;
while (remaining.length > maxChars) {
let splitAt = remaining.lastIndexOf('\n', maxChars);
if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
}
if (remaining) chunks.push(remaining);
return chunks;
}
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
return new DOMException('The operation was aborted', 'AbortError');
}
function abortableDelay(ms, signal) {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortError(signal));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(abortError(signal));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
async function requestJson(fetchImpl, url, {
body,
signal,
timeoutMs = DEFAULT_TIMEOUT_MS,
headers = {},
method = 'POST',
action = 'request',
} = {}) {
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(signal?.reason);
if (signal?.aborted) throw abortError(signal);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = timeoutMs > 0 ? setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs) : null;
try {
const response = await fetchImpl(url, {
method,
redirect: 'error',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body ?? {}),
signal: controller.signal,
});
if (!response.ok) {
let providerCode;
try {
const errorBody = await response.json();
providerCode = safeProviderCode(errorBody?.code ?? errorBody?.errcode);
} catch {
// DingTalk occasionally returns an empty or non-JSON error body.
}
throw new DingtalkApiError(
'http-error',
`钉钉服务请求失败(HTTP ${response.status})。`,
{ status: response.status, providerCode },
);
}
try {
return await response.json();
} catch (error) {
throw new DingtalkApiError('invalid-response', '钉钉服务返回了无法解析的响应。', { cause: error });
}
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError('network-error', `暂时无法完成钉钉${action}请求。`, { cause: error });
} finally {
if (timer) clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function requestMultipart(fetchImpl, url, { body, signal, timeoutMs = 60_000 } = {}) {
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(signal?.reason);
if (signal?.aborted) throw abortError(signal);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
const response = await fetchImpl(url, {
method: 'POST',
body,
signal: controller.signal,
redirect: 'error',
});
let value;
let parseError;
try {
value = await response.json();
} catch (error) {
parseError = error;
}
if (!response.ok) {
throw new DingtalkApiError(
'http-error',
`钉钉服务请求失败(HTTP ${response.status})。`,
{ status: response.status, providerCode: safeProviderCode(value?.code ?? value?.errcode) },
);
}
if (parseError) {
throw new DingtalkApiError(
'invalid-response',
'钉钉服务返回了无法解析的响应。',
{ cause: parseError },
);
}
return value;
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError('network-error', '暂时无法完成钉钉文件上传请求。', { cause: error });
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
function normalizeFileTarget(target) {
const robotCode = nonEmptyString(target?.robotCode);
if (!robotCode) throw new TypeError('DingTalk robotCode is required');
if (target?.type === 'group') {
const openConversationId = nonEmptyString(target.openConversationId);
if (openConversationId) return { type: 'group', robotCode, openConversationId };
}
if (target?.type === 'user') {
const userId = nonEmptyString(target.userId);
if (userId) return { type: 'user', robotCode, userId };
}
throw new TypeError('DingTalk file target is invalid');
}
function dingtalkFileType(fileName) {
return extname(fileName).slice(1).toLowerCase();
}
function normalizeCardTarget(target) {
if (target?.type === 'user') {
const userId = nonEmptyString(target.userId);
if (userId) return { type: 'user', userId };
}
if (target?.type === 'group') {
const openConversationId = nonEmptyString(target.openConversationId);
if (openConversationId) return { type: 'group', openConversationId };
}
throw new TypeError('DingTalk AI Card target is invalid');
}
function cardData(text, flowStatus) {
return {
cardParamMap: {
flowStatus,
msgContent: normalizeDingtalkCardMarkdown(text),
staticMsgContent: '',
sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
config: JSON.stringify({ autoLayout: true }),
},
};
}
function cardDeliverBody(cardInstanceId, target, robotCode) {
const base = { outTrackId: cardInstanceId, userIdType: 1 };
if (target.type === 'group') {
return {
...base,
openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
imGroupOpenDeliverModel: { robotCode },
};
}
return {
...base,
openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
imRobotOpenDeliverModel: {
spaceType: 'IM_ROBOT',
robotCode,
extension: { dynamicSummary: 'true' },
},
};
}
export function normalizeDingtalkCardMarkdown(value) {
const text = typeof value === 'string' ? value.replace(/\r\n?/g, '\n') : '';
const lines = text.split('\n');
let inCodeBlock = false;
return lines.map((line, index) => {
const fenced = /^\s{0,3}```/.test(line);
const currentInCodeBlock = inCodeBlock;
if (fenced) inCodeBlock = !inCodeBlock;
if (index === lines.length - 1) return line;
if (currentInCodeBlock || fenced || inCodeBlock || !line || !lines[index + 1]) return `${line}\n`;
if (/^\s{0,3}(?:[-*+] |\d+[.)] |#{1,6} |\||> )/.test(lines[index + 1])) return `${line}\n`;
return `${line}<br>`;
}).join('');
}
function assertRegistrationOk(value, action) {
if (!value || typeof value !== 'object' || value.errcode !== 0) {
throw new DingtalkApiError(
'registration-rejected',
`钉钉扫码${action}失败。`,
);
}
return value;
}
function positiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
export function createDingtalkApi({
fetchImpl = fetch,
registrationBaseUrl = process.env.DINGTALK_REGISTRATION_BASE_URL
|| DINGTALK_REGISTRATION_BASE_URL,
registrationSource = process.env.DINGTALK_REGISTRATION_SOURCE
|| DINGTALK_REGISTRATION_SOURCE,
now = () => Date.now(),
cardMinIntervalMs = 50,
cardBackoffMs = 1_000,
delay = abortableDelay,
} = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
if (typeof now !== 'function') throw new TypeError('now must be a function');
if (!Number.isFinite(cardMinIntervalMs) || cardMinIntervalMs < 0) {
throw new TypeError('cardMinIntervalMs must be a non-negative number');
}
if (!Number.isFinite(cardBackoffMs) || cardBackoffMs < 0) {
throw new TypeError('cardBackoffMs must be a non-negative number');
}
if (typeof delay !== 'function') throw new TypeError('delay must be a function');
const registrationBase = normalizeTrustedUrl(registrationBaseUrl, {
label: '钉钉注册服务',
requireSubdomain: false,
});
const apiBase = new URL(DINGTALK_API_BASE_URL);
const source = nonEmptyString(registrationSource);
if (!source) throw new TypeError('registrationSource is required');
const tokenCache = new Map();
const tokenRequests = new Map();
let cardSlotTail = Promise.resolve();
let nextCardRequestAt = 0;
const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
async function accessToken({ clientId, clientSecret, signal }) {
const appKey = nonEmptyString(clientId);
const appSecret = nonEmptyString(clientSecret);
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
const cached = tokenCache.get(appKey);
if (cached && cached.expiresAt > now()) return cached.token;
if (tokenRequests.has(appKey)) return tokenRequests.get(appKey);
const request = (async () => {
const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
body: { appKey, appSecret },
signal,
action: '鉴权',
});
const token = nonEmptyString(value?.accessToken);
if (!token) throw new DingtalkApiError('invalid-access-token', '钉钉服务没有返回访问令牌。');
const expiresInSeconds = positiveNumber(value?.expireIn ?? value?.expiresIn, 7_200);
const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
return token;
})().finally(() => tokenRequests.delete(appKey));
tokenRequests.set(appKey, request);
return request;
}
async function messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind,
}) {
const botCode = nonEmptyString(robotCode);
const fileCode = nonEmptyString(downloadCode);
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
const token = await accessToken({ clientId, clientSecret, signal });
let response;
try {
response = await requestJson(
fetchImpl,
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
{
body: { downloadCode: fileCode, robotCode: botCode },
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
},
);
} catch (error) {
if (signal?.aborted) throw error;
throw new DingtalkApiError(
`${kind}-download-address-failed`,
`钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
{ cause: error, status: error?.status, providerCode: error?.providerCode },
);
}
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
if (!downloadUrl) {
throw new DingtalkApiError(
`invalid-${kind}-download`,
`钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
);
}
return secureDingtalkDownloadUrl(downloadUrl);
}
function acquireCardRequestSlot(signal) {
const acquire = async () => {
const waitMs = Math.max(0, nextCardRequestAt - now());
if (waitMs > 0) await delay(waitMs, signal);
nextCardRequestAt = Math.max(nextCardRequestAt, now()) + cardMinIntervalMs;
};
const slot = cardSlotTail.then(acquire, acquire);
cardSlotTail = slot.catch(() => undefined);
return slot;
}
async function cardRequest(pathname, options) {
await acquireCardRequestSlot(options.signal);
try {
return await requestJson(fetchImpl, endpoint(apiBase, pathname), options);
} catch (error) {
if (!(error instanceof DingtalkApiError) || error.status !== 403) throw error;
await delay(cardBackoffMs, options.signal);
await acquireCardRequestSlot(options.signal);
return requestJson(fetchImpl, endpoint(apiBase, pathname), options);
}
}
async function failCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
const instanceId = nonEmptyString(cardInstanceId);
const content = nonEmptyString(text);
if (!instanceId) throw new TypeError('cardInstanceId is required');
if (!content) throw new TypeError('text is required');
const token = await accessToken({ clientId, clientSecret, signal });
const headers = { 'x-acs-dingtalk-access-token': token };
const requests = [
cardRequest('v1.0/card/streaming', {
method: 'PUT',
body: {
outTrackId: instanceId,
guid: randomUUID(),
key: 'msgContent',
content: normalizeDingtalkCardMarkdown(content),
isFull: true,
isFinalize: false,
isError: true,
},
headers,
signal,
action: 'AI Card 失败收口',
}),
cardRequest('v1.0/card/instances', {
method: 'PUT',
body: {
outTrackId: instanceId,
cardData: cardData(content, '5'),
cardUpdateOptions: { updateCardDataByKey: true },
},
headers,
signal,
action: 'AI Card 失败状态',
}),
];
const results = await Promise.allSettled(requests);
if (results.every(({ status }) => status === 'rejected')) throw results[0].reason;
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(
fetchImpl,
endpoint(registrationBase, 'app/registration/init'),
{ body: { source }, signal, action: '初始化' },
), '初始化');
const nonce = nonEmptyString(initialized.nonce);
if (!nonce) throw new DingtalkApiError('invalid-registration', '钉钉扫码初始化缺少 nonce。');
const begun = assertRegistrationOk(await requestJson(
fetchImpl,
endpoint(registrationBase, 'app/registration/begin'),
{ body: { nonce }, signal, action: '创建' },
), '创建');
const deviceCode = nonEmptyString(begun.device_code);
const verificationUriComplete = nonEmptyString(begun.verification_uri_complete);
if (!deviceCode || !verificationUriComplete) {
throw new DingtalkApiError('invalid-registration', '钉钉扫码服务返回的信息不完整。');
}
const verificationUrl = normalizeTrustedUrl(verificationUriComplete, {
label: '钉钉扫码',
requireSubdomain: false,
}).toString();
return {
deviceCode,
userCode: nonEmptyString(begun.user_code) ?? undefined,
verificationUri: nonEmptyString(begun.verification_uri) ?? undefined,
verificationUriComplete: verificationUrl,
expiresInSeconds: positiveNumber(begun.expires_in, 7_200),
intervalSeconds: positiveNumber(begun.interval, 5),
};
},
async pollRegistration({ deviceCode, signal } = {}) {
const code = nonEmptyString(deviceCode);
if (!code) throw new TypeError('deviceCode is required');
const polled = assertRegistrationOk(await requestJson(
fetchImpl,
endpoint(registrationBase, 'app/registration/poll'),
{ body: { device_code: code }, signal, action: '状态查询' },
), '状态查询');
const status = nonEmptyString(polled.status)?.toUpperCase();
if (!status || !REGISTRATION_STATUSES.has(status)) {
throw new DingtalkApiError('invalid-registration-status', '钉钉扫码服务返回了无法识别的状态。');
}
const result = {
status,
failReason: nonEmptyString(polled.fail_reason) ?? undefined,
};
if (status === 'SUCCESS') {
result.clientId = nonEmptyString(polled.client_id) ?? undefined;
result.clientSecret = nonEmptyString(polled.client_secret) ?? undefined;
if (!result.clientId || !result.clientSecret) {
throw new DingtalkApiError('missing-credentials', '钉钉扫码已确认,但没有返回机器人凭据。');
}
}
return result;
},
accessToken,
async downloadImage({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
maxBytes,
}) {
const downloadUrl = await messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind: 'image',
});
try {
return await fetchImageBuffer(downloadUrl, {
fetchImpl,
signal,
maxBytes,
});
} catch (error) {
if (signal?.aborted || error instanceof ImagePromptError) throw error;
throw new DingtalkApiError(
'image-content-download-failed',
'钉钉图片内容下载失败。',
{ cause: error },
);
}
},
async downloadFile({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
}) {
const downloadUrl = await messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind: 'file',
});
if (downloadUrl.protocol !== 'https:') {
throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
}
try {
const response = await fetchImpl(downloadUrl, {
method: 'GET',
redirect: 'follow',
signal,
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new DingtalkApiError(
'file-content-download-failed',
`钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
{ status: response?.status },
);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError(
'file-content-download-failed',
'钉钉文件内容下载失败。',
{ cause: error },
);
}
},
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
const appKey = nonEmptyString(clientId);
const appSecret = nonEmptyString(clientSecret);
const content = nonEmptyString(initialText);
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
if (!content) throw new TypeError('initialText is required');
const normalizedTarget = normalizeCardTarget(target);
const token = await accessToken({ clientId: appKey, clientSecret: appSecret, signal });
const cardInstanceId = `dsh_${randomUUID()}`;
const headers = { 'x-acs-dingtalk-access-token': token };
let delivered = false;
try {
await cardRequest('v1.0/card/instances', {
body: {
cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID,
outTrackId: cardInstanceId,
cardData: {
cardParamMap: { config: JSON.stringify({ autoLayout: true }) },
},
callbackType: 'STREAM',
imGroupOpenSpaceModel: { supportForward: true },
imRobotOpenSpaceModel: { supportForward: true },
},
headers,
signal,
action: 'AI Card 创建',
});
await cardRequest('v1.0/card/instances/deliver', {
body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
headers,
signal,
action: 'AI Card 投放',
});
delivered = true;
await cardRequest('v1.0/card/instances', {
method: 'PUT',
body: { outTrackId: cardInstanceId, cardData: cardData(content, '2') },
headers,
signal,
action: 'AI Card 启动',
});
await cardRequest('v1.0/card/streaming', {
method: 'PUT',
body: {
outTrackId: cardInstanceId,
guid: randomUUID(),
key: 'msgContent',
content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''),
isFull: true,
isFinalize: false,
isError: false,
},
headers,
signal,
action: 'AI Card 启动',
});
} catch (error) {
if (delivered) {
const cleanupSignal = AbortSignal.timeout(5_000);
await failCard({
clientId: appKey,
clientSecret: appSecret,
cardInstanceId,
text: '消息处理失败,请稍后重试。',
signal: cleanupSignal,
}).catch(() => undefined);
}
throw error;
}
return { cardInstanceId };
},
async updateAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
const instanceId = nonEmptyString(cardInstanceId);
const content = nonEmptyString(text);
if (!instanceId) throw new TypeError('cardInstanceId is required');
if (!content) throw new TypeError('text is required');
const token = await accessToken({ clientId, clientSecret, signal });
await cardRequest('v1.0/card/streaming', {
method: 'PUT',
body: {
outTrackId: instanceId,
guid: randomUUID(),
key: 'msgContent',
content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''),
isFull: true,
isFinalize: false,
isError: false,
},
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: 'AI Card 更新',
});
return true;
},
async finishAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) {
const instanceId = nonEmptyString(cardInstanceId);
const content = nonEmptyString(text);
if (!instanceId) throw new TypeError('cardInstanceId is required');
if (!content) throw new TypeError('text is required');
const token = await accessToken({ clientId, clientSecret, signal });
const headers = { 'x-acs-dingtalk-access-token': token };
const normalizedContent = normalizeDingtalkCardMarkdown(content);
await cardRequest('v1.0/card/streaming', {
method: 'PUT',
body: {
outTrackId: instanceId,
guid: randomUUID(),
key: 'msgContent',
content: normalizedContent,
isFull: true,
isFinalize: true,
isError: false,
},
headers,
signal,
action: 'AI Card 完成',
});
let completed = true;
const completionRequest = {
method: 'PUT',
body: {
outTrackId: instanceId,
cardData: cardData(content, '3'),
cardUpdateOptions: { updateCardDataByKey: true },
},
headers,
signal,
action: 'AI Card 收口',
};
try {
await cardRequest('v1.0/card/instances', completionRequest);
} catch {
try {
await cardRequest('v1.0/card/instances', completionRequest);
} catch {
completed = false;
}
}
return { delivered: true, completed };
},
failAiCard: failCard,
async sendText({ clientId, clientSecret, sessionWebhook, text, signal }) {
const content = nonEmptyString(text);
if (!content) throw new TypeError('text is required');
const webhook = normalizeDingtalkSessionWebhook(sessionWebhook);
const token = await accessToken({ clientId, clientSecret, signal });
const response = await requestJson(fetchImpl, webhook, {
body: { msgtype: 'text', text: { content } },
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '消息回复',
});
if ((response?.errcode !== undefined && response.errcode !== 0)
|| (response?.code !== undefined && response.code !== 0)) {
throw new DingtalkApiError('send-rejected', '钉钉服务拒绝了回复消息。');
}
return true;
},
async sendFile(request) {
return sendArtifact(request, {
uploadType: 'file',
messageKey: 'sampleFile',
createMessageParams: (mediaId, file) => ({
mediaId,
fileName: file.fileName,
fileType: dingtalkFileType(file.fileName),
}),
});
},
async sendImage(request) {
return sendArtifact(request, {
uploadType: 'image',
messageKey: 'sampleImageMsg',
createMessageParams: (mediaId) => ({ photoURL: mediaId }),
});
},
clearAccessToken(clientId) {
const appKey = nonEmptyString(clientId);
if (appKey) tokenCache.delete(appKey);
},
});
}
export const createDingTalkApi = createDingtalkApi;
export const normalizeDingTalkSessionWebhook = normalizeDingtalkSessionWebhook;
export const splitDingTalkText = splitDingtalkText;