@soimy/dingtalk 3.1.4 → 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 +155 -12
- package/package.json +2 -2
- package/src/card-service.ts +31 -51
- package/src/channel.ts +317 -94
- package/src/config-schema.ts +9 -1
- package/src/connection-manager.ts +14 -1
- package/src/inbound-handler.ts +221 -177
- package/src/media-utils.ts +517 -3
- package/src/onboarding.ts +47 -3
- package/src/send-service.ts +73 -27
- package/src/session-lock.ts +32 -0
- package/src/types.ts +15 -0
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` 默认为 `'
|
|
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
|
-
| 互动卡片
|
|
316
|
-
| 图片
|
|
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: '
|
|
485
|
+
cardTemplateKey: 'content', // 卡片内容字段键(默认值:content)
|
|
411
486
|
}
|
|
412
487
|
```
|
|
413
488
|
|
|
414
|
-
> **注意**:`cardTemplateKey` 应与您的卡片模板中定义的字段名称一致。默认值为 `'
|
|
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,
|
|
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 {
|
|
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,
|
|
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.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@vitest/coverage-v8": "^3.2.4",
|
|
52
52
|
"oxfmt": "0.34.0",
|
|
53
53
|
"oxlint": "^1.49.0",
|
|
54
|
-
"oxlint-tsgolint": "^0.
|
|
54
|
+
"oxlint-tsgolint": "^0.15.0",
|
|
55
55
|
"typescript": "^5.3.0",
|
|
56
56
|
"vitest": "^3.2.4"
|
|
57
57
|
},
|
package/src/card-service.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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) {
|