Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 115 additions & 15 deletions packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,69 @@ it('does not initialize or subscribe to cards when configuration is omitted', ()
).toBeUndefined();
});

it('refreshes the shared proactive token after a card request returns 401', async () => {
let tokenRequests = 0;
let cardRequests = 0;
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input) => {
const url = String(input);
if (url.includes('/gettoken?')) {
tokenRequests++;
return new Response(
JSON.stringify({
access_token: tokenRequests === 1 ? 'stale-token' : 'fresh-token',
expires_in: 7200,
}),
{ status: 200 },
);
}
if (url === 'https://api.dingtalk.com/v1.0/card/instances') {
cardRequests++;
return cardRequests === 1
? new Response('expired', { status: 401 })
: new Response('{}', { status: 200 });
}
throw new Error(`Unexpected request: ${url}`);
});
const channel = createChannel();
const state = channel as unknown as {
proactiveToken?: { token: string; expiresAt: number };
interactiveCardClient: {
options: { invalidateAccessToken(token: string): void };
updateInstance(input: {
outTrackId: string;
cardParamMap: Record<string, unknown>;
}): Promise<void>;
};
};
const cardClient = state.interactiveCardClient;

await cardClient.updateInstance({
outTrackId: 'status-1',
cardParamMap: {},
});

expect(tokenRequests).toBe(2);
expect(cardRequests).toBe(2);
expect(
fetchSpy.mock.calls
.filter(
([input]) =>
String(input) === 'https://api.dingtalk.com/v1.0/card/instances',
)
.map(
([, init]) =>
(init?.headers as Record<string, string>)[
'x-acs-dingtalk-access-token'
],
),
).toEqual(['stale-token', 'fresh-token']);
state.interactiveCardClient.options.invalidateAccessToken('stale-token');
expect(state.proactiveToken?.token).toBe('fresh-token');
fetchSpy.mockRestore();
});

function createCallbackResultChannel(
result: DingtalkCardCallbackResult,
): DingtalkChannelInstance {
Expand Down Expand Up @@ -1615,6 +1678,16 @@ describe('DingtalkChannel prompt reactions', () => {
});

describe('DingtalkChannel status cards', () => {
it('disposes status-card recovery when disconnected', () => {
const channel = createChannel();
const dispose = vi.fn();
Object.assign(channel, { statusCardController: { dispose } });

channel.disconnect();

expect(dispose).toHaveBeenCalledOnce();
});

it('passes the configured model to the status card controller', () => {
const channel = createChannel({ model: 'qwen3.7-max' });

Expand Down Expand Up @@ -7162,22 +7235,49 @@ describe('DingtalkChannel proactive send', () => {
expect(tokenCalls()).toHaveLength(2);
});

it('throws when the token endpoint rejects', async () => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
stubProactiveFetch(
undefined,
() =>
new Response(
JSON.stringify({ errcode: 40089, errmsg: 'invalid credential' }),
{ status: 200 },
),
);
it.each([
[40001, 'invalid credential'],
[40013, 'invalid appKey'],
[40089, 'invalid credential'],
[40096, 'invalid appKey or appSecret'],
[90002, 'invalid appKey'],
[90003, 'app not found'],
])(
'classifies gettoken errcode %s as a non-retryable token error',
async (errcode, errmsg) => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
stubProactiveFetch(
undefined,
() =>
new Response(JSON.stringify({ errcode, errmsg }), { status: 200 }),
);

await expect(channel.pushProactive(groupTarget, 'hello')).rejects.toThrow(
'gettoken errcode=40089',
);
});
const request = channel.pushProactive(groupTarget, 'hello');
await expect(request).rejects.toThrow(`gettoken errcode=${errcode}`);
await expect(request).rejects.toMatchObject({ retryable: false });
},
);

it.each([
[-1, '系统繁忙'],
[88, 'throttled'],
])(
'classifies gettoken errcode %s as a retryable token error',
async (errcode, errmsg) => {
const channel = proactive(createChannel());
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
stubProactiveFetch(
undefined,
() =>
new Response(JSON.stringify({ errcode, errmsg }), { status: 200 }),
);

const request = channel.pushProactive(groupTarget, 'hello');
await expect(request).rejects.toThrow(`gettoken errcode=${errcode}`);
await expect(request).rejects.toMatchObject({ retryable: true });
},
);

it('skips blank text without calling the API', async () => {
const channel = proactive(createChannel());
Expand Down
22 changes: 20 additions & 2 deletions packages/channels/dingtalk/src/DingtalkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ import {
DingtalkConnectionManager,
type DingtalkManagedSocket,
} from './DingtalkConnectionManager.js';
import { DingtalkInteractiveCardClient } from './interactive-card-client.js';
import {
DingtalkCardRequestError,
DingtalkInteractiveCardClient,
} from './interactive-card-client.js';
import {
parseDingtalkCardActorId,
parseDingtalkCardCallback,
Expand Down Expand Up @@ -594,6 +597,14 @@ const DIRECT_MSG_API =
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
/**
* gettoken business errors a retry cannot fix: an invalid appkey/secret or a
* missing app. Any other errcode (-1 system busy, 88 throttled, ...) is
* treated as transient so card recovery keeps retrying through it.
*/
const PERMANENT_TOKEN_ERROR_CODES = new Set([
40001, 40013, 40089, 40096, 90002, 90003,
]);
Comment thread
qqqys marked this conversation as resolved.
const REPLY_FETCH_TIMEOUT_MS = 15_000;
// Extensions for generated media store names, keyed by the download's mime
// type. The agent reads stored media via `read_file`, whose type detection is
Expand Down Expand Up @@ -778,6 +789,11 @@ export class DingtalkChannel extends ChannelBase {
this.interactiveCardClient = new DingtalkInteractiveCardClient({
robotCode: config.clientId,
getAccessToken: () => this.getProactiveToken(),
invalidateAccessToken: (token) => {
if (this.proactiveToken?.token === token) {
this.proactiveToken = undefined;
}
},
});
if (
this.interactiveCardConfig.statusCard.enabled &&
Expand Down Expand Up @@ -1351,8 +1367,9 @@ export class DingtalkChannel extends ChannelBase {
process.stderr.write(
`[DingTalk:${this.name}] access token request failed: gettoken errcode=${data.errcode} ${errmsg}\n`,
);
throw new Error(
throw new DingtalkCardRequestError(
`DingTalk access token request failed: gettoken errcode=${data.errcode}${errmsg ? ` ${errmsg}` : ''}`,
!PERMANENT_TOKEN_ERROR_CODES.has(Number(data.errcode)),
);
Comment thread
qqqys marked this conversation as resolved.
}
this.proactiveToken = {
Expand Down Expand Up @@ -1562,6 +1579,7 @@ export class DingtalkChannel extends ChannelBase {
if (this.dedupTimer) {
clearInterval(this.dedupTimer);
}
this.statusCardController?.dispose();
this.activeReactionKeys.clear();
this.sessionReactionKeys.clear();
if (this.connectionManager) {
Expand Down
Loading
Loading