@soimy/dingtalk 2.7.0 → 3.0.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 +99 -3
- package/index.ts +9 -8
- package/package.json +32 -28
- package/src/access-control.ts +55 -0
- package/src/auth.ts +47 -0
- package/src/card-service.ts +338 -0
- package/src/channel.ts +145 -1590
- package/src/config-schema.ts +7 -12
- package/src/config.ts +79 -0
- package/src/connection-manager.ts +69 -32
- package/src/dedup.ts +66 -0
- package/src/group-members-store.ts +45 -0
- package/src/inbound-handler.ts +453 -0
- package/src/logger-context.ts +17 -0
- package/src/media-utils.ts +24 -20
- package/src/message-utils.ts +156 -0
- package/src/onboarding.ts +70 -67
- package/src/peer-id-registry.ts +6 -2
- package/src/runtime.ts +2 -2
- package/src/send-service.ts +281 -0
- package/src/signature.ts +15 -0
- package/src/types.ts +45 -30
- package/src/utils.ts +29 -16
- package/clawbot.plugin.json +0 -9
- package/src/AGENTS.md +0 -63
- package/src/openclaw-channel-dingtalk.code-workspace +0 -17
package/README.md
CHANGED
|
@@ -44,10 +44,70 @@ openclaw plugins install -l .
|
|
|
44
44
|
2. 确保包含 `index.ts`, `openclaw.plugin.json` 和 `package.json`。
|
|
45
45
|
3. 运行 `openclaw plugins list` 确认 `dingtalk` 已显示在列表中。
|
|
46
46
|
|
|
47
|
+
### 安装后必做:配置插件信任白名单(`plugins.allow`)
|
|
48
|
+
|
|
49
|
+
从 OpenClaw 新版本开始,如果发现了非内置插件且 `plugins.allow` 为空,会提示:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load ...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
这是一条安全告警(不是安装失败),建议显式写入你信任的插件 id。
|
|
56
|
+
|
|
57
|
+
#### 步骤 1:确认插件 id
|
|
58
|
+
|
|
59
|
+
本插件 id 固定为:`dingtalk`(定义于 `openclaw.plugin.json`)。
|
|
60
|
+
|
|
61
|
+
也可用下面命令查看已发现插件:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
openclaw plugins list
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### 步骤 2:在 `~/.openclaw/openclaw.json` 添加 `plugins.allow`
|
|
68
|
+
|
|
69
|
+
```json5
|
|
70
|
+
{
|
|
71
|
+
"plugins": {
|
|
72
|
+
"enabled": true,
|
|
73
|
+
"allow": ["dingtalk"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
如果你还有其他已安装且需要启用的插件,请一并加入,例如:
|
|
79
|
+
|
|
80
|
+
```json5
|
|
81
|
+
{
|
|
82
|
+
"plugins": {
|
|
83
|
+
"allow": ["dingtalk", "telegram", "voice-call"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### 步骤 3:重启 Gateway
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
openclaw gateway restart
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> 注意:如果你之前已经配置过 `plugins.allow`,但没有 `dingtalk`,那么插件不会被加载。请把 `dingtalk` 加入该列表。
|
|
95
|
+
|
|
47
96
|
## 更新
|
|
48
97
|
|
|
98
|
+
`openclaw plugins update` 使用插件 id(不是 npm 包名),并且仅适用于 npm 安装来源。
|
|
99
|
+
|
|
100
|
+
如果你是通过 npm 安装本插件:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
openclaw plugins update dingtalk
|
|
49
104
|
```
|
|
50
|
-
|
|
105
|
+
|
|
106
|
+
如果你是本地源码/链接安装(`openclaw plugins install -l .`),请在插件目录更新代码后重启 Gateway:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git pull
|
|
110
|
+
openclaw gateway restart
|
|
51
111
|
```
|
|
52
112
|
|
|
53
113
|
## 配置
|
|
@@ -140,12 +200,17 @@ openclaw configure --section channels
|
|
|
140
200
|
|
|
141
201
|
### 方法 2:手动配置文件
|
|
142
202
|
|
|
143
|
-
在 `~/.openclaw/openclaw.json`
|
|
203
|
+
在 `~/.openclaw/openclaw.json` 中添加(仅作参考,交互式配置会自动生成):
|
|
144
204
|
|
|
145
|
-
>
|
|
205
|
+
> 至少包含 `plugins.allow` 和 `channels.dingtalk` 两部分,内容参考上文钉钉开发者配置指南
|
|
146
206
|
|
|
147
207
|
```json5
|
|
148
208
|
{
|
|
209
|
+
"plugins": {
|
|
210
|
+
"enabled": true,
|
|
211
|
+
"allow": ["dingtalk"]
|
|
212
|
+
},
|
|
213
|
+
|
|
149
214
|
...
|
|
150
215
|
"channels": {
|
|
151
216
|
"telegram": { ... },
|
|
@@ -496,6 +561,37 @@ await finishAICard(card, finalText, log);
|
|
|
496
561
|
- **src/types.ts**: 类型定义
|
|
497
562
|
- **utils.ts**: 通用工具函数
|
|
498
563
|
|
|
564
|
+
## 测试
|
|
565
|
+
|
|
566
|
+
项目已基于 Vitest 初始化自动化测试,目录结构如下:
|
|
567
|
+
|
|
568
|
+
```text
|
|
569
|
+
tests/
|
|
570
|
+
unit/
|
|
571
|
+
sign.test.ts # HmacSHA256 + Base64 签名测试
|
|
572
|
+
message-transform.test.ts # 文本/Markdown 消息转换测试
|
|
573
|
+
integration/
|
|
574
|
+
send-lifecycle.test.ts # 插件 outbound.sendText 生命周期适配测试
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### 运行测试
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
# 安装依赖(pnpm)
|
|
581
|
+
pnpm install
|
|
582
|
+
|
|
583
|
+
# 运行全部测试
|
|
584
|
+
pnpm test
|
|
585
|
+
|
|
586
|
+
# 生成覆盖率报告(coverage/)
|
|
587
|
+
pnpm test:coverage
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Mock 约束
|
|
591
|
+
|
|
592
|
+
- 所有测试中的网络请求均通过 `vi.mock('axios')` 拦截,禁止真实调用钉钉 API。
|
|
593
|
+
- 集成测试通过模块 mock 隔离 `openclaw/plugin-sdk`、`dingtalk-stream` 等外部依赖。
|
|
594
|
+
|
|
499
595
|
## 许可
|
|
500
596
|
|
|
501
597
|
MIT
|
package/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from
|
|
2
|
-
import { emptyPluginConfigSchema } from
|
|
3
|
-
import { dingtalkPlugin } from
|
|
4
|
-
import { setDingTalkRuntime } from
|
|
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:
|
|
8
|
-
name:
|
|
9
|
-
description:
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
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
|
-
"
|
|
10
|
+
"openclaw",
|
|
23
11
|
"stream",
|
|
24
|
-
"钉钉"
|
|
25
|
-
"bot"
|
|
12
|
+
"钉钉"
|
|
26
13
|
],
|
|
27
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
36
|
-
"
|
|
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
|
-
"@
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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
|
+
}
|