@soimy/dingtalk 2.6.5 → 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
@@ -308,6 +308,7 @@ openclaw gateway restart
308
308
  - 通过 `cardTemplateId` 指定模板
309
309
  - 通过 `cardTemplateKey` 指定内容字段
310
310
  - **适用于 AI 对话场景**
311
+ - 支持在卡片中实时显示 AI 思考过程(推理流)和工具执行结果
311
312
 
312
313
  **AI Card API 特性:**
313
314
  当配置 `messageType: 'card'` 时:
@@ -317,6 +318,24 @@ openclaw gateway restart
317
318
  3. 自动状态管理(PROCESSING → INPUTING → FINISHED)
318
319
  4. 更稳定的流式体验,无需手动节流
319
320
 
321
+ ### AI 思考过程与工具执行显示(AI Card 模式)
322
+
323
+ 当 `messageType` 为 `card` 时,插件可以在卡片中实时展示 AI 的推理过程(🤔 思考中)和工具调用结果(🛠️ 工具执行)。这两项功能通过**对话级命令**控制,无需修改配置文件:
324
+
325
+ | 功能 | 对话命令 | 说明 |
326
+ | ----------------- | --------------------- | ---------------------------------- |
327
+ | 显示 AI 推理流 | `/reasoning stream` | 开启后,AI 思考内容实时更新到卡片 |
328
+ | 显示工具执行结果 | `/verbose on` | 开启后,工具调用结果实时更新到卡片 |
329
+ | 关闭 AI 推理流 | `/reasoning off` | 关闭推理流显示 |
330
+ | 关闭工具执行显示 | `/verbose off` | 关闭工具执行结果显示 |
331
+
332
+ **显示格式:**
333
+
334
+ - 思考内容以 `🤔 **思考中**` 为标题,正文以 `>` 引用块展示,最多显示前 500 个字符
335
+ - 工具结果以 `🛠️ **工具执行**` 为标题,正文以 `>` 引用块展示,最多显示前 500 个字符
336
+
337
+ > **注意:** 推理流和工具执行均会产生额外的卡片流式更新 API 调用,在 AI 推理步骤较多时可能显著增加 API 消耗,建议按需开启。
338
+
320
339
  **配置示例:**
321
340
 
322
341
  ```json5
@@ -477,6 +496,37 @@ await finishAICard(card, finalText, log);
477
496
  - **src/types.ts**: 类型定义
478
497
  - **utils.ts**: 通用工具函数
479
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
+
480
530
  ## 许可
481
531
 
482
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,36 +1,44 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "2.6.5",
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",
29
+ "publishConfig": {
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"
41
+ },
34
42
  "dependencies": {
35
43
  "axios": "^1.6.0",
36
44
  "dingtalk-stream": "^2.1.4",
@@ -39,12 +47,12 @@
39
47
  },
40
48
  "devDependencies": {
41
49
  "@types/node": "^25.2.0",
42
- "@typescript-eslint/eslint-plugin": "^6.0.0",
43
- "@typescript-eslint/parser": "^6.0.0",
44
- "eslint": "^8.0.0",
45
- "eslint-config-prettier": "^9.0.0",
46
- "prettier": "^3.0.0",
47
- "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"
48
56
  },
49
57
  "peerDependencies": {
50
58
  "openclaw": ">=2026.2.13"
@@ -76,4 +84,4 @@
76
84
  "defaultChoice": "npm"
77
85
  }
78
86
  }
79
- }
87
+ }
@@ -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
+ }