@dingtalk-real-ai/dingtalk-connector 0.8.22 → 0.8.24-beta.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/CHANGELOG.md +4 -4
- package/dist/card-CAOOUHQM.mjs +400 -0
- package/dist/card-bridge-CRi-ZgU5.mjs +2 -0
- package/dist/card-bridge-CkMhxeNK.mjs +236 -0
- package/dist/chunk-upload-D2dlc99C.mjs +2 -0
- package/dist/{common-CGPC5bYt.mjs → common-CqP_VRV0.mjs} +1 -1
- package/dist/{common-BGJlWkEp.mjs → common-D1-5KKZu.mjs} +2 -2
- package/dist/{connection-D4uO_J9G.mjs → connection-RS9GgzXP.mjs} +1 -1
- package/dist/entry-bundled.mjs +4 -1
- package/dist/gateway-methods-5dVTBzdk.mjs +2 -0
- package/dist/{gateway-methods-C3nEHxL4.mjs → gateway-methods-vknfgGZR.mjs} +7 -5
- package/dist/index.mjs +5 -2
- package/dist/{media-BViJQGgb.mjs → media-B6LXbp29.mjs} +9 -8
- package/dist/{media-CIO05hZn.mjs → media-Bgj_b22c.mjs} +1 -1
- package/dist/{message-handler-0NLKAqHU.mjs → message-handler-DoQXpxfy.mjs} +16 -14
- package/dist/{messaging-C2zJ8O-o.mjs → messaging-Sa9Olh4h.mjs} +9 -404
- package/dist/{runtime-BCFW2-1B.mjs → runtime-XLDHI9Xj.mjs} +6 -5
- package/dist/token-BeN5QjK_.mjs +63 -0
- package/dist/{utils-DgNm1Ek_.mjs → utils-Bu6GzLbG.mjs} +2 -63
- package/dist/utils-CvdYcBIE.mjs +5 -0
- package/dist/utils-legacy-CzczxtWT.mjs +3 -0
- package/docs/{RELEASE_NOTES_V0.8.22.md → RELEASE_NOTES_V0.8.23.md} +8 -7
- package/index.ts +3 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/dingtalk-channel-rules/SKILL.md +91 -0
- package/skills/dingtalk-troubleshoot/SKILL.md +93 -0
- package/skills/dws-cli/SKILL.md +129 -0
- package/skills/dws-cli/references/error-codes.md +95 -0
- package/skills/dws-cli/references/field-rules.md +105 -0
- package/skills/dws-cli/references/global-reference.md +104 -0
- package/skills/dws-cli/references/intent-guide.md +114 -0
- package/skills/dws-cli/references/products/aitable.md +452 -0
- package/skills/dws-cli/references/products/attendance.md +93 -0
- package/skills/dws-cli/references/products/calendar.md +217 -0
- package/skills/dws-cli/references/products/chat.md +292 -0
- package/skills/dws-cli/references/products/contact.md +108 -0
- package/skills/dws-cli/references/products/ding.md +57 -0
- package/skills/dws-cli/references/products/report.md +162 -0
- package/skills/dws-cli/references/products/simple.md +128 -0
- package/skills/dws-cli/references/products/todo.md +138 -0
- package/skills/dws-cli/references/products/workbench.md +39 -0
- package/skills/dws-cli/references/recovery-guide.md +94 -0
- package/src/services/card-bridge.ts +380 -0
- package/dist/chunk-upload-p9PQRr1H.mjs +0 -2
- package/dist/gateway-methods-COXVcCFs.mjs +0 -2
- package/dist/utils-TpPdfqWr.mjs +0 -4
- package/dist/utils-legacy-CFYDBM4r.mjs +0 -3
- /package/dist/{chunk-upload-6p9cf3UB.mjs → chunk-upload-Dc2dqew5.mjs} +0 -0
- /package/dist/{game-xiyou-DxRHjOIJ.mjs → game-xiyou-JhCED0BK.mjs} +0 -0
- /package/dist/{session-DJ4jYqPv.mjs → session-7QpNCXBn.mjs} +0 -0
- /package/dist/{utils-legacy-CALCPP1t.mjs → utils-legacy-C-AthFoD.mjs} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,15 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.8.22] - 2026-05-24
|
|
10
|
+
## [0.8.23] - 2026-05-26 / [0.8.22] - 2026-05-24
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
> `0.8.23` 为 `0.8.22` 的重发版本,内容沿用自 `0.8.22` / `0.8.22-beta.0`,建议直接安装 `0.8.23`([#609](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/609))。
|
|
13
|
+
> `0.8.23` is a republish of `0.8.22` with identical functionality; please install `0.8.23` ([#609](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues/609)).
|
|
14
14
|
|
|
15
15
|
### 升级 / Upgrade
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
openclaw plugins install @dingtalk-real-ai/dingtalk-connector@0.8.
|
|
18
|
+
openclaw plugins install @dingtalk-real-ai/dingtalk-connector@0.8.23
|
|
19
19
|
openclaw gateway restart
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { t as dingtalkHttp } from "./http-client-DFWZgO1n.mjs";
|
|
2
|
+
import { r as getAccessToken, t as DINGTALK_API } from "./token-BeN5QjK_.mjs";
|
|
3
|
+
//#region src/services/messaging/card.ts
|
|
4
|
+
const AI_CARD_TEMPLATE_ID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema";
|
|
5
|
+
/**
|
|
6
|
+
* 钉钉卡片 API 的最大 QPS(官方限制约 40 次/秒)。
|
|
7
|
+
* 保守取 20,为 createAICardForTarget / finishAICard 等非流式调用留余量。
|
|
8
|
+
*/
|
|
9
|
+
const CARD_API_MAX_QPS = 20;
|
|
10
|
+
/** QPS 限流退避时长(ms),遇到 403 QpsLimit 后暂停发送 */
|
|
11
|
+
const QPS_BACKOFF_DURATION_MS = 2e3;
|
|
12
|
+
/**
|
|
13
|
+
* 全局令牌桶限流器,所有 streamAICard 调用共享。
|
|
14
|
+
*
|
|
15
|
+
* 解决的问题:每个 reply-dispatcher 实例有独立的 500ms 节流间隔,
|
|
16
|
+
* 但多个会话并发时总 QPS 会叠加超过钉钉 API 限制(40 次/秒),
|
|
17
|
+
* 导致频繁触发 403 QpsLimit 错误。
|
|
18
|
+
*
|
|
19
|
+
* 工作原理:
|
|
20
|
+
* - 令牌桶以 CARD_API_MAX_QPS 的速率补充令牌
|
|
21
|
+
* - 每次 API 调用前消耗一个令牌,无令牌时等待
|
|
22
|
+
* - 遇到 QpsLimit 错误时触发退避,暂停所有调用
|
|
23
|
+
*/
|
|
24
|
+
const cardRateLimiter = {
|
|
25
|
+
tokens: CARD_API_MAX_QPS,
|
|
26
|
+
lastRefillTime: Date.now(),
|
|
27
|
+
backoffUntil: 0,
|
|
28
|
+
_queueTail: Promise.resolve(),
|
|
29
|
+
refill() {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const elapsedSeconds = (now - this.lastRefillTime) / 1e3;
|
|
32
|
+
if (elapsedSeconds > 0) {
|
|
33
|
+
this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS);
|
|
34
|
+
this.lastRefillTime = now;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async waitForToken() {
|
|
38
|
+
const prev = this._queueTail;
|
|
39
|
+
let release;
|
|
40
|
+
this._queueTail = new Promise((resolve) => {
|
|
41
|
+
release = resolve;
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
await prev;
|
|
45
|
+
} catch {}
|
|
46
|
+
try {
|
|
47
|
+
let totalWaitMs = 0;
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (now < this.backoffUntil) {
|
|
50
|
+
const backoffWaitMs = this.backoffUntil - now;
|
|
51
|
+
await sleep(backoffWaitMs);
|
|
52
|
+
totalWaitMs += backoffWaitMs;
|
|
53
|
+
}
|
|
54
|
+
this.refill();
|
|
55
|
+
if (this.tokens < 1) {
|
|
56
|
+
const waitMs = Math.ceil((1 - this.tokens) / CARD_API_MAX_QPS * 1e3);
|
|
57
|
+
await sleep(waitMs);
|
|
58
|
+
totalWaitMs += waitMs;
|
|
59
|
+
this.refill();
|
|
60
|
+
}
|
|
61
|
+
this.tokens -= 1;
|
|
62
|
+
return totalWaitMs;
|
|
63
|
+
} finally {
|
|
64
|
+
release();
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
triggerBackoff() {
|
|
68
|
+
const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS;
|
|
69
|
+
this.backoffUntil = backoffEnd;
|
|
70
|
+
this.tokens = 0;
|
|
71
|
+
this.lastRefillTime = backoffEnd;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** 简单的 sleep 工具函数 */
|
|
75
|
+
function sleep(ms) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 判断错误是否为钉钉 QPS 限流错误。
|
|
80
|
+
*
|
|
81
|
+
* 导出给上层调用(如 reply-dispatcher),用于在错误处理时区分
|
|
82
|
+
* 「瞬时可恢复错误」与「真正的发送失败」,避免把 QPS 限流这种
|
|
83
|
+
* 内部已自动退避重试、后续会自动恢复的错误展示为用户可见的
|
|
84
|
+
* 「消息发送失败」提示。
|
|
85
|
+
*/
|
|
86
|
+
function isQpsLimitError(err) {
|
|
87
|
+
const errorCode = err?.response?.data?.code;
|
|
88
|
+
return err?.response?.status === 403 && typeof errorCode === "string" && errorCode.includes("QpsLimit");
|
|
89
|
+
}
|
|
90
|
+
/** AI Card 状态 */
|
|
91
|
+
const AICardStatus = {
|
|
92
|
+
PROCESSING: "1",
|
|
93
|
+
INPUTING: "2",
|
|
94
|
+
FINISHED: "3",
|
|
95
|
+
EXECUTING: "4",
|
|
96
|
+
FAILED: "5"
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* 统一换行符为 \n,避免 CRLF 干扰 Markdown 解析
|
|
100
|
+
*/
|
|
101
|
+
function normalizeLineEndings(text) {
|
|
102
|
+
return text.replace(/\r\n?/g, "\n");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 确保 Markdown 表格前有空行,否则钉钉无法正确渲染表格
|
|
106
|
+
*/
|
|
107
|
+
function ensureTableBlankLines(text) {
|
|
108
|
+
const lines = normalizeLineEndings(text).split("\n");
|
|
109
|
+
const result = [];
|
|
110
|
+
const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/;
|
|
111
|
+
const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/;
|
|
112
|
+
const isDivider = (line) => line && typeof line === "string" && line.includes("|") && tableDividerRegex.test(line);
|
|
113
|
+
for (let i = 0; i < lines.length; i++) {
|
|
114
|
+
const currentLine = lines[i];
|
|
115
|
+
const nextLine = lines[i + 1] ?? "";
|
|
116
|
+
if (tableRowRegex.test(currentLine) && isDivider(nextLine) && i > 0 && lines[i - 1].trim() !== "" && !tableRowRegex.test(lines[i - 1])) result.push("");
|
|
117
|
+
result.push(currentLine);
|
|
118
|
+
}
|
|
119
|
+
return result.join("\n");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 将单个 \n 转换为 <br>,保留 \n\n 段落分隔。
|
|
123
|
+
*
|
|
124
|
+
* 钉钉 AI Card 渲染器的换行约定:
|
|
125
|
+
* - 普通文本:用 `<br>` 做换行,`\n` 不创建视觉换行
|
|
126
|
+
* - 代码块(```):用 `\n` 做换行,`<br>` 会原样显示为文本
|
|
127
|
+
* - 列表(- / 1.)、表格(|)、标题(#):用 `\n` 做语法行分隔
|
|
128
|
+
* - 引用块(>):用 `<br>` + lazy continuation,续行不需要 `>`
|
|
129
|
+
* - 段落间距:`\n\n`
|
|
130
|
+
*
|
|
131
|
+
* 本函数按上述约定转换:
|
|
132
|
+
* - 代码块内:完全保留原始 `\n`
|
|
133
|
+
* - 连续引用行:合并为一行,`<br>` 连接,去掉续行 `>` 前缀
|
|
134
|
+
* - 其余:Markdown 块语法行前保留 `\n`,单 `\n` → `<br>`
|
|
135
|
+
*/
|
|
136
|
+
function fixNewlines(text) {
|
|
137
|
+
const normalized = normalizeLineEndings(text);
|
|
138
|
+
const markdownBlockStartPattern = /^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))/;
|
|
139
|
+
const fencePattern = /^\s{0,3}```/;
|
|
140
|
+
const quotePattern = /^\s{0,3}>\s?/;
|
|
141
|
+
const mergedLines = [];
|
|
142
|
+
let pendingQuoteLines = [];
|
|
143
|
+
let inCodeBlock = false;
|
|
144
|
+
const flushPendingQuoteLines = () => {
|
|
145
|
+
if (pendingQuoteLines.length > 0) {
|
|
146
|
+
mergedLines.push(pendingQuoteLines.join("<br>"));
|
|
147
|
+
pendingQuoteLines = [];
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
for (const line of normalized.split("\n")) {
|
|
151
|
+
const isFence = fencePattern.test(line);
|
|
152
|
+
if (inCodeBlock) {
|
|
153
|
+
flushPendingQuoteLines();
|
|
154
|
+
mergedLines.push(line);
|
|
155
|
+
if (isFence) inCodeBlock = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (isFence) {
|
|
159
|
+
flushPendingQuoteLines();
|
|
160
|
+
mergedLines.push(line);
|
|
161
|
+
inCodeBlock = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (quotePattern.test(line)) if (pendingQuoteLines.length === 0) pendingQuoteLines.push(line);
|
|
165
|
+
else pendingQuoteLines.push(line.replace(quotePattern, ""));
|
|
166
|
+
else {
|
|
167
|
+
flushPendingQuoteLines();
|
|
168
|
+
mergedLines.push(line);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
flushPendingQuoteLines();
|
|
172
|
+
const lines = mergedLines;
|
|
173
|
+
inCodeBlock = false;
|
|
174
|
+
const parts = [];
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const currentLine = lines[i];
|
|
177
|
+
const nextInCodeBlock = fencePattern.test(currentLine) ? !inCodeBlock : inCodeBlock;
|
|
178
|
+
if (i < lines.length - 1) {
|
|
179
|
+
const nextLine = lines[i + 1];
|
|
180
|
+
const keepNewline = nextInCodeBlock || currentLine === "" || nextLine === "" || fencePattern.test(nextLine) || markdownBlockStartPattern.test(nextLine);
|
|
181
|
+
parts.push(currentLine + (keepNewline ? "\n" : "<br>"));
|
|
182
|
+
} else parts.push(currentLine);
|
|
183
|
+
inCodeBlock = nextInCodeBlock;
|
|
184
|
+
}
|
|
185
|
+
return parts.join("");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 标准化 AI Card 消息内容:先修复表格空行,再处理换行符。
|
|
189
|
+
* 用于 streamAICard 和 finishAICard 的所有路径,确保行为一致。
|
|
190
|
+
*/
|
|
191
|
+
function normalizeForCard(content) {
|
|
192
|
+
return fixNewlines(ensureTableBlankLines(content));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 构建卡片投放请求体
|
|
196
|
+
*/
|
|
197
|
+
function buildDeliverBody(cardInstanceId, target, robotCode) {
|
|
198
|
+
const base = {
|
|
199
|
+
outTrackId: cardInstanceId,
|
|
200
|
+
userIdType: 1
|
|
201
|
+
};
|
|
202
|
+
if (target.type === "group") return {
|
|
203
|
+
...base,
|
|
204
|
+
openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
|
|
205
|
+
imGroupOpenDeliverModel: { robotCode }
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
|
|
210
|
+
imRobotOpenDeliverModel: {
|
|
211
|
+
spaceType: "IM_ROBOT",
|
|
212
|
+
robotCode,
|
|
213
|
+
extension: { dynamicSummary: "true" }
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 通用 AI Card 创建函数
|
|
219
|
+
*/
|
|
220
|
+
async function createAICardForTarget(config, target, log) {
|
|
221
|
+
const targetDesc = target.type === "group" ? `群聊 ${target.openConversationId}` : `用户 ${target.userId}`;
|
|
222
|
+
try {
|
|
223
|
+
const token = await getAccessToken(config);
|
|
224
|
+
const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
225
|
+
log?.info?.(`[DingTalk][AICard] 开始创建卡片:${targetDesc}, outTrackId=${cardInstanceId}`);
|
|
226
|
+
const createBody = {
|
|
227
|
+
cardTemplateId: AI_CARD_TEMPLATE_ID,
|
|
228
|
+
outTrackId: cardInstanceId,
|
|
229
|
+
cardData: { cardParamMap: { config: JSON.stringify({ autoLayout: true }) } },
|
|
230
|
+
callbackType: "STREAM",
|
|
231
|
+
imGroupOpenSpaceModel: { supportForward: true },
|
|
232
|
+
imRobotOpenSpaceModel: { supportForward: true }
|
|
233
|
+
};
|
|
234
|
+
await dingtalkHttp.post(`${DINGTALK_API}/v1.0/card/instances`, createBody, { headers: {
|
|
235
|
+
"x-acs-dingtalk-access-token": token,
|
|
236
|
+
"Content-Type": "application/json"
|
|
237
|
+
} });
|
|
238
|
+
const deliverBody = buildDeliverBody(cardInstanceId, target, String(config.clientId ?? ""));
|
|
239
|
+
await dingtalkHttp.post(`${DINGTALK_API}/v1.0/card/instances/deliver`, deliverBody, { headers: {
|
|
240
|
+
"x-acs-dingtalk-access-token": token,
|
|
241
|
+
"Content-Type": "application/json"
|
|
242
|
+
} });
|
|
243
|
+
return {
|
|
244
|
+
cardInstanceId,
|
|
245
|
+
accessToken: token,
|
|
246
|
+
tokenExpireTime: Date.now() + 7200 * 1e3,
|
|
247
|
+
inputingStarted: false
|
|
248
|
+
};
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log?.error?.(`[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${err.message}`);
|
|
251
|
+
if (err.response) log?.error?.(`[DingTalk][AICard] 错误响应:status=${err.response.status}`);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 确保 Token 有效(自动刷新过期的 Token)
|
|
257
|
+
*/
|
|
258
|
+
async function ensureValidToken(card, config) {
|
|
259
|
+
if (Date.now() > card.tokenExpireTime - 300 * 1e3) {
|
|
260
|
+
card.accessToken = await getAccessToken(config);
|
|
261
|
+
card.tokenExpireTime = Date.now() + 7200 * 1e3;
|
|
262
|
+
}
|
|
263
|
+
return card.accessToken;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 流式更新 AI Card 内容
|
|
267
|
+
*
|
|
268
|
+
* 内置全局令牌桶限流:所有会话共享同一速率限制,
|
|
269
|
+
* 遇到 QpsLimit 错误时自动退避 2 秒后重试一次。
|
|
270
|
+
*/
|
|
271
|
+
async function streamAICard(card, content, finished = false, config, log) {
|
|
272
|
+
if (!card) {
|
|
273
|
+
log?.warn?.(`[DingTalk][AICard] streamAICard 收到 null card,跳过更新`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (config) await ensureValidToken(card, config);
|
|
277
|
+
if (!card.inputingStarted) {
|
|
278
|
+
const inputingWaitMs = await cardRateLimiter.waitForToken();
|
|
279
|
+
if (inputingWaitMs > 0) log?.debug?.(`[DingTalk][AICard] INPUTING 等待限流令牌 ${inputingWaitMs}ms`);
|
|
280
|
+
const statusBody = {
|
|
281
|
+
outTrackId: card.cardInstanceId,
|
|
282
|
+
cardData: { cardParamMap: {
|
|
283
|
+
flowStatus: AICardStatus.INPUTING,
|
|
284
|
+
msgContent: normalizeForCard(content),
|
|
285
|
+
staticMsgContent: "",
|
|
286
|
+
sys_full_json_obj: JSON.stringify({ order: ["msgContent"] }),
|
|
287
|
+
config: JSON.stringify({ autoLayout: true })
|
|
288
|
+
} }
|
|
289
|
+
};
|
|
290
|
+
const putInputing = () => dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/instances`, statusBody, { headers: {
|
|
291
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
292
|
+
"Content-Type": "application/json"
|
|
293
|
+
} });
|
|
294
|
+
try {
|
|
295
|
+
const statusResp = await putInputing();
|
|
296
|
+
log?.info?.(`[DingTalk][AICard] INPUTING 响应:status=${statusResp.status}`);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (isQpsLimitError(err)) {
|
|
299
|
+
cardRateLimiter.triggerBackoff();
|
|
300
|
+
log?.warn?.(`[DingTalk][AICard] INPUTING 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`);
|
|
301
|
+
await cardRateLimiter.waitForToken();
|
|
302
|
+
try {
|
|
303
|
+
const retryResp = await putInputing();
|
|
304
|
+
log?.info?.(`[DingTalk][AICard] INPUTING 重试成功:status=${retryResp.status}`);
|
|
305
|
+
} catch (retryErr) {
|
|
306
|
+
log?.error?.(`[DingTalk][AICard] INPUTING 重试失败:${retryErr.message}`);
|
|
307
|
+
throw retryErr;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
log?.error?.(`[DingTalk][AICard] INPUTING 切换失败:${err.message}`);
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
card.inputingStarted = true;
|
|
315
|
+
}
|
|
316
|
+
const fixedContent = normalizeForCard(content);
|
|
317
|
+
const streamContent = finished ? fixedContent : fixedContent.replace(/\n+$/, "");
|
|
318
|
+
const body = {
|
|
319
|
+
outTrackId: card.cardInstanceId,
|
|
320
|
+
guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
321
|
+
key: "msgContent",
|
|
322
|
+
content: streamContent,
|
|
323
|
+
isFull: true,
|
|
324
|
+
isFinalize: finished,
|
|
325
|
+
isError: false
|
|
326
|
+
};
|
|
327
|
+
const streamWaitMs = await cardRateLimiter.waitForToken();
|
|
328
|
+
if (streamWaitMs > 0) log?.debug?.(`[DingTalk][AICard] streaming 等待限流令牌 ${streamWaitMs}ms`);
|
|
329
|
+
log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished}`);
|
|
330
|
+
try {
|
|
331
|
+
const streamResp = await dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/streaming`, body, { headers: {
|
|
332
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
333
|
+
"Content-Type": "application/json"
|
|
334
|
+
} });
|
|
335
|
+
log?.info?.(`[DingTalk][AICard] streaming 响应:status=${streamResp.status}`);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (isQpsLimitError(err)) {
|
|
338
|
+
cardRateLimiter.triggerBackoff();
|
|
339
|
+
log?.warn?.(`[DingTalk][AICard] streaming 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`);
|
|
340
|
+
await cardRateLimiter.waitForToken();
|
|
341
|
+
try {
|
|
342
|
+
body.guid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
343
|
+
await dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/streaming`, body, { headers: {
|
|
344
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
345
|
+
"Content-Type": "application/json"
|
|
346
|
+
} });
|
|
347
|
+
log?.info?.(`[DingTalk][AICard] streaming 重试成功`);
|
|
348
|
+
return;
|
|
349
|
+
} catch (retryErr) {
|
|
350
|
+
log?.error?.(`[DingTalk][AICard] streaming 重试失败:${retryErr.message}`);
|
|
351
|
+
throw retryErr;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 完成 AI Card
|
|
359
|
+
*/
|
|
360
|
+
async function finishAICard(card, content, config, log) {
|
|
361
|
+
if (config) await ensureValidToken(card, config);
|
|
362
|
+
const fixedContent = normalizeForCard(content);
|
|
363
|
+
log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`);
|
|
364
|
+
await streamAICard(card, fixedContent, true, config, log);
|
|
365
|
+
const body = {
|
|
366
|
+
outTrackId: card.cardInstanceId,
|
|
367
|
+
cardData: { cardParamMap: {
|
|
368
|
+
flowStatus: AICardStatus.FINISHED,
|
|
369
|
+
msgContent: fixedContent,
|
|
370
|
+
staticMsgContent: "",
|
|
371
|
+
sys_full_json_obj: JSON.stringify({ order: ["msgContent"] }),
|
|
372
|
+
config: JSON.stringify({ autoLayout: true })
|
|
373
|
+
} },
|
|
374
|
+
cardUpdateOptions: { updateCardDataByKey: true }
|
|
375
|
+
};
|
|
376
|
+
const putFinished = () => dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/instances`, body, { headers: {
|
|
377
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
378
|
+
"Content-Type": "application/json"
|
|
379
|
+
} });
|
|
380
|
+
try {
|
|
381
|
+
await cardRateLimiter.waitForToken();
|
|
382
|
+
const finishResp = await putFinished();
|
|
383
|
+
log?.info?.(`[DingTalk][AICard] FINISHED 响应:status=${finishResp.status}`);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (isQpsLimitError(err)) {
|
|
386
|
+
cardRateLimiter.triggerBackoff();
|
|
387
|
+
log?.warn?.(`[DingTalk][AICard] FINISHED 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`);
|
|
388
|
+
try {
|
|
389
|
+
await cardRateLimiter.waitForToken();
|
|
390
|
+
const retryResp = await putFinished();
|
|
391
|
+
log?.info?.(`[DingTalk][AICard] FINISHED 重试成功:status=${retryResp.status}`);
|
|
392
|
+
return;
|
|
393
|
+
} catch (retryErr) {
|
|
394
|
+
log?.error?.(`[DingTalk][AICard] FINISHED 重试失败:${retryErr.message}`);
|
|
395
|
+
}
|
|
396
|
+
} else log?.error?.(`[DingTalk][AICard] FINISHED 更新失败:${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
export { streamAICard as i, finishAICard as n, isQpsLimitError as r, createAICardForTarget as t };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { a as resolveDingtalkAccount } from "./accounts-CF4oK_HZ.mjs";
|
|
2
|
+
import { i as streamAICard, n as finishAICard, t as createAICardForTarget } from "./card-CAOOUHQM.mjs";
|
|
3
|
+
//#region src/services/card-bridge.ts
|
|
4
|
+
const DINGTALK_CARD_BRIDGE_SYMBOL = Symbol.for("@dingtalk-connector/card-bridge");
|
|
5
|
+
const CARD_RECORD_TTL_MS = 1440 * 60 * 1e3;
|
|
6
|
+
const CARD_RECORD_MAX_SIZE = 1e4;
|
|
7
|
+
const CARD_RECORD_CLEANUP_INTERVAL_MS = 3600 * 1e3;
|
|
8
|
+
const DEFAULT_FAILED_CARD_MARKDOWN = "Task failed";
|
|
9
|
+
const TARGET_ID_MAX_LENGTH = 256;
|
|
10
|
+
const TARGET_ID_DANGEROUS_CHARS_PATTERN = /[<>"\x00-\x1f\x7f]/;
|
|
11
|
+
const cards = /* @__PURE__ */ new Map();
|
|
12
|
+
let cleanupTimerInstalled = false;
|
|
13
|
+
var PublicError = class extends Error {};
|
|
14
|
+
function nowMs() {
|
|
15
|
+
return Date.now();
|
|
16
|
+
}
|
|
17
|
+
function asRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
19
|
+
}
|
|
20
|
+
function optionalString(value) {
|
|
21
|
+
return typeof value === "string" ? value : void 0;
|
|
22
|
+
}
|
|
23
|
+
function errorMessage(err) {
|
|
24
|
+
if (err instanceof Error) return err.message;
|
|
25
|
+
if (typeof err === "string") return err;
|
|
26
|
+
return "Unknown error";
|
|
27
|
+
}
|
|
28
|
+
function publicErrorMessage(err, fallback) {
|
|
29
|
+
return err instanceof PublicError ? err.message : fallback;
|
|
30
|
+
}
|
|
31
|
+
function isValidTargetId(value) {
|
|
32
|
+
return value.length > 0 && value.length <= TARGET_ID_MAX_LENGTH && !TARGET_ID_DANGEROUS_CHARS_PATTERN.test(value);
|
|
33
|
+
}
|
|
34
|
+
function parseCardTarget(target) {
|
|
35
|
+
if (typeof target !== "string") return null;
|
|
36
|
+
const raw = target.trim();
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
const lowered = raw.toLowerCase();
|
|
39
|
+
if (lowered.startsWith("user:")) {
|
|
40
|
+
const userId = raw.slice(5).trim();
|
|
41
|
+
return isValidTargetId(userId) ? {
|
|
42
|
+
type: "user",
|
|
43
|
+
userId
|
|
44
|
+
} : null;
|
|
45
|
+
}
|
|
46
|
+
if (lowered.startsWith("group:")) {
|
|
47
|
+
const openConversationId = raw.slice(6).trim();
|
|
48
|
+
return isValidTargetId(openConversationId) ? {
|
|
49
|
+
type: "group",
|
|
50
|
+
openConversationId
|
|
51
|
+
} : null;
|
|
52
|
+
}
|
|
53
|
+
if (raw.startsWith("cid") && isValidTargetId(raw)) return {
|
|
54
|
+
type: "group",
|
|
55
|
+
openConversationId: raw
|
|
56
|
+
};
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function targetToString(target) {
|
|
60
|
+
return target.type === "user" ? `user:${target.userId}` : `group:${target.openConversationId}`;
|
|
61
|
+
}
|
|
62
|
+
function normalizeStatus(value) {
|
|
63
|
+
if (value === "completed" || value === "failed") return value;
|
|
64
|
+
return "running";
|
|
65
|
+
}
|
|
66
|
+
function cleanupExpiredCards(log, timestamp = nowMs()) {
|
|
67
|
+
let removed = 0;
|
|
68
|
+
for (const [cardInstanceId, record] of cards) if (timestamp - record.lastUsedAt > CARD_RECORD_TTL_MS) {
|
|
69
|
+
cards.delete(cardInstanceId);
|
|
70
|
+
removed += 1;
|
|
71
|
+
}
|
|
72
|
+
if (removed > 0) log?.info?.(`[DingTalk][CardBridge] cleaned expired cards count=${removed}`);
|
|
73
|
+
return removed;
|
|
74
|
+
}
|
|
75
|
+
function evictOverflowCards(log) {
|
|
76
|
+
const overflow = cards.size - CARD_RECORD_MAX_SIZE;
|
|
77
|
+
if (overflow <= 0) return 0;
|
|
78
|
+
const oldest = [...cards.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt).slice(0, overflow);
|
|
79
|
+
for (const [cardInstanceId] of oldest) cards.delete(cardInstanceId);
|
|
80
|
+
log?.warn?.(`[DingTalk][CardBridge] evicted old cards count=${oldest.length}`);
|
|
81
|
+
return oldest.length;
|
|
82
|
+
}
|
|
83
|
+
function ensureCleanupTimerInstalled() {
|
|
84
|
+
if (cleanupTimerInstalled) return;
|
|
85
|
+
setInterval(() => {
|
|
86
|
+
cleanupExpiredCards();
|
|
87
|
+
}, CARD_RECORD_CLEANUP_INTERVAL_MS).unref?.();
|
|
88
|
+
cleanupTimerInstalled = true;
|
|
89
|
+
}
|
|
90
|
+
function rememberCard(card, config, log) {
|
|
91
|
+
cleanupExpiredCards(log);
|
|
92
|
+
const record = {
|
|
93
|
+
card,
|
|
94
|
+
config,
|
|
95
|
+
lastUsedAt: nowMs(),
|
|
96
|
+
queue: Promise.resolve()
|
|
97
|
+
};
|
|
98
|
+
cards.set(card.cardInstanceId, record);
|
|
99
|
+
evictOverflowCards(log);
|
|
100
|
+
return record;
|
|
101
|
+
}
|
|
102
|
+
async function loadRuntimeConfig(api, cfg) {
|
|
103
|
+
if (cfg) return cfg;
|
|
104
|
+
const apiConfig = api.config;
|
|
105
|
+
if (apiConfig) return apiConfig;
|
|
106
|
+
const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
|
|
107
|
+
return loadConfig();
|
|
108
|
+
}
|
|
109
|
+
function getCardRecord(cardInstanceId) {
|
|
110
|
+
const record = cards.get(cardInstanceId);
|
|
111
|
+
if (!record) throw new PublicError(`Unknown cardInstanceId: ${cardInstanceId}`);
|
|
112
|
+
record.lastUsedAt = nowMs();
|
|
113
|
+
return record;
|
|
114
|
+
}
|
|
115
|
+
function resolveAccountConfig(cfg, accountId, log, method) {
|
|
116
|
+
const account = resolveDingtalkAccount({
|
|
117
|
+
cfg,
|
|
118
|
+
accountId: typeof accountId === "string" ? accountId : void 0
|
|
119
|
+
});
|
|
120
|
+
if (!account.enabled || !account.configured) throw new PublicError("DingTalk not configured");
|
|
121
|
+
log?.debug?.(`[DingTalk][CardBridge][${method}] using accountId=${account.accountId}`);
|
|
122
|
+
return account;
|
|
123
|
+
}
|
|
124
|
+
async function createCard(params) {
|
|
125
|
+
const account = resolveAccountConfig(params.cfg, params.accountId, params.log, "card.create");
|
|
126
|
+
const card = await createAICardForTarget(account.config, params.target, params.log);
|
|
127
|
+
if (!card) throw new PublicError("Failed to create DingTalk AI Card");
|
|
128
|
+
rememberCard(card, account.config, params.log);
|
|
129
|
+
try {
|
|
130
|
+
if (params.markdown) await streamAICard(card, params.markdown, false, account.config, params.log);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
cards.delete(card.cardInstanceId);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
params.log?.info?.(`[DingTalk][CardBridge][card.create] created cardInstanceId=${card.cardInstanceId}`);
|
|
136
|
+
return {
|
|
137
|
+
cardInstanceId: card.cardInstanceId,
|
|
138
|
+
accountId: account.accountId,
|
|
139
|
+
target: targetToString(params.target)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function updateCard(params) {
|
|
143
|
+
const record = getCardRecord(params.cardInstanceId);
|
|
144
|
+
const status = normalizeStatus(params.status);
|
|
145
|
+
const operation = record.queue.then(() => updateCardRecord(record, params.cardInstanceId, params.markdown, status, params.log), () => updateCardRecord(record, params.cardInstanceId, params.markdown, status, params.log));
|
|
146
|
+
record.queue = operation.catch(() => void 0);
|
|
147
|
+
await operation;
|
|
148
|
+
return {
|
|
149
|
+
cardInstanceId: params.cardInstanceId,
|
|
150
|
+
status
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function updateCardRecord(record, cardInstanceId, markdown, status, log) {
|
|
154
|
+
if (cards.get(cardInstanceId) !== record) throw new PublicError(`Unknown cardInstanceId: ${cardInstanceId}`);
|
|
155
|
+
record.lastUsedAt = nowMs();
|
|
156
|
+
log?.info?.(`[DingTalk][CardBridge][card.update] cardInstanceId=${cardInstanceId} status=${status}`);
|
|
157
|
+
if (status === "completed") try {
|
|
158
|
+
await finishAICard(record.card, markdown || " ", record.config, log);
|
|
159
|
+
} finally {
|
|
160
|
+
cards.delete(cardInstanceId);
|
|
161
|
+
}
|
|
162
|
+
else if (status === "failed") {
|
|
163
|
+
const content = markdown.trim() ? markdown : DEFAULT_FAILED_CARD_MARKDOWN;
|
|
164
|
+
try {
|
|
165
|
+
await finishAICard(record.card, content, record.config, log);
|
|
166
|
+
} finally {
|
|
167
|
+
cards.delete(cardInstanceId);
|
|
168
|
+
}
|
|
169
|
+
} else await streamAICard(record.card, markdown || " ", false, record.config, log);
|
|
170
|
+
}
|
|
171
|
+
function installDingtalkCardBridge(api) {
|
|
172
|
+
ensureCleanupTimerInstalled();
|
|
173
|
+
const g = globalThis;
|
|
174
|
+
g[DINGTALK_CARD_BRIDGE_SYMBOL] = {
|
|
175
|
+
async create(params) {
|
|
176
|
+
const cfg = await loadRuntimeConfig(api, params?.cfg);
|
|
177
|
+
const target = parseCardTarget(params?.target);
|
|
178
|
+
if (!target) throw new PublicError("target is required (user:<userId>, group:<openConversationId>, or cid...)");
|
|
179
|
+
return createCard({
|
|
180
|
+
cfg,
|
|
181
|
+
accountId: params?.accountId,
|
|
182
|
+
target,
|
|
183
|
+
markdown: optionalString(params?.markdown),
|
|
184
|
+
log: params?.log ?? api.logger
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async update(params) {
|
|
188
|
+
if (!params?.cardInstanceId) throw new PublicError("cardInstanceId is required");
|
|
189
|
+
return updateCard({
|
|
190
|
+
cardInstanceId: String(params.cardInstanceId),
|
|
191
|
+
markdown: String(params?.markdown ?? ""),
|
|
192
|
+
status: normalizeStatus(params?.status),
|
|
193
|
+
log: params?.log ?? api.logger
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function registerDingtalkCardGatewayMethods(api) {
|
|
199
|
+
const log = api.logger;
|
|
200
|
+
api.registerGatewayMethod("dingtalk-connector.card.create", async ({ params, respond }) => {
|
|
201
|
+
try {
|
|
202
|
+
const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
|
|
203
|
+
const cfg = loadConfig();
|
|
204
|
+
const rawParams = asRecord(params);
|
|
205
|
+
const target = parseCardTarget(rawParams.target);
|
|
206
|
+
if (!target) return respond(false, { error: "target is required (user:<userId>, group:<openConversationId>, or cid...)" });
|
|
207
|
+
respond(true, await createCard({
|
|
208
|
+
cfg,
|
|
209
|
+
accountId: optionalString(rawParams.accountId),
|
|
210
|
+
target,
|
|
211
|
+
markdown: optionalString(rawParams.markdown),
|
|
212
|
+
log
|
|
213
|
+
}));
|
|
214
|
+
} catch (err) {
|
|
215
|
+
log?.error?.(`[Gateway][card.create] 错误: ${errorMessage(err)}`);
|
|
216
|
+
respond(false, { error: publicErrorMessage(err, "card.create failed") });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
api.registerGatewayMethod("dingtalk-connector.card.update", async ({ params, respond }) => {
|
|
220
|
+
try {
|
|
221
|
+
const rawParams = asRecord(params);
|
|
222
|
+
if (!rawParams.cardInstanceId) return respond(false, { error: "cardInstanceId is required" });
|
|
223
|
+
respond(true, await updateCard({
|
|
224
|
+
cardInstanceId: String(rawParams.cardInstanceId),
|
|
225
|
+
markdown: String(rawParams.markdown ?? ""),
|
|
226
|
+
status: normalizeStatus(rawParams.status),
|
|
227
|
+
log
|
|
228
|
+
}));
|
|
229
|
+
} catch (err) {
|
|
230
|
+
log?.error?.(`[Gateway][card.update] 错误: ${errorMessage(err)}`);
|
|
231
|
+
respond(false, { error: publicErrorMessage(err, "card.update failed") });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
export { installDingtalkCardBridge as n, registerDingtalkCardGatewayMethods as r, DINGTALK_CARD_BRIDGE_SYMBOL as t };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as VIDEO_MARKER_PATTERN, r as FILE_MARKER_PATTERN, t as AUDIO_MARKER_PATTERN } from "./common-
|
|
1
|
+
import { a as VIDEO_MARKER_PATTERN, r as FILE_MARKER_PATTERN, t as AUDIO_MARKER_PATTERN } from "./common-D1-5KKZu.mjs";
|
|
2
2
|
export { AUDIO_MARKER_PATTERN, FILE_MARKER_PATTERN, VIDEO_MARKER_PATTERN };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as createLogger } from "./logger-BDWwViGT.mjs";
|
|
2
2
|
import { r as dingtalkUploadHttp } from "./http-client-DFWZgO1n.mjs";
|
|
3
|
-
import { t as CHUNK_CONFIG } from "./chunk-upload-
|
|
3
|
+
import { t as CHUNK_CONFIG } from "./chunk-upload-Dc2dqew5.mjs";
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import FormData from "form-data";
|
|
@@ -50,7 +50,7 @@ async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 2
|
|
|
50
50
|
if ((mediaType === "video" || mediaType === "file") && fileSize > CHUNK_CONFIG.SIZE_THRESHOLD) {
|
|
51
51
|
log?.info?.(`文件超过 20MB,使用分块上传:${absPath} (${fileSizeMB}MB)`);
|
|
52
52
|
try {
|
|
53
|
-
const { uploadLargeFileByChunks } = await import("./chunk-upload-
|
|
53
|
+
const { uploadLargeFileByChunks } = await import("./chunk-upload-D2dlc99C.mjs");
|
|
54
54
|
const downloadCode = await uploadLargeFileByChunks(absPath, mediaType, oapiToken, debugEnabled);
|
|
55
55
|
if (downloadCode) {
|
|
56
56
|
log?.info?.(`分块上传成功:${absPath}, download_code: ${downloadCode}`);
|