mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
961 lines
34 KiB
JavaScript
961 lines
34 KiB
JavaScript
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;
|