@soimy/dingtalk 2.7.0 → 3.0.0-beta.1

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/README.md CHANGED
@@ -496,6 +496,37 @@ await finishAICard(card, finalText, log);
496
496
  - **src/types.ts**: 类型定义
497
497
  - **utils.ts**: 通用工具函数
498
498
 
499
+ ## 测试
500
+
501
+ 项目已基于 Vitest 初始化自动化测试,目录结构如下:
502
+
503
+ ```text
504
+ tests/
505
+ unit/
506
+ sign.test.ts # HmacSHA256 + Base64 签名测试
507
+ message-transform.test.ts # 文本/Markdown 消息转换测试
508
+ integration/
509
+ send-lifecycle.test.ts # 插件 outbound.sendText 生命周期适配测试
510
+ ```
511
+
512
+ ### 运行测试
513
+
514
+ ```bash
515
+ # 安装依赖(pnpm)
516
+ pnpm install
517
+
518
+ # 运行全部测试
519
+ pnpm test
520
+
521
+ # 生成覆盖率报告(coverage/)
522
+ pnpm test:coverage
523
+ ```
524
+
525
+ ### Mock 约束
526
+
527
+ - 所有测试中的网络请求均通过 `vi.mock('axios')` 拦截,禁止真实调用钉钉 API。
528
+ - 集成测试通过模块 mock 隔离 `openclaw/plugin-sdk`、`dingtalk-stream` 等外部依赖。
529
+
499
530
  ## 许可
500
531
 
501
532
  MIT
package/index.ts CHANGED
@@ -1,12 +1,13 @@
1
- import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
- import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
3
- import { dingtalkPlugin } from './src/channel';
4
- import { setDingTalkRuntime } from './src/runtime';
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { dingtalkPlugin } from "./src/channel";
4
+ import { setDingTalkRuntime } from "./src/runtime";
5
+ import type { DingtalkPluginModule } from "./src/types";
5
6
 
6
- const plugin = {
7
- id: 'dingtalk',
8
- name: 'DingTalk Channel',
9
- description: 'DingTalk (钉钉) messaging channel via Stream mode',
7
+ const plugin: DingtalkPluginModule = {
8
+ id: "dingtalk",
9
+ name: "DingTalk Channel",
10
+ description: "DingTalk (钉钉) messaging channel via Stream mode",
10
11
  configSchema: emptyPluginConfigSchema(),
11
12
  register(api: OpenClawPluginApi): void {
12
13
  setDingTalkRuntime(api.runtime);
package/package.json CHANGED
@@ -1,39 +1,43 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "2.7.0",
3
+ "version": "3.0.0-beta.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
- "main": "index.ts",
6
- "type": "module",
7
- "files": [
8
- "index.ts",
9
- "src",
10
- "openclaw.plugin.json",
11
- "clawbot.plugin.json"
12
- ],
13
- "scripts": {
14
- "type-check": "tsc --noEmit",
15
- "lint": "eslint index.ts src/",
16
- "lint:fix": "eslint --fix index.ts src/ && prettier --write index.ts src/"
17
- },
18
5
  "keywords": [
6
+ "bot",
7
+ "channel",
19
8
  "clawdbot",
20
- "openclaw",
21
9
  "dingtalk",
22
- "channel",
10
+ "openclaw",
23
11
  "stream",
24
- "钉钉",
25
- "bot"
12
+ "钉钉"
26
13
  ],
27
- "author": "YM Shen <soimy@163.com> (http://github.com/soimy)",
14
+ "homepage": "https://github.com/soimy/openclaw-channel-dingtalk",
28
15
  "license": "MIT",
16
+ "author": "YM Shen <soimy@163.com> (http://github.com/soimy)",
29
17
  "repository": {
30
18
  "type": "git",
31
19
  "url": "git+https://github.com/soimy/openclaw-channel-dingtalk.git"
32
20
  },
33
- "homepage": "https://github.com/soimy/openclaw-channel-dingtalk",
21
+ "files": [
22
+ "index.ts",
23
+ "src/*.ts",
24
+ "openclaw.plugin.json",
25
+ "clawbot.plugin.json"
26
+ ],
27
+ "type": "module",
28
+ "main": "index.ts",
34
29
  "publishConfig": {
35
- "registry": "https://registry.npmjs.org/",
36
- "access": "public"
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org/"
32
+ },
33
+ "scripts": {
34
+ "format": "oxfmt --write package.json tsconfig.json index.ts src/*.ts",
35
+ "format:check": "oxfmt --check package.json tsconfig.json index.ts src/*.ts",
36
+ "lint": "oxlint --type-aware index.ts src",
37
+ "lint:fix": "oxlint --type-aware --fix index.ts src && pnpm format",
38
+ "test": "vitest run",
39
+ "test:coverage": "vitest run --coverage",
40
+ "type-check": "tsc -p tsconfig.json"
37
41
  },
38
42
  "dependencies": {
39
43
  "axios": "^1.6.0",
@@ -43,12 +47,12 @@
43
47
  },
44
48
  "devDependencies": {
45
49
  "@types/node": "^25.2.0",
46
- "@typescript-eslint/eslint-plugin": "^6.0.0",
47
- "@typescript-eslint/parser": "^6.0.0",
48
- "eslint": "^8.0.0",
49
- "eslint-config-prettier": "^9.0.0",
50
- "prettier": "^3.0.0",
51
- "typescript": "^5.3.0"
50
+ "@vitest/coverage-v8": "^3.2.4",
51
+ "oxfmt": "0.34.0",
52
+ "oxlint": "^1.49.0",
53
+ "oxlint-tsgolint": "^0.14.2",
54
+ "typescript": "^5.3.0",
55
+ "vitest": "^3.2.4"
52
56
  },
53
57
  "peerDependencies": {
54
58
  "openclaw": ">=2026.2.13"
@@ -0,0 +1,55 @@
1
+ export type NormalizedAllowFrom = {
2
+ entries: string[];
3
+ entriesLower: string[];
4
+ hasWildcard: boolean;
5
+ hasEntries: boolean;
6
+ };
7
+
8
+ /**
9
+ * Normalize allowFrom list:
10
+ * - trim whitespace
11
+ * - support "dingtalk:/dd:/ding:" prefixes
12
+ * - precompute lower-case list for case-insensitive checks
13
+ */
14
+ export function normalizeAllowFrom(list?: Array<string>): NormalizedAllowFrom {
15
+ const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
16
+ const hasWildcard = entries.includes("*");
17
+ const normalized = entries
18
+ .filter((value) => value !== "*")
19
+ .map((value) => value.replace(/^(dingtalk|dd|ding):/i, ""));
20
+ const normalizedLower = normalized.map((value) => value.toLowerCase());
21
+ return {
22
+ entries: normalized,
23
+ entriesLower: normalizedLower,
24
+ hasWildcard,
25
+ hasEntries: entries.length > 0,
26
+ };
27
+ }
28
+
29
+ export function isSenderAllowed(params: {
30
+ allow: NormalizedAllowFrom;
31
+ senderId?: string;
32
+ }): boolean {
33
+ const { allow, senderId } = params;
34
+ if (!allow.hasEntries) {
35
+ return true;
36
+ }
37
+ if (allow.hasWildcard) {
38
+ return true;
39
+ }
40
+ if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ export function isSenderGroupAllowed(params: {
47
+ allow: NormalizedAllowFrom;
48
+ groupId?: string;
49
+ }): boolean {
50
+ const { allow, groupId } = params;
51
+ if (groupId && allow.entriesLower.includes(groupId.toLowerCase())) {
52
+ return true;
53
+ }
54
+ return false;
55
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,47 @@
1
+ import axios from "axios";
2
+ import type { DingTalkConfig, Logger, TokenInfo } from "./types";
3
+ import { retryWithBackoff } from "./utils";
4
+
5
+ interface TokenCache {
6
+ accessToken: string;
7
+ expiry: number;
8
+ }
9
+
10
+ // Access Token cache - keyed by clientId for multi-account support.
11
+ const accessTokenCache = new Map<string, TokenCache>();
12
+
13
+ /**
14
+ * Get DingTalk access token with clientId-scoped cache + retry.
15
+ * Refreshes token one minute before expiry to avoid near-expiry failures.
16
+ */
17
+ export async function getAccessToken(config: DingTalkConfig, log?: Logger): Promise<string> {
18
+ const cacheKey = config.clientId;
19
+ const now = Date.now();
20
+ const cached = accessTokenCache.get(cacheKey);
21
+
22
+ if (cached && cached.expiry > now + 60000) {
23
+ return cached.accessToken;
24
+ }
25
+
26
+ const token = await retryWithBackoff(
27
+ async () => {
28
+ const response = await axios.post<TokenInfo>(
29
+ "https://api.dingtalk.com/v1.0/oauth2/accessToken",
30
+ {
31
+ appKey: config.clientId,
32
+ appSecret: config.clientSecret,
33
+ },
34
+ );
35
+
36
+ accessTokenCache.set(cacheKey, {
37
+ accessToken: response.data.accessToken,
38
+ expiry: now + response.data.expireIn * 1000,
39
+ });
40
+
41
+ return response.data.accessToken;
42
+ },
43
+ { maxRetries: 3, log },
44
+ );
45
+
46
+ return token;
47
+ }
@@ -0,0 +1,338 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import axios from "axios";
3
+ import { getAccessToken } from "./auth";
4
+ import { stripTargetPrefix } from "./config";
5
+ import { resolveOriginalPeerId } from "./peer-id-registry";
6
+ import type {
7
+ AICardInstance,
8
+ AICardStreamingRequest,
9
+ DingTalkConfig,
10
+ DingTalkInboundMessage,
11
+ Logger,
12
+ } from "./types";
13
+ import { AICardStatus } from "./types";
14
+
15
+ const DINGTALK_API = "https://api.dingtalk.com";
16
+ // Card cache TTL (1 hour) for terminal states.
17
+ const CARD_CACHE_TTL = 60 * 60 * 1000;
18
+ // Thinking/tool stream snippets are truncated to keep card updates compact.
19
+ const THINKING_TRUNCATE_LENGTH = 500;
20
+
21
+ // AI Card instance cache for streaming updates.
22
+ const aiCardInstances = new Map<string, AICardInstance>();
23
+ // accountId:conversationId -> cardInstanceId
24
+ const activeCardsByTarget = new Map<string, string>();
25
+
26
+ // Helper to identify card terminal states.
27
+ export function isCardInTerminalState(state: string): boolean {
28
+ return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
29
+ }
30
+
31
+ export function getCardById(cardId: string): AICardInstance | undefined {
32
+ return aiCardInstances.get(cardId);
33
+ }
34
+
35
+ export function getActiveCardIdByTarget(targetKey: string): string | undefined {
36
+ return activeCardsByTarget.get(targetKey);
37
+ }
38
+
39
+ export function deleteActiveCardByTarget(targetKey: string): void {
40
+ activeCardsByTarget.delete(targetKey);
41
+ }
42
+
43
+ export function cleanupCardCache(): void {
44
+ const now = Date.now();
45
+ // Clean terminal cards only; active cards stay in cache to support streaming continuity.
46
+ for (const [cardInstanceId, instance] of aiCardInstances.entries()) {
47
+ if (isCardInTerminalState(instance.state) && now - instance.lastUpdated > CARD_CACHE_TTL) {
48
+ aiCardInstances.delete(cardInstanceId);
49
+ for (const [targetKey, mappedCardId] of activeCardsByTarget.entries()) {
50
+ if (mappedCardId === cardInstanceId) {
51
+ activeCardsByTarget.delete(targetKey);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ export function formatContentForCard(content: string, type: "thinking" | "tool"): string {
60
+ if (!content) {
61
+ return "";
62
+ }
63
+
64
+ // Truncate to configured length and keep a visual ellipsis when truncated.
65
+ const truncated =
66
+ content.slice(0, THINKING_TRUNCATE_LENGTH) +
67
+ (content.length > THINKING_TRUNCATE_LENGTH ? "…" : "");
68
+
69
+ // Quote each line to improve readability in markdown card content.
70
+ const quotedLines = truncated
71
+ .split("\n")
72
+ .map((line) => line.replace(/^_(?=[^ ])/, "*").replace(/(?<=[^ ])_(?=$)/, "*"))
73
+ .map((line) => `> ${line}`)
74
+ .join("\n");
75
+
76
+ const emoji = type === "thinking" ? "🤔" : "🛠️";
77
+ const label = type === "thinking" ? "思考中" : "工具执行";
78
+
79
+ return `${emoji} **${label}**\n${quotedLines}`;
80
+ }
81
+
82
+ async function sendTemplateMismatchNotification(
83
+ card: AICardInstance,
84
+ text: string,
85
+ log?: Logger,
86
+ ): Promise<void> {
87
+ const config = card.config;
88
+ if (!config) {
89
+ return;
90
+ }
91
+ try {
92
+ const token = await getAccessToken(config, log);
93
+ const { targetId, isExplicitUser } = stripTargetPrefix(card.conversationId);
94
+ const resolvedTarget = resolveOriginalPeerId(targetId);
95
+ const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
96
+ const url = isGroup
97
+ ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
98
+ : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
99
+
100
+ // Direct markdown fallback notification to user/group, without re-entering sendMessage card flow.
101
+ const payload: Record<string, unknown> = {
102
+ robotCode: config.robotCode || config.clientId,
103
+ msgKey: "sampleMarkdown",
104
+ msgParam: JSON.stringify({ title: "OpenClaw 提醒", text }),
105
+ };
106
+
107
+ if (isGroup) {
108
+ payload.openConversationId = resolvedTarget;
109
+ } else {
110
+ payload.userIds = [resolvedTarget];
111
+ }
112
+
113
+ await axios({
114
+ url,
115
+ method: "POST",
116
+ data: payload,
117
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
118
+ });
119
+ } catch (sendErr: any) {
120
+ log?.warn?.(`[DingTalk][AICard] Failed to send error notification to user: ${sendErr.message}`);
121
+ }
122
+ }
123
+
124
+ export async function createAICard(
125
+ config: DingTalkConfig,
126
+ conversationId: string,
127
+ data: DingTalkInboundMessage,
128
+ accountId: string,
129
+ log?: Logger,
130
+ ): Promise<AICardInstance | null> {
131
+ try {
132
+ const token = await getAccessToken(config, log);
133
+ // Use randomUUID to avoid collisions across workers/restarts.
134
+ const cardInstanceId = `card_${randomUUID()}`;
135
+
136
+ log?.info?.(`[DingTalk][AICard] Creating and delivering card outTrackId=${cardInstanceId}`);
137
+ log?.debug?.(
138
+ `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${conversationId}`,
139
+ );
140
+
141
+ const isGroup = conversationId.startsWith("cid");
142
+
143
+ if (!config.cardTemplateId) {
144
+ throw new Error("DingTalk cardTemplateId is not configured.");
145
+ }
146
+
147
+ // DingTalk createAndDeliver API payload.
148
+ const createAndDeliverBody = {
149
+ cardTemplateId: config.cardTemplateId,
150
+ outTrackId: cardInstanceId,
151
+ cardData: {
152
+ cardParamMap: {},
153
+ },
154
+ callbackType: "STREAM",
155
+ imGroupOpenSpaceModel: { supportForward: true },
156
+ imRobotOpenSpaceModel: { supportForward: true },
157
+ openSpaceId: isGroup
158
+ ? `dtv1.card//IM_GROUP.${conversationId}`
159
+ : `dtv1.card//IM_ROBOT.${conversationId}`,
160
+ userIdType: 1,
161
+ imGroupOpenDeliverModel: isGroup
162
+ ? { robotCode: config.robotCode || config.clientId }
163
+ : undefined,
164
+ imRobotOpenDeliverModel: !isGroup ? { spaceType: "IM_ROBOT" } : undefined,
165
+ };
166
+
167
+ if (isGroup && !config.robotCode) {
168
+ log?.warn?.(
169
+ "[DingTalk][AICard] robotCode not configured, using clientId as fallback. " +
170
+ "For best compatibility, set robotCode explicitly in config.",
171
+ );
172
+ }
173
+
174
+ log?.debug?.(
175
+ `[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`,
176
+ );
177
+ const resp = await axios.post(
178
+ `${DINGTALK_API}/v1.0/card/instances/createAndDeliver`,
179
+ createAndDeliverBody,
180
+ {
181
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
182
+ },
183
+ );
184
+ log?.debug?.(
185
+ `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`,
186
+ );
187
+
188
+ // Cache the AI card instance with config reference for token refresh/recovery.
189
+ const aiCardInstance: AICardInstance = {
190
+ cardInstanceId,
191
+ accessToken: token,
192
+ conversationId,
193
+ createdAt: Date.now(),
194
+ lastUpdated: Date.now(),
195
+ state: AICardStatus.PROCESSING,
196
+ config,
197
+ };
198
+ aiCardInstances.set(cardInstanceId, aiCardInstance);
199
+
200
+ const targetKey = `${accountId}:${conversationId}`;
201
+ activeCardsByTarget.set(targetKey, cardInstanceId);
202
+ log?.debug?.(
203
+ `[DingTalk][AICard] Registered active card mapping: ${targetKey} -> ${cardInstanceId}`,
204
+ );
205
+
206
+ return aiCardInstance;
207
+ } catch (err: any) {
208
+ log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
209
+ if (err.response) {
210
+ log?.error?.(
211
+ `[DingTalk][AICard] Error response: status=${err.response.status} data=${JSON.stringify(err.response.data)}`,
212
+ );
213
+ }
214
+ return null;
215
+ }
216
+ }
217
+
218
+ export async function streamAICard(
219
+ card: AICardInstance,
220
+ content: string,
221
+ finished: boolean = false,
222
+ log?: Logger,
223
+ ): Promise<void> {
224
+ // Refresh token defensively before DingTalk 2h token horizon.
225
+ const tokenAge = Date.now() - card.createdAt;
226
+ const tokenRefreshThreshold = 90 * 60 * 1000;
227
+
228
+ if (tokenAge > tokenRefreshThreshold && card.config) {
229
+ log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
230
+ try {
231
+ card.accessToken = await getAccessToken(card.config, log);
232
+ log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
233
+ } catch (err: any) {
234
+ log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
235
+ }
236
+ }
237
+
238
+ // Always use full replacement to make client rendering deterministic.
239
+ const streamBody: AICardStreamingRequest = {
240
+ outTrackId: card.cardInstanceId,
241
+ guid: randomUUID(),
242
+ key: card.config?.cardTemplateKey || "content",
243
+ content: content,
244
+ isFull: true,
245
+ isFinalize: finished,
246
+ isError: false,
247
+ };
248
+
249
+ log?.debug?.(
250
+ `[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`,
251
+ );
252
+
253
+ try {
254
+ const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
255
+ headers: {
256
+ "x-acs-dingtalk-access-token": card.accessToken,
257
+ "Content-Type": "application/json",
258
+ },
259
+ });
260
+ log?.debug?.(
261
+ `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
262
+ );
263
+
264
+ card.lastUpdated = Date.now();
265
+ if (finished) {
266
+ card.state = AICardStatus.FINISHED;
267
+ } else if (card.state === AICardStatus.PROCESSING) {
268
+ card.state = AICardStatus.INPUTING;
269
+ }
270
+ } catch (err: any) {
271
+ // 500 unknownError usually means cardTemplateKey mismatch with template variable names.
272
+ if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
273
+ const usedKey = streamBody.key;
274
+ const cardTemplateId = card.config?.cardTemplateId || "(unknown)";
275
+ const errorMsg =
276
+ `⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n` +
277
+ `这通常是因为 \`cardTemplateKey\` (当前值: \`${usedKey}\`) 与钉钉卡片模板 \`${cardTemplateId}\` 中定义的正文变量名不匹配。\n\n` +
278
+ `**建议操作**:\n` +
279
+ `1. 前往钉钉开发者后台检查该模板的“变量管理”\n` +
280
+ `2. 确保配置中的 \`cardTemplateKey\` 与模板中用于显示内容的字段变量名完全一致\n\n` +
281
+ `*注意:当前及后续消息将自动转为 Markdown 发送,直到问题修复。*\n` +
282
+ `*参考文档: https://github.com/soimy/openclaw-channel-dingtalk/blob/main/README.md#3-%E5%BB%BA%E7%AB%8B%E5%8D%A1%E7%89%87%E6%A8%A1%E6%9D%BF%E5%8F%AF%E9%80%89`;
283
+
284
+ log?.error?.(
285
+ `[DingTalk][AICard] Streaming failed with 500 unknownError. Key: ${usedKey}, Template: ${cardTemplateId}. ` +
286
+ `Verify that "cardTemplateKey" matches the content field variable name in your card template.`,
287
+ );
288
+
289
+ card.state = AICardStatus.FAILED;
290
+ card.lastUpdated = Date.now();
291
+ await sendTemplateMismatchNotification(card, errorMsg, log);
292
+ throw err;
293
+ }
294
+
295
+ // Retry once on 401 with refreshed token.
296
+ if (err.response?.status === 401 && card.config) {
297
+ log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
298
+ try {
299
+ card.accessToken = await getAccessToken(card.config, log);
300
+ const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
301
+ headers: {
302
+ "x-acs-dingtalk-access-token": card.accessToken,
303
+ "Content-Type": "application/json",
304
+ },
305
+ });
306
+ log?.debug?.(
307
+ `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
308
+ );
309
+ card.lastUpdated = Date.now();
310
+ if (finished) {
311
+ card.state = AICardStatus.FINISHED;
312
+ } else if (card.state === AICardStatus.PROCESSING) {
313
+ card.state = AICardStatus.INPUTING;
314
+ }
315
+ return;
316
+ } catch (retryErr: any) {
317
+ log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
318
+ }
319
+ }
320
+
321
+ card.state = AICardStatus.FAILED;
322
+ card.lastUpdated = Date.now();
323
+ log?.error?.(
324
+ `[DingTalk][AICard] Streaming update failed: ${err.message}, resp=${JSON.stringify(err.response?.data)}`,
325
+ );
326
+ throw err;
327
+ }
328
+ }
329
+
330
+ export async function finishAICard(
331
+ card: AICardInstance,
332
+ content: string,
333
+ log?: Logger,
334
+ ): Promise<void> {
335
+ log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
336
+ // Finalize by streaming one last full payload with isFinalize=true.
337
+ await streamAICard(card, content, true, log);
338
+ }