@adongguo/dingtalk 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ai-card.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * AI Card Streaming for DingTalk
3
+ *
4
+ * Implements AI Card creation, streaming updates, and completion.
5
+ * Provides typewriter effect for AI responses in DingTalk.
6
+ */
7
+
8
+ import type { DingTalkConfig, DingTalkIncomingMessage } from "./types.js";
9
+
10
+ // ============ Constants ============
11
+
12
+ /** AI Card template ID (DingTalk official AI Card template) */
13
+ const AI_CARD_TEMPLATE_ID = "382e4302-551d-4880-bf29-a30acfab2e71.schema";
14
+
15
+ /** DingTalk API base URL */
16
+ const DINGTALK_API = "https://api.dingtalk.com";
17
+
18
+ /** AI Card status values (consistent with DingTalk SDK) */
19
+ export const AICardStatus = {
20
+ PROCESSING: "1",
21
+ INPUTING: "2",
22
+ FINISHED: "3",
23
+ EXECUTING: "4",
24
+ FAILED: "5",
25
+ } as const;
26
+
27
+ export type AICardStatusType = (typeof AICardStatus)[keyof typeof AICardStatus];
28
+
29
+ // ============ Types ============
30
+
31
+ export interface AICardInstance {
32
+ cardInstanceId: string;
33
+ accessToken: string;
34
+ inputingStarted: boolean;
35
+ }
36
+
37
+ export interface AICardMessageData {
38
+ conversationType: "1" | "2";
39
+ conversationId: string;
40
+ senderStaffId?: string;
41
+ senderId?: string;
42
+ }
43
+
44
+ interface Logger {
45
+ info?: (msg: string) => void;
46
+ warn?: (msg: string) => void;
47
+ error?: (msg: string) => void;
48
+ }
49
+
50
+ // ============ Access Token Cache ============
51
+
52
+ let accessToken: string | null = null;
53
+ let accessTokenExpiry = 0;
54
+
55
+ /**
56
+ * Get access token for DingTalk API, with caching.
57
+ */
58
+ export async function getAccessToken(config: DingTalkConfig): Promise<string> {
59
+ const now = Date.now();
60
+ if (accessToken && accessTokenExpiry > now + 60_000) {
61
+ return accessToken;
62
+ }
63
+
64
+ const response = await fetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({
68
+ appKey: config.appKey,
69
+ appSecret: config.appSecret,
70
+ }),
71
+ });
72
+
73
+ if (!response.ok) {
74
+ const text = await response.text();
75
+ throw new Error(`Failed to get access token: ${response.status} ${text}`);
76
+ }
77
+
78
+ const data = (await response.json()) as { accessToken: string; expireIn: number };
79
+ accessToken = data.accessToken;
80
+ accessTokenExpiry = now + data.expireIn * 1000;
81
+ return accessToken;
82
+ }
83
+
84
+ /**
85
+ * Clear the access token cache.
86
+ */
87
+ export function clearAccessTokenCache(): void {
88
+ accessToken = null;
89
+ accessTokenExpiry = 0;
90
+ }
91
+
92
+ // ============ AI Card Functions ============
93
+
94
+ /**
95
+ * Create and deliver an AI Card instance.
96
+ * Returns null if creation fails (caller should fall back to regular message).
97
+ */
98
+ export async function createAICard(
99
+ config: DingTalkConfig,
100
+ data: AICardMessageData,
101
+ log?: Logger,
102
+ ): Promise<AICardInstance | null> {
103
+ try {
104
+ const token = await getAccessToken(config);
105
+ const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
106
+
107
+ log?.info?.(`[DingTalk][AICard] Creating card outTrackId=${cardInstanceId}`);
108
+ log?.info?.(
109
+ `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${data.conversationId}`,
110
+ );
111
+
112
+ // 1. Create card instance (empty cardParamMap, no preset flowStatus)
113
+ const createBody = {
114
+ cardTemplateId: AI_CARD_TEMPLATE_ID,
115
+ outTrackId: cardInstanceId,
116
+ cardData: {
117
+ cardParamMap: {},
118
+ },
119
+ callbackType: "STREAM",
120
+ imGroupOpenSpaceModel: { supportForward: true },
121
+ imRobotOpenSpaceModel: { supportForward: true },
122
+ };
123
+
124
+ log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances body=${JSON.stringify(createBody)}`);
125
+ const createResp = await fetch(`${DINGTALK_API}/v1.0/card/instances`, {
126
+ method: "POST",
127
+ headers: {
128
+ "x-acs-dingtalk-access-token": token,
129
+ "Content-Type": "application/json",
130
+ },
131
+ body: JSON.stringify(createBody),
132
+ });
133
+
134
+ if (!createResp.ok) {
135
+ const text = await createResp.text();
136
+ log?.error?.(`[DingTalk][AICard] Create failed: ${createResp.status} ${text}`);
137
+ return null;
138
+ }
139
+
140
+ const createData = await createResp.json();
141
+ log?.info?.(`[DingTalk][AICard] Create response: ${JSON.stringify(createData)}`);
142
+
143
+ // 2. Deliver card
144
+ const isGroup = data.conversationType === "2";
145
+ const deliverBody: Record<string, unknown> = {
146
+ outTrackId: cardInstanceId,
147
+ userIdType: 1,
148
+ };
149
+
150
+ if (isGroup) {
151
+ deliverBody.openSpaceId = `dtv1.card//IM_GROUP.${data.conversationId}`;
152
+ deliverBody.imGroupOpenDeliverModel = {
153
+ robotCode: config.appKey,
154
+ };
155
+ } else {
156
+ const userId = data.senderStaffId || data.senderId;
157
+ deliverBody.openSpaceId = `dtv1.card//IM_ROBOT.${userId}`;
158
+ deliverBody.imRobotOpenDeliverModel = { spaceType: "IM_ROBOT" };
159
+ }
160
+
161
+ log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances/deliver body=${JSON.stringify(deliverBody)}`);
162
+ const deliverResp = await fetch(`${DINGTALK_API}/v1.0/card/instances/deliver`, {
163
+ method: "POST",
164
+ headers: {
165
+ "x-acs-dingtalk-access-token": token,
166
+ "Content-Type": "application/json",
167
+ },
168
+ body: JSON.stringify(deliverBody),
169
+ });
170
+
171
+ if (!deliverResp.ok) {
172
+ const text = await deliverResp.text();
173
+ log?.error?.(`[DingTalk][AICard] Deliver failed: ${deliverResp.status} ${text}`);
174
+ return null;
175
+ }
176
+
177
+ const deliverData = await deliverResp.json();
178
+ log?.info?.(`[DingTalk][AICard] Deliver response: ${JSON.stringify(deliverData)}`);
179
+
180
+ return { cardInstanceId, accessToken: token, inputingStarted: false };
181
+ } catch (err: unknown) {
182
+ const errMessage = err instanceof Error ? err.message : String(err);
183
+ log?.error?.(`[DingTalk][AICard] Create card failed: ${errMessage}`);
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Stream content update to AI Card.
190
+ * First call switches card to INPUTING status.
191
+ */
192
+ export async function streamAICard(
193
+ card: AICardInstance,
194
+ content: string,
195
+ finished: boolean = false,
196
+ log?: Logger,
197
+ ): Promise<void> {
198
+ // First streaming call: switch to INPUTING status
199
+ if (!card.inputingStarted) {
200
+ const statusBody = {
201
+ outTrackId: card.cardInstanceId,
202
+ cardData: {
203
+ cardParamMap: {
204
+ flowStatus: AICardStatus.INPUTING,
205
+ msgContent: "",
206
+ staticMsgContent: "",
207
+ sys_full_json_obj: JSON.stringify({
208
+ order: ["msgContent"],
209
+ }),
210
+ },
211
+ },
212
+ };
213
+
214
+ log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (INPUTING) outTrackId=${card.cardInstanceId}`);
215
+ const statusResp = await fetch(`${DINGTALK_API}/v1.0/card/instances`, {
216
+ method: "PUT",
217
+ headers: {
218
+ "x-acs-dingtalk-access-token": card.accessToken,
219
+ "Content-Type": "application/json",
220
+ },
221
+ body: JSON.stringify(statusBody),
222
+ });
223
+
224
+ if (!statusResp.ok) {
225
+ const text = await statusResp.text();
226
+ log?.error?.(`[DingTalk][AICard] INPUTING switch failed: ${statusResp.status} ${text}`);
227
+ throw new Error(`INPUTING switch failed: ${statusResp.status}`);
228
+ }
229
+
230
+ log?.info?.(`[DingTalk][AICard] INPUTING response: ${statusResp.status}`);
231
+ card.inputingStarted = true;
232
+ }
233
+
234
+ // Stream content update
235
+ const body = {
236
+ outTrackId: card.cardInstanceId,
237
+ guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
238
+ key: "msgContent",
239
+ content: content,
240
+ isFull: true, // Full replacement
241
+ isFinalize: finished,
242
+ isError: false,
243
+ };
244
+
245
+ log?.info?.(
246
+ `[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished}`,
247
+ );
248
+ const streamResp = await fetch(`${DINGTALK_API}/v1.0/card/streaming`, {
249
+ method: "PUT",
250
+ headers: {
251
+ "x-acs-dingtalk-access-token": card.accessToken,
252
+ "Content-Type": "application/json",
253
+ },
254
+ body: JSON.stringify(body),
255
+ });
256
+
257
+ if (!streamResp.ok) {
258
+ const text = await streamResp.text();
259
+ log?.error?.(`[DingTalk][AICard] Streaming update failed: ${streamResp.status} ${text}`);
260
+ throw new Error(`Streaming update failed: ${streamResp.status}`);
261
+ }
262
+
263
+ log?.info?.(`[DingTalk][AICard] Streaming response: ${streamResp.status}`);
264
+ }
265
+
266
+ /**
267
+ * Complete AI Card with final content and FINISHED status.
268
+ */
269
+ export async function finishAICard(card: AICardInstance, content: string, log?: Logger): Promise<void> {
270
+ log?.info?.(`[DingTalk][AICard] Finishing card, final content length=${content.length}`);
271
+
272
+ // 1. Close streaming channel with final content (isFinalize=true)
273
+ await streamAICard(card, content, true, log);
274
+
275
+ // 2. Update card status to FINISHED
276
+ const body = {
277
+ outTrackId: card.cardInstanceId,
278
+ cardData: {
279
+ cardParamMap: {
280
+ flowStatus: AICardStatus.FINISHED,
281
+ msgContent: content,
282
+ staticMsgContent: "",
283
+ sys_full_json_obj: JSON.stringify({
284
+ order: ["msgContent"],
285
+ }),
286
+ },
287
+ },
288
+ };
289
+
290
+ log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (FINISHED) outTrackId=${card.cardInstanceId}`);
291
+ const finishResp = await fetch(`${DINGTALK_API}/v1.0/card/instances`, {
292
+ method: "PUT",
293
+ headers: {
294
+ "x-acs-dingtalk-access-token": card.accessToken,
295
+ "Content-Type": "application/json",
296
+ },
297
+ body: JSON.stringify(body),
298
+ });
299
+
300
+ if (!finishResp.ok) {
301
+ const text = await finishResp.text();
302
+ log?.error?.(`[DingTalk][AICard] FINISHED update failed: ${finishResp.status} ${text}`);
303
+ } else {
304
+ log?.info?.(`[DingTalk][AICard] FINISHED response: ${finishResp.status}`);
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Mark AI Card as failed with error message.
310
+ */
311
+ export async function failAICard(card: AICardInstance, errorMessage: string, log?: Logger): Promise<void> {
312
+ log?.error?.(`[DingTalk][AICard] Marking card as failed: ${errorMessage}`);
313
+
314
+ const body = {
315
+ outTrackId: card.cardInstanceId,
316
+ cardData: {
317
+ cardParamMap: {
318
+ flowStatus: AICardStatus.FAILED,
319
+ msgContent: `Error: ${errorMessage}`,
320
+ staticMsgContent: "",
321
+ sys_full_json_obj: JSON.stringify({
322
+ order: ["msgContent"],
323
+ }),
324
+ },
325
+ },
326
+ };
327
+
328
+ try {
329
+ await fetch(`${DINGTALK_API}/v1.0/card/instances`, {
330
+ method: "PUT",
331
+ headers: {
332
+ "x-acs-dingtalk-access-token": card.accessToken,
333
+ "Content-Type": "application/json",
334
+ },
335
+ body: JSON.stringify(body),
336
+ });
337
+ } catch (err: unknown) {
338
+ const errMsg = err instanceof Error ? err.message : String(err);
339
+ log?.error?.(`[DingTalk][AICard] Failed to mark card as failed: ${errMsg}`);
340
+ }
341
+ }