@soimy/dingtalk 3.1.3 → 3.2.0

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
@@ -2,6 +2,19 @@
2
2
 
3
3
  钉钉企业内部机器人 Channel 插件,使用 Stream 模式(无需公网 IP)。
4
4
 
5
+ > [!IMPORTANT]
6
+ > **重要声明(上游消息丢失排查中)**
7
+ >
8
+ > 当前我们观察到:部分消息在钉钉 App 端发送后,未稳定到达本地 `dingtalk-stream` SDK 回调入口(已通过最小复现与链路统计验证)。
9
+ > 相关问题已向钉钉开发者后台提交了工单,在相关问题得到解决前,暂停本项目的Feature开发,仅进入维护模式。
10
+ >
11
+ > 相关信息:
12
+ > - issue 讨论:[#104](https://github.com/soimy/openclaw-channel-dingtalk/issues/104)
13
+ > - 最小可复现说明(SDK 侧):<https://github.com/soimy/dingtalk-stream-sdk-nodejs/blob/main/docs/inbound-msg-missing-repro.zh-CN.md>
14
+ > - 插件侧测试分支:[`test/inbound-msg-missing`](https://github.com/soimy/openclaw-channel-dingtalk/tree/test/inbound-msg-missing)
15
+ >
16
+ > 在上游链路问题完全收敛前,建议关键业务场景先做好重试与可观测性(trace 前缀、计数日志、缺失 ID 对账)。
17
+
5
18
  ## 功能特性
6
19
 
7
20
  - ✅ **Stream 模式** — WebSocket 长连接,无需公网 IP 或 Webhook
@@ -159,6 +172,8 @@ openclaw configure --section channels
159
172
 
160
173
  - ✅ **Card.Instance.Write** — 创建和投放卡片实例
161
174
  - ✅ **Card.Streaming.Write** — 对卡片进行流式更新
175
+ - ✅ **机器人消息发送相关权限** — 允许机器人向单聊/群聊发送消息
176
+ - ✅ **媒体文件上传相关权限** — 允许调用媒体上传接口发送图片、语音、视频、文件
162
177
 
163
178
  **步骤:**
164
179
 
@@ -185,7 +200,7 @@ openclaw configure --section channels
185
200
 
186
201
  **说明:**
187
202
 
188
- - 使用 DingTalk 官方 AI 卡片模板时,`cardTemplateKey` 默认为 `'msgContent'`,无需修改
203
+ - 使用 DingTalk 官方 AI 卡片模板时,`cardTemplateKey` 默认为 `'content'`,无需修改
189
204
  - 如果您创建自定义卡片模板,需要确保模板中包含相应的内容字段,并将 `cardTemplateKey` 配置为该字段名称
190
205
 
191
206
  ##### 4. 获取凭证
@@ -226,6 +241,7 @@ openclaw configure --section channels
226
241
  "groupPolicy": "open",
227
242
  "debug": false,
228
243
  "messageType": "markdown", // 或 "card"
244
+ // "mediaMaxMb": 20, // 可选:接收文件大小上限(MB),默认 5 MB
229
245
  // 仅card需要配置
230
246
  "cardTemplateId": "你复制的模板ID",
231
247
  "cardTemplateKey": "你模板的内容变量"
@@ -256,10 +272,12 @@ openclaw gateway restart
256
272
  | `dmPolicy` | string | `"open"` | 私聊策略:open/pairing/allowlist |
257
273
  | `groupPolicy` | string | `"open"` | 群聊策略:open/allowlist |
258
274
  | `allowFrom` | string[] | `[]` | 允许的发送者 ID 列表 |
275
+ | `mediaUrlAllowlist` | string[] | `[]` | 允许通过 `mediaUrl` 下载的主机/IP/CIDR 白名单 |
259
276
  | `messageType` | string | `"markdown"` | 消息类型:markdown/card |
260
277
  | `cardTemplateId` | string | | AI 互动卡片模板 ID(仅当 messageType=card) |
261
278
  | `cardTemplateKey` | string | `"content"` | 卡片模板内容字段键(仅当 messageType=card) |
262
279
  | `debug` | boolean | `false` | 是否开启调试日志 |
280
+ | `mediaMaxMb` | number | - | 接收文件大小上限(MB),不设则使用 runtime 默认值(5 MB) |
263
281
  | `maxConnectionAttempts` | number | `10` | 最大连接尝试次数 |
264
282
  | `initialReconnectDelay` | number | `1000` | 初始重连延迟(毫秒) |
265
283
  | `maxReconnectDelay` | number | `60000` | 最大重连延迟(毫秒) |
@@ -308,12 +326,68 @@ openclaw gateway restart
308
326
 
309
327
  ### 发送
310
328
 
311
- | 类型 | 支持 | 说明 |
312
- | -------- | ---- | -------------------------------- |
313
- | 文本 | ✅ | 完整支持 |
314
- | Markdown | ✅ | 自动检测或手动指定 |
315
- | 互动卡片 | ✅ | 支持流式更新,适用于 AI 实时输出 |
316
- | 图片 | | 需要通过媒体上传 API |
329
+ | 类型 | 支持 | 说明 |
330
+ | ------------ | ---- | -------------------------------------------------------- |
331
+ | 文本 | ✅ | 完整支持 |
332
+ | Markdown | ✅ | 自动检测或手动指定 |
333
+ | 互动卡片 | ✅ | 支持流式更新,适用于 AI 实时输出 |
334
+ | 图片 | | 先上传媒体再发送,支持本地路径和 HTTP(S) URL |
335
+ | 语音 | ✅ | 先上传媒体再发送 |
336
+ | 视频 | ✅ | 先上传媒体再发送 |
337
+ | 文件 | ✅ | 先上传媒体再发送 |
338
+ | 原生语音消息 | ✅ | `message send` / `outbound.sendMedia` 可用 `asVoice=true` |
339
+
340
+ > **重要限制:**
341
+ > 当前**不支持图片的图文混排**。也就是说,Markdown 消息和 AI 互动卡片目前都只能发送文本内容,不能在同一条消息中同时内嵌图片。
342
+ > 如果需要发送图片,请单独调用 `outbound.sendMedia(...)` 或 `sendProactiveMedia(...)`。
343
+ > 无论是**本地图片路径**还是**远程 HTTP(S) 图片 URL**,都支持单独发送;远程图片会先下载到临时文件,再上传到钉钉后发送。
344
+ > 远程 URL 下载默认限制为:**10 秒超时**、**20MB 上限**,并拒绝 `localhost` / 内网地址(如 `127.0.0.1`、`10.x.x.x`、`192.168.x.x`、`172.16-31.x.x`)以降低 SSRF 风险。
345
+ > 如需从受控内网媒体服务下载,请配置 `mediaUrlAllowlist`(例如 `192.168.1.23`、`files.internal.example`、`10.0.0.0/8`);配置后仅白名单主机可下载。
346
+ > 远程域名会先做 DNS 解析并校验解析结果;若解析到内网/本地地址且未被白名单明确允许,将在下载前拒绝。
347
+ > `asVoice=true` 需要同时提供 `media/path/filePath/mediaUrl` 指向音频文件;纯文本不会自动转语音。
348
+
349
+ #### mediaUrlAllowlist 配置示例
350
+
351
+ `mediaUrlAllowlist` 支持以下写法:
352
+
353
+ - 主机名:`cdn.example.com`
354
+ - 泛域名:`*.example.com`
355
+ - 主机+端口:`files.internal.example:8443`
356
+ - 单个 IP:`192.168.1.23`、`fd00::1`
357
+ - CIDR 网段:`10.0.0.0/8`、`fc00::/7`
358
+
359
+ 示例:
360
+
361
+ ```json
362
+ {
363
+ "channels": {
364
+ "dingtalk": {
365
+ "clientId": "your-app-key",
366
+ "clientSecret": "your-app-secret",
367
+ "mediaUrlAllowlist": [
368
+ "cdn.example.com",
369
+ "*.assets.example.com",
370
+ "files.internal.example:8443",
371
+ "192.168.1.23",
372
+ "10.0.0.0/8",
373
+ "fc00::/7"
374
+ ]
375
+ }
376
+ }
377
+ }
378
+ ```
379
+
380
+ > 行为说明:配置 `mediaUrlAllowlist` 后,下载阶段进入严格白名单模式,非白名单目标一律拒绝。
381
+
382
+ #### sendMedia 常见错误码
383
+
384
+ `outbound.sendMedia(...)` 在下载准备失败时会透出错误码前缀(例如 `remote media preparation failed: [ERR_MEDIA_PRIVATE_HOST] ...`):
385
+
386
+ - `ERR_MEDIA_ALLOWLIST_MISS`:目标 host 不在 `mediaUrlAllowlist`
387
+ - `ERR_MEDIA_PRIVATE_HOST`:URL 本身是本地/内网 host 且未被允许
388
+ - `ERR_MEDIA_DNS_UNRESOLVED`:域名无法解析
389
+ - `ERR_MEDIA_DNS_PRIVATE`:域名解析结果命中本地/内网地址且未被允许
390
+ - `ERR_MEDIA_REDIRECT_HOST`:下载阶段出现非预期重定向 host
317
391
 
318
392
  ## API 消耗说明
319
393
 
@@ -374,6 +448,7 @@ openclaw gateway restart
374
448
  - 通过 `cardTemplateKey` 指定内容字段
375
449
  - **适用于 AI 对话场景**
376
450
  - 支持在卡片中实时显示 AI 思考过程(推理流)和工具执行结果
451
+ - 当前卡片模式仅支持**文本内容流式更新**,不支持图片图文混排
377
452
 
378
453
  **AI Card API 特性:**
379
454
  当配置 `messageType: 'card'` 时:
@@ -407,11 +482,11 @@ openclaw gateway restart
407
482
  {
408
483
  messageType: 'card', // 启用 AI 互动卡片模式
409
484
  cardTemplateId: '382e4302-551d-4880-bf29-a30acfab2e71.schema', // AI 卡片模板 ID(默认值)
410
- cardTemplateKey: 'msgContent', // 卡片内容字段键(默认值:msgContent
485
+ cardTemplateKey: 'content', // 卡片内容字段键(默认值:content
411
486
  }
412
487
  ```
413
488
 
414
- > **注意**:`cardTemplateKey` 应与您的卡片模板中定义的字段名称一致。默认值为 `'msgContent'`,适用于 DingTalk 官方 AI 卡片模板。如果您使用自定义模板,请根据模板定义的字段名称进行配置。
489
+ > **注意**:`cardTemplateKey` 应与您的卡片模板中定义的字段名称一致。默认值为 `'content'`,适用于 DingTalk 官方 AI 卡片模板。如果您使用自定义模板,请根据模板定义的字段名称进行配置。
415
490
 
416
491
  ## 使用示例
417
492
 
@@ -420,6 +495,59 @@ openclaw gateway restart
420
495
  1. **私聊机器人** — 找到机器人,发送消息
421
496
  2. **群聊 @机器人** — 在群里 @机器人名称 + 消息
422
497
 
498
+ 如果你是通过 OpenClaw 的 outbound 能力主动发消息,也可以直接调用:
499
+
500
+ ```typescript
501
+ import { dingtalkPlugin } from './src/channel';
502
+
503
+ const cfg = {
504
+ channels: {
505
+ dingtalk: {
506
+ clientId: 'dingxxxxxx',
507
+ clientSecret: 'your-app-secret',
508
+ robotCode: 'dingxxxxxx',
509
+ },
510
+ },
511
+ };
512
+
513
+ // 发送本地图片
514
+ await dingtalkPlugin.outbound.sendMedia({
515
+ cfg,
516
+ to: 'cidxxxxxxxx',
517
+ mediaPath: '/absolute/path/to/photo.png',
518
+ accountId: 'default',
519
+ });
520
+
521
+ // 发送远程图片 URL(插件会先下载到临时文件,再上传到钉钉)
522
+ await dingtalkPlugin.outbound.sendMedia({
523
+ cfg,
524
+ to: 'cidxxxxxxxx',
525
+ mediaUrl: 'https://example.com/banner.jpg',
526
+ accountId: 'default',
527
+ });
528
+
529
+ // 发送文件或其他媒体,也可以显式指定 mediaType
530
+ await dingtalkPlugin.outbound.sendMedia({
531
+ cfg,
532
+ to: 'user_123456',
533
+ mediaPath: '/absolute/path/to/manual.pdf',
534
+ mediaType: 'file',
535
+ accountId: 'default',
536
+ });
537
+ ```
538
+
539
+ `to` 支持两类目标:
540
+
541
+ - 群会话:`cid...`
542
+ - 单聊用户:`userId`,或显式写成 `user:<userId>`
543
+
544
+ 如果你传入的是远程图片 URL,插件当前会按下面的方式处理:
545
+
546
+ 1. 下载远程图片到本地临时文件
547
+ 2. 调用钉钉媒体上传接口获取 `media_id`
548
+ 3. 以独立图片消息发送
549
+ 4. 发送完成后清理临时文件
550
+
423
551
  ## 故障排除
424
552
 
425
553
  ### 收不到消息
@@ -547,13 +675,17 @@ MediaFile; // 下载的媒体文件
547
675
  sendBySession(config, sessionWebhook, text, options); // 通过会话发送
548
676
 
549
677
  // AI 互动卡片
550
- createAICard(config, conversationId, data, log); // 创建并投放 AI 卡片
678
+ createAICard(config, conversationId, log); // 创建并投放 AI 卡片
551
679
  streamAICard(card, content, finished, log); // 流式更新卡片内容
552
680
  finishAICard(card, content, log); // 完成并关闭卡片
553
681
 
554
682
  // 自动模式选择
555
683
  sendMessage(config, conversationId, text, options); // 根据配置自动选择(含卡片/文本回退)
556
684
 
685
+ // 主动媒体发送
686
+ uploadMedia(config, mediaPath, mediaType, log); // 上传媒体并返回 media_id
687
+ sendProactiveMedia(config, target, mediaPath, mediaType, options); // 发送图片/语音/视频/文件
688
+
557
689
  // 认证
558
690
  getAccessToken(config, log); // 获取访问令牌
559
691
  ```
@@ -561,10 +693,15 @@ getAccessToken(config, log); // 获取访问令牌
561
693
  **使用示例:**
562
694
 
563
695
  ```typescript
564
- import { createAICard, streamAICard, finishAICard } from './src/channel';
696
+ import {
697
+ createAICard,
698
+ finishAICard,
699
+ sendProactiveMedia,
700
+ streamAICard,
701
+ } from './src/channel';
565
702
 
566
703
  // 创建 AI 卡片
567
- const card = await createAICard(config, conversationId, messageData, log);
704
+ const card = await createAICard(config, conversationId, log);
568
705
 
569
706
  // 流式更新内容
570
707
  for (const chunk of aiResponseChunks) {
@@ -573,6 +710,12 @@ for (const chunk of aiResponseChunks) {
573
710
 
574
711
  // 完成并关闭卡片
575
712
  await finishAICard(card, finalText, log);
713
+
714
+ // 主动发送图片
715
+ await sendProactiveMedia(config, 'cidxxxxxxxx', '/absolute/path/to/photo.png', 'image', {
716
+ accountId: 'default',
717
+ log,
718
+ });
576
719
  ```
577
720
 
578
721
  ### 架构
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.1.3",
3
+ "version": "3.2.0",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -35,6 +35,7 @@
35
35
  "format:check": "oxfmt --check package.json tsconfig.json index.ts src/*.ts",
36
36
  "lint": "oxlint --type-aware index.ts src",
37
37
  "lint:fix": "oxlint --type-aware --fix index.ts src && pnpm format",
38
+ "monitor:stream": "node scripts/dingtalk-stream-monitor.mjs",
38
39
  "test": "vitest run",
39
40
  "test:coverage": "vitest run --coverage",
40
41
  "type-check": "tsc -p tsconfig.json"
@@ -50,7 +51,7 @@
50
51
  "@vitest/coverage-v8": "^3.2.4",
51
52
  "oxfmt": "0.34.0",
52
53
  "oxlint": "^1.49.0",
53
- "oxlint-tsgolint": "^0.14.2",
54
+ "oxlint-tsgolint": "^0.15.0",
54
55
  "typescript": "^5.3.0",
55
56
  "vitest": "^3.2.4"
56
57
  },
@@ -8,55 +8,19 @@ import type {
8
8
  AICardInstance,
9
9
  AICardStreamingRequest,
10
10
  DingTalkConfig,
11
- DingTalkInboundMessage,
12
11
  Logger,
13
12
  } from "./types";
14
13
  import { AICardStatus } from "./types";
15
14
 
16
15
  const DINGTALK_API = "https://api.dingtalk.com";
17
- // Card cache TTL (1 hour) for terminal states.
18
- const CARD_CACHE_TTL = 60 * 60 * 1000;
19
16
  // Thinking/tool stream snippets are truncated to keep card updates compact.
20
17
  const THINKING_TRUNCATE_LENGTH = 500;
21
18
 
22
- // AI Card instance cache for streaming updates.
23
- const aiCardInstances = new Map<string, AICardInstance>();
24
- // accountId:conversationId -> cardInstanceId
25
- const activeCardsByTarget = new Map<string, string>();
26
-
27
19
  // Helper to identify card terminal states.
28
20
  export function isCardInTerminalState(state: string): boolean {
29
21
  return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
30
22
  }
31
23
 
32
- export function getCardById(cardId: string): AICardInstance | undefined {
33
- return aiCardInstances.get(cardId);
34
- }
35
-
36
- export function getActiveCardIdByTarget(targetKey: string): string | undefined {
37
- return activeCardsByTarget.get(targetKey);
38
- }
39
-
40
- export function deleteActiveCardByTarget(targetKey: string): void {
41
- activeCardsByTarget.delete(targetKey);
42
- }
43
-
44
- export function cleanupCardCache(): void {
45
- const now = Date.now();
46
- // Clean terminal cards only; active cards stay in cache to support streaming continuity.
47
- for (const [cardInstanceId, instance] of aiCardInstances.entries()) {
48
- if (isCardInTerminalState(instance.state) && now - instance.lastUpdated > CARD_CACHE_TTL) {
49
- aiCardInstances.delete(cardInstanceId);
50
- for (const [targetKey, mappedCardId] of activeCardsByTarget.entries()) {
51
- if (mappedCardId === cardInstanceId) {
52
- activeCardsByTarget.delete(targetKey);
53
- break;
54
- }
55
- }
56
- }
57
- }
58
- }
59
-
60
24
  export function formatContentForCard(content: string, type: "thinking" | "tool"): string {
61
25
  if (!content) {
62
26
  return "";
@@ -122,11 +86,32 @@ async function sendTemplateMismatchNotification(
122
86
  }
123
87
  }
124
88
 
89
+ /**
90
+ * Send a proactive text message via card API (createAndDeliver + immediate finalize).
91
+ * Used in card mode to replace oToMessages/batchSend for single-chat users.
92
+ */
93
+ export async function sendProactiveCardText(
94
+ config: DingTalkConfig,
95
+ conversationId: string,
96
+ content: string,
97
+ log?: Logger,
98
+ ): Promise<{ ok: boolean; error?: string }> {
99
+ try {
100
+ const card = await createAICard(config, conversationId, log);
101
+ if (!card) {
102
+ return { ok: false, error: "Failed to create AI card" };
103
+ }
104
+ await finishAICard(card, content, log);
105
+ return { ok: true };
106
+ } catch (err: any) {
107
+ log?.error?.(`[DingTalk][AICard] Proactive card send failed: ${err.message}`);
108
+ return { ok: false, error: err.message };
109
+ }
110
+ }
111
+
125
112
  export async function createAICard(
126
113
  config: DingTalkConfig,
127
114
  conversationId: string,
128
- data: DingTalkInboundMessage,
129
- accountId: string,
130
115
  log?: Logger,
131
116
  ): Promise<AICardInstance | null> {
132
117
  try {
@@ -135,9 +120,6 @@ export async function createAICard(
135
120
  const cardInstanceId = `card_${randomUUID()}`;
136
121
 
137
122
  log?.info?.(`[DingTalk][AICard] Creating and delivering card outTrackId=${cardInstanceId}`);
138
- log?.debug?.(
139
- `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${conversationId}`,
140
- );
141
123
 
142
124
  const isGroup = conversationId.startsWith("cid");
143
125
 
@@ -146,11 +128,12 @@ export async function createAICard(
146
128
  }
147
129
 
148
130
  // DingTalk createAndDeliver API payload.
131
+ const cardTemplateKey = config.cardTemplateKey || "content";
149
132
  const createAndDeliverBody = {
150
133
  cardTemplateId: config.cardTemplateId,
151
134
  outTrackId: cardInstanceId,
152
135
  cardData: {
153
- cardParamMap: {},
136
+ cardParamMap: { [cardTemplateKey]: "" },
154
137
  },
155
138
  callbackType: "STREAM",
156
139
  imGroupOpenSpaceModel: { supportForward: true },
@@ -162,7 +145,9 @@ export async function createAICard(
162
145
  imGroupOpenDeliverModel: isGroup
163
146
  ? { robotCode: config.robotCode || config.clientId }
164
147
  : undefined,
165
- imRobotOpenDeliverModel: !isGroup ? { spaceType: "IM_ROBOT" } : undefined,
148
+ imRobotOpenDeliverModel: !isGroup
149
+ ? { spaceType: "IM_ROBOT", robotCode: config.robotCode || config.clientId }
150
+ : undefined,
166
151
  };
167
152
 
168
153
  if (isGroup && !config.robotCode) {
@@ -186,7 +171,7 @@ export async function createAICard(
186
171
  `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`,
187
172
  );
188
173
 
189
- // Cache the AI card instance with config reference for token refresh/recovery.
174
+ // Return the AI card instance with config reference for token refresh/recovery.
190
175
  const aiCardInstance: AICardInstance = {
191
176
  cardInstanceId,
192
177
  accessToken: token,
@@ -196,13 +181,6 @@ export async function createAICard(
196
181
  state: AICardStatus.PROCESSING,
197
182
  config,
198
183
  };
199
- aiCardInstances.set(cardInstanceId, aiCardInstance);
200
-
201
- const targetKey = `${accountId}:${conversationId}`;
202
- activeCardsByTarget.set(targetKey, cardInstanceId);
203
- log?.debug?.(
204
- `[DingTalk][AICard] Registered active card mapping: ${targetKey} -> ${cardInstanceId}`,
205
- );
206
184
 
207
185
  return aiCardInstance;
208
186
  } catch (err: any) {
@@ -274,6 +252,7 @@ export async function streamAICard(
274
252
  );
275
253
 
276
254
  card.lastUpdated = Date.now();
255
+ card.lastStreamedContent = content;
277
256
  if (finished) {
278
257
  card.state = AICardStatus.FINISHED;
279
258
  } else if (card.state === AICardStatus.PROCESSING) {
@@ -319,6 +298,7 @@ export async function streamAICard(
319
298
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
320
299
  );
321
300
  card.lastUpdated = Date.now();
301
+ card.lastStreamedContent = content;
322
302
  if (finished) {
323
303
  card.state = AICardStatus.FINISHED;
324
304
  } else if (card.state === AICardStatus.PROCESSING) {