import assert from 'node:assert/strict'; import test from 'node:test'; import { createDingtalkApi, DINGTALK_AI_CARD_TEMPLATE_ID, DINGTALK_API_BASE_URL, DINGTALK_DONE_REACTION_NAME, DINGTALK_ERROR_REACTION_NAME, DINGTALK_THINKING_REACTION_NAME, DingtalkApiError, normalizeDingtalkCardMarkdown, normalizeDingtalkSessionWebhook, splitDingtalkText, } from '../../../src/channels/dingtalk/dingtalk-api.mjs'; function jsonResponse(value, { status = 200 } = {}) { return { ok: status >= 200 && status < 300, status, json: async () => value, }; } function deferred() { let resolve; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; } test('registration API performs init, begin, and poll with normalized results', async () => { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (url.pathname.endsWith('/init')) return jsonResponse({ errcode: 0, nonce: ' nonce-1 ' }); if (url.pathname.endsWith('/begin')) { return jsonResponse({ errcode: 0, device_code: ' device-1 ', user_code: 'user-1', verification_uri: 'https://h5.dingtalk.com/verify', verification_uri_complete: 'https://h5.dingtalk.com/verify?code=one', expires_in: 300, interval: 3, }); } return jsonResponse({ errcode: 0, status: 'success', client_id: 'ding-client', client_secret: 'host-only-secret', }); }; const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 }); const begun = await api.beginRegistration(); const polled = await api.pollRegistration({ deviceCode: begun.deviceCode }); assert.deepEqual(begun, { deviceCode: 'device-1', userCode: 'user-1', verificationUri: 'https://h5.dingtalk.com/verify', verificationUriComplete: 'https://h5.dingtalk.com/verify?code=one', expiresInSeconds: 300, intervalSeconds: 3, }); assert.deepEqual(polled, { status: 'SUCCESS', failReason: undefined, clientId: 'ding-client', clientSecret: 'host-only-secret', }); assert.deepEqual(calls.map(({ url, options }) => ({ path: new URL(url).pathname, body: JSON.parse(options.body), redirect: options.redirect, })), [ { path: '/app/registration/init', body: { source: 'DING_DWS_CLAW' }, redirect: 'error' }, { path: '/app/registration/begin', body: { nonce: 'nonce-1' }, redirect: 'error' }, { path: '/app/registration/poll', body: { device_code: 'device-1' }, redirect: 'error' }, ]); }); test('session replies use the fixed token endpoint, reject redirects, and cache access tokens', async () => { const calls = []; let now = 1_000; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) { return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 }); } return jsonResponse({ errcode: 0 }); }; const api = createDingtalkApi({ fetchImpl, now: () => now }); const request = { clientId: 'ding-client', clientSecret: 'host-only-secret', sessionWebhook: 'https://oapi.dingtalk.com/robot/sendBySession?session=opaque', text: '回答', }; await api.sendText(request); now += 10_000; await api.sendText({ ...request, text: '继续' }); assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1); assert.equal(calls.length, 3); for (const { options } of calls) assert.equal(options.redirect, 'error'); assert.deepEqual(JSON.parse(calls[0].options.body), { appKey: 'ding-client', appSecret: 'host-only-secret', }); assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'access-token'); assert.deepEqual(JSON.parse(calls[2].options.body), { msgtype: 'text', text: { content: '继续' }, }); }); test('session text replies include populated mention targets and omit empty mentions', async () => { const sent = []; const api = createDingtalkApi({ fetchImpl: async (url, options) => { if (url.toString().includes('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 }); } sent.push(JSON.parse(options.body)); return jsonResponse({ errcode: 0 }); }, }); for (const at of [ { atUserIds: ['staff-one'] }, { atMobiles: ['13800000000'] }, { isAtAll: true }, {}, { atUserIds: [], atMobiles: [], isAtAll: false }, ]) { await api.sendText({ clientId: 'ding-client', clientSecret: 'host-only-secret', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=mentions', text: '回答', at, }); } assert.deepEqual(sent.map((body) => body.at), [ { atUserIds: ['staff-one'] }, { atMobiles: ['13800000000'] }, { isAtAll: true }, undefined, undefined, ]); for (const body of sent) assert.equal(body.text.content, '回答'); }); test('DingTalk adds, recalls, and replaces its native status reactions', async () => { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (url.pathname.endsWith('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 }); } return jsonResponse({}); }; const api = createDingtalkApi({ fetchImpl }); const request = { clientId: 'ding-client', clientSecret: 'host-only-secret', robotCode: 'robot-from-callback', messageId: 'open-message-one', conversationId: 'open-conversation-one', }; await api.addReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME }); await api.recallReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME }); await api.addReaction({ ...request, reactionName: DINGTALK_DONE_REACTION_NAME }); await api.addReaction({ ...request, reactionName: DINGTALK_ERROR_REACTION_NAME }); assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1); assert.deepEqual(calls.slice(1).map(({ url, options }) => ({ path: new URL(url).pathname, token: options.headers['x-acs-dingtalk-access-token'], body: JSON.parse(options.body), })), [ ['reply', DINGTALK_THINKING_REACTION_NAME], ['recall', DINGTALK_THINKING_REACTION_NAME], ['reply', DINGTALK_DONE_REACTION_NAME], ['reply', DINGTALK_ERROR_REACTION_NAME], ].map(([action, reactionName]) => ({ path: `/v1.0/robot/emotion/${action}`, token: 'reaction-access-token', body: { robotCode: 'robot-from-callback', openMsgId: 'open-message-one', openConversationId: 'open-conversation-one', emotionType: 2, emotionName: reactionName, textEmotion: { emotionId: '2659900', emotionName: reactionName, text: reactionName, backgroundId: 'im_bg_1', }, }, }))); }); test('DingTalk treats a success=false emotion response as rejected', async () => { const api = createDingtalkApi({ fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken') ? jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 }) : jsonResponse({ success: false, code: 'emotion_not_supported' }), }); await assert.rejects( api.addReaction({ clientId: 'ding-client', clientSecret: 'host-only-secret', robotCode: 'robot-from-callback', messageId: 'open-message-one', conversationId: 'open-conversation-one', reactionName: DINGTALK_DONE_REACTION_NAME, }), (error) => error instanceof DingtalkApiError && error.code === 'reaction-rejected' && error.providerCode === 'emotion_not_supported', ); }); test('cold reactions share their token request without delaying the normal reply token request', async () => { const firstToken = deferred(); const calls = []; let tokenRequests = 0; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (new URL(url).pathname.endsWith('/oauth2/accessToken')) { tokenRequests += 1; if (tokenRequests === 1) return firstToken.promise; return jsonResponse({ accessToken: 'normal-access-token', expireIn: 7_200 }); } return jsonResponse({}); }; const api = createDingtalkApi({ fetchImpl }); const reactions = Array.from({ length: 10 }, (_unused, index) => ( api.addReaction({ clientId: 'ding-client', clientSecret: 'host-only-secret', messageId: `open-message-${index + 1}`, conversationId: 'open-conversation-one', reactionName: DINGTALK_THINKING_REACTION_NAME, }) )); await new Promise((resolve) => setImmediate(resolve)); assert.equal(tokenRequests, 1, 'concurrent reactions must share their own cold token request'); await api.sendText({ clientId: 'ding-client', clientSecret: 'host-only-secret', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=normal', text: '正常回复', }); assert.equal(tokenRequests, 2); firstToken.resolve(jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 })); await Promise.all(reactions); const normalSend = calls.find(({ url }) => url.includes('ticket=normal')); assert.equal(normalSend.options.headers['x-acs-dingtalk-access-token'], 'normal-access-token'); }); test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (url.pathname.endsWith('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 }); } if (url.pathname.endsWith('/media/upload')) { return jsonResponse({ errcode: 0, media_id: '@media-one', type: 'file' }); } return jsonResponse({ processQueryKey: 'query-one' }); }; const api = createDingtalkApi({ fetchImpl }); const response = await api.sendFile({ clientId: 'ding-client', clientSecret: 'host-only-secret', target: { type: 'group', robotCode: 'robot-code', openConversationId: 'cid-one', }, file: { fileName: 'result.pdf', mediaType: 'application/pdf', bytes: Buffer.from('dingtalk-result'), }, }); assert.equal(response.processQueryKey, 'query-one'); const uploadUrl = new URL(calls[1].url); assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com'); assert.equal(uploadUrl.pathname, '/media/upload'); assert.equal(uploadUrl.searchParams.get('access_token'), 'file-access-token'); assert.equal(uploadUrl.searchParams.get('type'), 'file'); assert.ok(calls[1].options.body instanceof FormData); const media = calls[1].options.body.get('media'); assert.equal(media.name, 'result.pdf'); assert.equal(media.type, 'application/pdf'); assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-result'); assert.equal(calls[1].options.headers, undefined); assert.equal( calls[2].url, `${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`, ); assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'file-access-token'); const sent = JSON.parse(calls[2].options.body); assert.deepEqual(sent, { robotCode: 'robot-code', msgKey: 'sampleFile', msgParam: JSON.stringify({ mediaId: '@media-one', fileName: 'result.pdf', fileType: 'pdf', }), openConversationId: 'cid-one', }); }); test('DingTalk uploads and sends a native image message to the exact robot user', async () => { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url: url.toString(), options }); if (url.pathname.endsWith('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 }); } if (url.pathname.endsWith('/media/upload')) { return jsonResponse({ errcode: 0, media_id: '@image-one', type: 'image' }); } return jsonResponse({ processQueryKey: 'image-query-one' }); }; const api = createDingtalkApi({ fetchImpl }); const response = await api.sendImage({ clientId: 'ding-client', clientSecret: 'host-only-secret', target: { type: 'user', robotCode: 'robot-code', userId: 'user-one', }, file: { fileName: 'result.png', mediaType: 'image/png', bytes: Buffer.from('dingtalk-image'), }, }); assert.equal(response.processQueryKey, 'image-query-one'); const uploadUrl = new URL(calls[1].url); assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com'); assert.equal(uploadUrl.pathname, '/media/upload'); assert.equal(uploadUrl.searchParams.get('access_token'), 'image-access-token'); assert.equal(uploadUrl.searchParams.get('type'), 'image'); const media = calls[1].options.body.get('media'); assert.equal(media.name, 'result.png'); assert.equal(media.type, 'image/png'); assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-image'); assert.equal( calls[2].url, `${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`, ); assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'image-access-token'); assert.deepEqual(JSON.parse(calls[2].options.body), { robotCode: 'robot-code', msgKey: 'sampleImageMsg', msgParam: JSON.stringify({ photoURL: '@image-one' }), userIds: ['user-one'], }); }); function dingtalkFileRequest(overrides = {}) { return { clientId: 'ding-client', clientSecret: 'host-only-secret', target: { type: 'group', robotCode: 'robot-code', openConversationId: 'cid-error-case', }, file: { fileName: 'result.pdf', mediaType: 'application/pdf', bytes: Buffer.from('dingtalk-error-case'), }, ...overrides, }; } function dingtalkFileFetch(finalResponse) { return async (url, options) => { if (url.pathname.endsWith('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 }); } if (url.pathname.endsWith('/media/upload')) { return jsonResponse({ errcode: 0, media_id: '@media-error-case', type: 'file' }); } return finalResponse(url, options); }; } test('DingTalk marks every ambiguous robot file send result as uncertain', async (t) => { const cases = [ { name: 'network failure', finalResponse: async () => { throw new TypeError('private socket detail'); }, }, { name: 'timeout', finalResponse: async () => { throw new DingtalkApiError('timeout', 'private timeout detail'); }, }, { name: 'invalid JSON', finalResponse: async () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError('private invalid JSON detail'); }, }), }, { name: 'HTTP 5xx', finalResponse: async () => jsonResponse({ code: 'InternalError' }, { status: 503 }), }, ]; for (const scenario of cases) { await t.test(scenario.name, async () => { const api = createDingtalkApi({ fetchImpl: dingtalkFileFetch(scenario.finalResponse), }); await assert.rejects( api.sendFile(dingtalkFileRequest()), (error) => error.code === 'artifact-delivery-uncertain' && !error.message.includes('private'), ); }); } }); test('DingTalk keeps ambiguous native image sends in the uncertain bucket', async () => { const api = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => { throw new TypeError('private socket detail'); }), }); await assert.rejects( api.sendImage(dingtalkFileRequest({ file: { fileName: 'result.png', mediaType: 'image/png', bytes: Buffer.from('dingtalk-image-error'), }, })), (error) => error.code === 'artifact-delivery-uncertain' && !error.message.includes('private'), ); }); test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => { const cases = [ { name: 'permission', status: 403, code: 'artifact-permission-required' }, { name: 'too large', status: 413, code: 'artifact-too-large' }, { name: 'rate limited', status: 429, code: 'artifact-rate-limited' }, { name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' }, ]; for (const scenario of cases) { await t.test(scenario.name, async () => { const api = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => jsonResponse( { code: scenario.status }, { status: scenario.status }, )), }); await assert.rejects( api.sendFile(dingtalkFileRequest()), (error) => error.code === scenario.code, ); }); } const permissionApi = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => jsonResponse({ code: 'Forbidden.AccessDenied', })), }); await assert.rejects( permissionApi.sendFile(dingtalkFileRequest()), (error) => error.code === 'artifact-permission-required' && error.providerCode === 'Forbidden.AccessDenied', ); const rejectedApi = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => jsonResponse({ code: 'InvalidParameter' })), }); await assert.rejects( rejectedApi.sendFile(dingtalkFileRequest()), (error) => error.code === 'artifact-provider-rejected' && error.providerCode === 'InvalidParameter', ); }); test('DingTalk preserves caller abort and never marks a pre-send upload failure uncertain', async () => { let uploadCalls = 0; const uploadApi = createDingtalkApi({ fetchImpl: async (url) => { uploadCalls += 1; if (url.pathname.endsWith('/oauth2/accessToken')) { return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 }); } throw new TypeError('private upload transport failure'); }, }); await assert.rejects( uploadApi.sendFile(dingtalkFileRequest()), (error) => error.code === 'artifact-provider-failed' && error.code !== 'artifact-delivery-uncertain', ); assert.equal(uploadCalls, 2); const controller = new AbortController(); const reason = new Error('caller stopped the turn'); const abortApi = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => { controller.abort(reason); throw new DOMException('Aborted', 'AbortError'); }), }); await assert.rejects( abortApi.sendFile(dingtalkFileRequest({ signal: controller.signal })), (error) => error === reason && error.code !== 'artifact-delivery-uncertain', ); }); test('DingTalk lets the provider decide whether a robot file type is supported', async () => { let providerCalls = 0; const api = createDingtalkApi({ fetchImpl: dingtalkFileFetch(async () => { providerCalls += 1; return jsonResponse({ code: 400 }, { status: 400 }); }), }); await assert.rejects( api.sendFile({ clientId: 'ding-client', clientSecret: 'host-only-secret', target: { type: 'user', robotCode: 'robot-code', userId: 'user-one' }, file: { fileName: 'result.html', bytes: Buffer.from('