@jeik/dingtalk-connector 0.8.21-fix1
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 +686 -0
- package/LICENSE +21 -0
- package/README.en.md +181 -0
- package/README.md +221 -0
- package/bin/dingtalk-connector.js +858 -0
- package/bin/wizard-config.mjs +110 -0
- package/dist/accounts-BAzdqkAV.mjs +268 -0
- package/dist/accounts-BQptOmgB.mjs +2 -0
- package/dist/chunk-upload-BBQgGtcZ.mjs +193 -0
- package/dist/chunk-upload-DaLXXZH3.mjs +2 -0
- package/dist/common-C8pYKU_y.mjs +2 -0
- package/dist/common-Dt9n6fQN.mjs +101 -0
- package/dist/connection-DHHFFNQJ.mjs +423 -0
- package/dist/entry-bundled.d.mts +16 -0
- package/dist/entry-bundled.mjs +31 -0
- package/dist/game-xiyou-CqHt-6Q1.mjs +4271 -0
- package/dist/gateway-methods-C4tcgI7P.mjs +771 -0
- package/dist/gateway-methods-Ci31A3vg.mjs +2 -0
- package/dist/http-client-CpnJHB89.mjs +2 -0
- package/dist/http-client-DFWZgO1n.mjs +33 -0
- package/dist/index.d.mts +193 -0
- package/dist/index.mjs +45 -0
- package/dist/logger-BmJkQkm1.mjs +2 -0
- package/dist/logger-mZ9OSbmD.mjs +58 -0
- package/dist/media-C_SVin7s.mjs +2 -0
- package/dist/media-cz72EVS3.mjs +509 -0
- package/dist/message-handler-DESzFFDc.mjs +1971 -0
- package/dist/messaging-B6l1sRvX.mjs +1044 -0
- package/dist/runtime-DUgpo5zC.mjs +1422 -0
- package/dist/session-DJ4jYqPv.mjs +114 -0
- package/dist/utils-Bjh4r_qS.mjs +4 -0
- package/dist/utils-CIfI_3Jh.mjs +63 -0
- package/dist/utils-legacy-CALCPP1t.mjs +230 -0
- package/dist/utils-legacy-CFYDBM4r.mjs +3 -0
- package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
- package/docs/DEAP_AGENT_GUIDE.md +115 -0
- package/docs/DINGTALK_MANUAL_SETUP.md +50 -0
- package/docs/MULTI_AGENT_SETUP.md +306 -0
- package/docs/RELEASE_NOTES_V0.7.10.md +40 -0
- package/docs/RELEASE_NOTES_V0.7.2.md +143 -0
- package/docs/RELEASE_NOTES_V0.7.3.md +149 -0
- package/docs/RELEASE_NOTES_V0.7.4.md +206 -0
- package/docs/RELEASE_NOTES_V0.7.5.md +267 -0
- package/docs/RELEASE_NOTES_V0.7.6.md +219 -0
- package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
- package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
- package/docs/RELEASE_NOTES_V0.7.9.md +65 -0
- package/docs/RELEASE_NOTES_V0.8.0.md +53 -0
- package/docs/RELEASE_NOTES_V0.8.1.md +47 -0
- package/docs/RELEASE_NOTES_V0.8.10.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.11.md +51 -0
- package/docs/RELEASE_NOTES_V0.8.12.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.13-beta.0.md +69 -0
- package/docs/RELEASE_NOTES_V0.8.13.md +62 -0
- package/docs/RELEASE_NOTES_V0.8.14.md +86 -0
- package/docs/RELEASE_NOTES_V0.8.16.md +40 -0
- package/docs/RELEASE_NOTES_V0.8.17.md +87 -0
- package/docs/RELEASE_NOTES_V0.8.18.md +64 -0
- package/docs/RELEASE_NOTES_V0.8.19.md +62 -0
- package/docs/RELEASE_NOTES_V0.8.2.md +55 -0
- package/docs/RELEASE_NOTES_V0.8.20.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.3.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.4.md +45 -0
- package/docs/RELEASE_NOTES_V0.8.7.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.8.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.9.md +81 -0
- package/docs/RELEASE_NOTES_v0.7.0.md +142 -0
- package/docs/RELEASE_NOTES_v0.7.1.md +74 -0
- package/docs/TROUBLESHOOTING.md +122 -0
- package/index.ts +77 -0
- package/openclaw.plugin.json +551 -0
- package/package.json +147 -0
- 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/channel.ts +588 -0
- package/src/config/accounts.ts +242 -0
- package/src/config/schema.ts +180 -0
- package/src/core/connection.ts +741 -0
- package/src/core/message-handler.ts +1788 -0
- package/src/core/provider.ts +111 -0
- package/src/core/state.ts +54 -0
- package/src/device-auth-config.ts +14 -0
- package/src/device-auth.ts +197 -0
- package/src/directory.ts +95 -0
- package/src/docs.ts +293 -0
- package/src/game-xiyou/achievement-engine.ts +252 -0
- package/src/game-xiyou/bounty-system.ts +315 -0
- package/src/game-xiyou/commands.ts +223 -0
- package/src/game-xiyou/drop-engine.ts +241 -0
- package/src/game-xiyou/encounter-system.ts +135 -0
- package/src/game-xiyou/escape-engine.ts +164 -0
- package/src/game-xiyou/exp-calculator.ts +139 -0
- package/src/game-xiyou/index.ts +479 -0
- package/src/game-xiyou/level-system.ts +91 -0
- package/src/game-xiyou/monster-pool.ts +180 -0
- package/src/game-xiyou/pity-counter.ts +114 -0
- package/src/game-xiyou/random-event-engine.ts +648 -0
- package/src/game-xiyou/renderer.ts +679 -0
- package/src/game-xiyou/storage.ts +218 -0
- package/src/game-xiyou/treasure-system.ts +105 -0
- package/src/game-xiyou/types.ts +582 -0
- package/src/game-xiyou/uid-resolver.ts +49 -0
- package/src/gateway-methods.ts +740 -0
- package/src/onboarding.ts +553 -0
- package/src/policy.ts +32 -0
- package/src/probe.ts +210 -0
- package/src/reply-dispatcher.ts +874 -0
- package/src/runtime.ts +32 -0
- package/src/sdk/helpers.ts +322 -0
- package/src/sdk/types.ts +519 -0
- package/src/secret-input.ts +19 -0
- package/src/services/media/audio.ts +54 -0
- package/src/services/media/chunk-upload.ts +296 -0
- package/src/services/media/common.ts +155 -0
- package/src/services/media/file.ts +75 -0
- package/src/services/media/image.ts +81 -0
- package/src/services/media/index.ts +10 -0
- package/src/services/media/video.ts +162 -0
- package/src/services/media.ts +1143 -0
- package/src/services/messaging/card.ts +604 -0
- package/src/services/messaging/index.ts +18 -0
- package/src/services/messaging/mentions.ts +267 -0
- package/src/services/messaging/send.ts +141 -0
- package/src/services/messaging.ts +1191 -0
- package/src/services/reply-markers.ts +55 -0
- package/src/targets.ts +45 -0
- package/src/types/index.ts +59 -0
- package/src/types/pdf-parse.d.ts +3 -0
- package/src/utils/agent.ts +63 -0
- package/src/utils/async.ts +51 -0
- package/src/utils/constants.ts +27 -0
- package/src/utils/http-client.ts +38 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +78 -0
- package/src/utils/session.ts +147 -0
- package/src/utils/token.ts +93 -0
- package/src/utils/utils-legacy.ts +454 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Card 流式响应模块
|
|
3
|
+
* 支持 AI Card 创建、流式更新、完成
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DingtalkConfig } from "../../types/index.ts";
|
|
7
|
+
import { DINGTALK_API, getAccessToken } from "../../utils/token.ts";
|
|
8
|
+
import { dingtalkHttp } from "../../utils/http-client.ts";
|
|
9
|
+
|
|
10
|
+
// ============ 全局 AI Card 活跃注册表 ============
|
|
11
|
+
// 用于让 outbound.sendText(message 工具)能感知当前会话是否有活跃的 AI Card,
|
|
12
|
+
// 并将消息路由到 streamAICard 而非发送独立的 DingTalk 消息气泡。
|
|
13
|
+
// key: openConversationId(群聊对话 ID,如 "cidXXXX")
|
|
14
|
+
const _activeCardRegistry = new Map<string, AICardInstance>();
|
|
15
|
+
|
|
16
|
+
export function registerActiveCard(openConversationId: string, card: AICardInstance): void {
|
|
17
|
+
_activeCardRegistry.set(openConversationId, card);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function unregisterActiveCard(openConversationId: string): void {
|
|
21
|
+
_activeCardRegistry.delete(openConversationId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getActiveCardForConversation(openConversationId: string): AICardInstance | null {
|
|
25
|
+
return _activeCardRegistry.get(openConversationId) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============ 常量 ============
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CARD_TEMPLATE_ID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema";
|
|
31
|
+
const DEFAULT_CARD_CONTENT_VAR = "msgContent";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 钉钉卡片 API 的最大 QPS(官方限制约 40 次/秒)。
|
|
35
|
+
* 保守取 20,为 createAICardForTarget / finishAICard 等非流式调用留余量。
|
|
36
|
+
*/
|
|
37
|
+
const CARD_API_MAX_QPS = 20;
|
|
38
|
+
|
|
39
|
+
/** QPS 限流退避时长(ms),遇到 403 QpsLimit 后暂停发送 */
|
|
40
|
+
const QPS_BACKOFF_DURATION_MS = 2_000;
|
|
41
|
+
|
|
42
|
+
// ============ 全局令牌桶限流器 ============
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 全局令牌桶限流器,所有 streamAICard 调用共享。
|
|
46
|
+
*
|
|
47
|
+
* 解决的问题:每个 reply-dispatcher 实例有独立的 500ms 节流间隔,
|
|
48
|
+
* 但多个会话并发时总 QPS 会叠加超过钉钉 API 限制(40 次/秒),
|
|
49
|
+
* 导致频繁触发 403 QpsLimit 错误。
|
|
50
|
+
*
|
|
51
|
+
* 工作原理:
|
|
52
|
+
* - 令牌桶以 CARD_API_MAX_QPS 的速率补充令牌
|
|
53
|
+
* - 每次 API 调用前消耗一个令牌,无令牌时等待
|
|
54
|
+
* - 遇到 QpsLimit 错误时触发退避,暂停所有调用
|
|
55
|
+
*/
|
|
56
|
+
const cardRateLimiter = {
|
|
57
|
+
/** 当前可用令牌数 */
|
|
58
|
+
tokens: CARD_API_MAX_QPS,
|
|
59
|
+
/** 上次令牌补充时间 */
|
|
60
|
+
lastRefillTime: Date.now(),
|
|
61
|
+
/** QPS 退避截止时间(遇到限流错误后设置) */
|
|
62
|
+
backoffUntil: 0,
|
|
63
|
+
/**
|
|
64
|
+
* 串行化锁:保证并发的 waitForToken 被一个一个处理。
|
|
65
|
+
* 否则多个并发调用会同时通过 `tokens < 1` 检查并各自扣减,
|
|
66
|
+
* 令牌桶会被并发击穿,导致实际 QPS 远超 CARD_API_MAX_QPS。
|
|
67
|
+
*/
|
|
68
|
+
_queueTail: Promise.resolve() as Promise<unknown>,
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 补充令牌:按时间流逝恢复令牌数
|
|
72
|
+
*/
|
|
73
|
+
refill(): void {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const elapsedSeconds = (now - this.lastRefillTime) / 1000;
|
|
76
|
+
if (elapsedSeconds > 0) {
|
|
77
|
+
this.tokens = Math.min(
|
|
78
|
+
CARD_API_MAX_QPS,
|
|
79
|
+
this.tokens + elapsedSeconds * CARD_API_MAX_QPS,
|
|
80
|
+
);
|
|
81
|
+
this.lastRefillTime = now;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 等待直到有可用令牌,或退避期结束
|
|
87
|
+
* @returns 等待的毫秒数(0 表示无需等待)
|
|
88
|
+
*
|
|
89
|
+
* 通过 `_queueTail` 将所有并发调用串行化,确保 token 扣减真正生效。
|
|
90
|
+
*/
|
|
91
|
+
async waitForToken(): Promise<number> {
|
|
92
|
+
const prev = this._queueTail;
|
|
93
|
+
let release!: () => void;
|
|
94
|
+
this._queueTail = new Promise<void>((resolve) => {
|
|
95
|
+
release = resolve;
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await prev;
|
|
99
|
+
} catch {
|
|
100
|
+
/* 忽略前序错误,只用于串行等待 */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
let totalWaitMs = 0;
|
|
105
|
+
|
|
106
|
+
// 如果处于退避期,先等待退避结束
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (now < this.backoffUntil) {
|
|
109
|
+
const backoffWaitMs = this.backoffUntil - now;
|
|
110
|
+
await sleep(backoffWaitMs);
|
|
111
|
+
totalWaitMs += backoffWaitMs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.refill();
|
|
115
|
+
|
|
116
|
+
// 如果没有可用令牌,等待直到有令牌
|
|
117
|
+
if (this.tokens < 1) {
|
|
118
|
+
const waitMs = Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000);
|
|
119
|
+
await sleep(waitMs);
|
|
120
|
+
totalWaitMs += waitMs;
|
|
121
|
+
this.refill();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.tokens -= 1;
|
|
125
|
+
return totalWaitMs;
|
|
126
|
+
} finally {
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 触发退避:遇到 QpsLimit 错误时调用
|
|
133
|
+
*/
|
|
134
|
+
triggerBackoff(): void {
|
|
135
|
+
const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS;
|
|
136
|
+
this.backoffUntil = backoffEnd;
|
|
137
|
+
// 清空令牌,退避期结束后重新补充
|
|
138
|
+
this.tokens = 0;
|
|
139
|
+
this.lastRefillTime = backoffEnd;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** 简单的 sleep 工具函数 */
|
|
144
|
+
function sleep(ms: number): Promise<void> {
|
|
145
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 判断错误是否为钉钉 QPS 限流错误。
|
|
150
|
+
*
|
|
151
|
+
* 导出给上层调用(如 reply-dispatcher),用于在错误处理时区分
|
|
152
|
+
* 「瞬时可恢复错误」与「真正的发送失败」,避免把 QPS 限流这种
|
|
153
|
+
* 内部已自动退避重试、后续会自动恢复的错误展示为用户可见的
|
|
154
|
+
* 「消息发送失败」提示。
|
|
155
|
+
*/
|
|
156
|
+
export function isQpsLimitError(err: any): boolean {
|
|
157
|
+
const errorCode = err?.response?.data?.code;
|
|
158
|
+
return (
|
|
159
|
+
err?.response?.status === 403 &&
|
|
160
|
+
typeof errorCode === "string" &&
|
|
161
|
+
errorCode.includes("QpsLimit")
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** AI Card 状态 */
|
|
166
|
+
const AICardStatus = {
|
|
167
|
+
PROCESSING: "1",
|
|
168
|
+
INPUTING: "2",
|
|
169
|
+
FINISHED: "3",
|
|
170
|
+
EXECUTING: "4",
|
|
171
|
+
FAILED: "5",
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
/** AI Card 实例接口 */
|
|
175
|
+
export interface AICardInstance {
|
|
176
|
+
cardInstanceId: string;
|
|
177
|
+
accessToken: string;
|
|
178
|
+
tokenExpireTime: number;
|
|
179
|
+
inputingStarted: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** AI Card 投放目标类型 */
|
|
183
|
+
export type AICardTarget =
|
|
184
|
+
| { type: "user"; userId: string }
|
|
185
|
+
| { type: "group"; openConversationId: string };
|
|
186
|
+
|
|
187
|
+
// ============ Markdown 格式修正 ============
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 确保 Markdown 表格前有空行,否则钉钉无法正确渲染表格
|
|
191
|
+
*/
|
|
192
|
+
function ensureTableBlankLines(text: string): string {
|
|
193
|
+
const lines = text.split("\n");
|
|
194
|
+
const result: string[] = [];
|
|
195
|
+
|
|
196
|
+
const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/;
|
|
197
|
+
const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/;
|
|
198
|
+
|
|
199
|
+
const isDivider = (line: string) =>
|
|
200
|
+
line &&
|
|
201
|
+
typeof line === "string" &&
|
|
202
|
+
line.includes("|") &&
|
|
203
|
+
tableDividerRegex.test(line);
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
206
|
+
const currentLine = lines[i];
|
|
207
|
+
const nextLine = lines[i + 1] ?? "";
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
tableRowRegex.test(currentLine) &&
|
|
211
|
+
isDivider(nextLine) &&
|
|
212
|
+
i > 0 &&
|
|
213
|
+
lines[i - 1].trim() !== "" &&
|
|
214
|
+
!tableRowRegex.test(lines[i - 1])
|
|
215
|
+
) {
|
|
216
|
+
result.push("");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
result.push(currentLine);
|
|
220
|
+
}
|
|
221
|
+
return result.join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============ AI Card 相关 ============
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 构建卡片投放请求体
|
|
228
|
+
*/
|
|
229
|
+
export function buildDeliverBody(
|
|
230
|
+
cardInstanceId: string,
|
|
231
|
+
target: AICardTarget,
|
|
232
|
+
robotCode: string,
|
|
233
|
+
): any {
|
|
234
|
+
const base = { outTrackId: cardInstanceId, userIdType: 1 };
|
|
235
|
+
|
|
236
|
+
if (target.type === "group") {
|
|
237
|
+
return {
|
|
238
|
+
...base,
|
|
239
|
+
openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
|
|
240
|
+
imGroupOpenDeliverModel: {
|
|
241
|
+
robotCode,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
...base,
|
|
248
|
+
openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
|
|
249
|
+
imRobotOpenDeliverModel: {
|
|
250
|
+
spaceType: 'IM_ROBOT',
|
|
251
|
+
robotCode,
|
|
252
|
+
extension: {
|
|
253
|
+
dynamicSummary: 'true',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 通用 AI Card 创建函数
|
|
261
|
+
*/
|
|
262
|
+
export async function createAICardForTarget(
|
|
263
|
+
config: DingtalkConfig,
|
|
264
|
+
target: AICardTarget,
|
|
265
|
+
log?: any,
|
|
266
|
+
): Promise<AICardInstance | null> {
|
|
267
|
+
const targetDesc =
|
|
268
|
+
target.type === "group"
|
|
269
|
+
? `群聊 ${target.openConversationId}`
|
|
270
|
+
: `用户 ${target.userId}`;
|
|
271
|
+
|
|
272
|
+
const cardTemplateId = config.cardTemplateId || DEFAULT_CARD_TEMPLATE_ID;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const token = await getAccessToken(config);
|
|
276
|
+
const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
277
|
+
|
|
278
|
+
log?.info?.(
|
|
279
|
+
`[DingTalk][AICard] 开始创建卡片:${targetDesc}, outTrackId=${cardInstanceId}, templateId=${cardTemplateId}`,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// 1. 创建卡片实例
|
|
283
|
+
const createBody = {
|
|
284
|
+
cardTemplateId: cardTemplateId,
|
|
285
|
+
outTrackId: cardInstanceId,
|
|
286
|
+
cardData: {
|
|
287
|
+
cardParamMap: {
|
|
288
|
+
config: JSON.stringify({ autoLayout: true }),
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
callbackType: "STREAM",
|
|
292
|
+
imGroupOpenSpaceModel: { supportForward: true },
|
|
293
|
+
imRobotOpenSpaceModel: { supportForward: true },
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const createResp = await dingtalkHttp.post(
|
|
297
|
+
`${DINGTALK_API}/v1.0/card/instances`,
|
|
298
|
+
createBody,
|
|
299
|
+
{
|
|
300
|
+
headers: {
|
|
301
|
+
"x-acs-dingtalk-access-token": token,
|
|
302
|
+
"Content-Type": "application/json",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// 2. 投放卡片
|
|
308
|
+
const deliverBody = buildDeliverBody(
|
|
309
|
+
cardInstanceId,
|
|
310
|
+
target,
|
|
311
|
+
String(config.clientId ?? ""),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const deliverResp = await dingtalkHttp.post(
|
|
315
|
+
`${DINGTALK_API}/v1.0/card/instances/deliver`,
|
|
316
|
+
deliverBody,
|
|
317
|
+
{
|
|
318
|
+
headers: {
|
|
319
|
+
"x-acs-dingtalk-access-token": token,
|
|
320
|
+
"Content-Type": "application/json",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// 记录 token 过期时间(钉钉 token 有效期 2 小时)
|
|
326
|
+
const tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000;
|
|
327
|
+
|
|
328
|
+
return { cardInstanceId, accessToken: token, tokenExpireTime, inputingStarted: false };
|
|
329
|
+
} catch (err: any) {
|
|
330
|
+
log?.error?.(
|
|
331
|
+
`[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${err.message}`,
|
|
332
|
+
);
|
|
333
|
+
if (err.response) {
|
|
334
|
+
log?.error?.(
|
|
335
|
+
`[DingTalk][AICard] 错误响应:status=${err.response.status}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 确保 Token 有效(自动刷新过期的 Token)
|
|
344
|
+
*/
|
|
345
|
+
async function ensureValidToken(
|
|
346
|
+
card: AICardInstance,
|
|
347
|
+
config: DingtalkConfig,
|
|
348
|
+
): Promise<string> {
|
|
349
|
+
// 如果 token 即将过期(提前 5 分钟刷新)
|
|
350
|
+
if (Date.now() > card.tokenExpireTime - 5 * 60 * 1000) {
|
|
351
|
+
const newToken = await getAccessToken(config);
|
|
352
|
+
card.accessToken = newToken;
|
|
353
|
+
card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000;
|
|
354
|
+
}
|
|
355
|
+
return card.accessToken;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 流式更新 AI Card 内容
|
|
360
|
+
*
|
|
361
|
+
* 内置全局令牌桶限流:所有会话共享同一速率限制,
|
|
362
|
+
* 遇到 QpsLimit 错误时自动退避 2 秒后重试一次。
|
|
363
|
+
*/
|
|
364
|
+
export async function streamAICard(
|
|
365
|
+
card: AICardInstance,
|
|
366
|
+
content: string,
|
|
367
|
+
finished: boolean = false,
|
|
368
|
+
config?: DingtalkConfig,
|
|
369
|
+
log?: any,
|
|
370
|
+
/** 覆盖默认变量名,优先级:contentVar > config.cardProcessVar > config.cardContentVar */
|
|
371
|
+
contentVar?: string,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
// marker 剥离:所有卡片写入都经过这里,是钉钉侧的单一 chokepoint。
|
|
374
|
+
// 带标记 → 提取最终答案 + 剥离;不带 → 原样。
|
|
375
|
+
const hadMarker = content.includes("[-process-]") || content.includes("[-final-]");
|
|
376
|
+
let finalContent = content;
|
|
377
|
+
if (hadMarker) {
|
|
378
|
+
const i = content.lastIndexOf("[-final-]");
|
|
379
|
+
finalContent = i >= 0 ? content.slice(i + "[-final-]".length) : content;
|
|
380
|
+
finalContent = finalContent.split("[-process-]").join("").split("[-final-]").join("").replace(/^[ \t\r\n]+/, "");
|
|
381
|
+
log?.info?.(`[DingTalk][marker] ${finished ? "finishAICard" : "streamAICard"} 检测到标记,已剥离(${content.length}→${finalContent.length} 字)`);
|
|
382
|
+
}
|
|
383
|
+
content = finalContent;
|
|
384
|
+
|
|
385
|
+
const varName = contentVar
|
|
386
|
+
|| (config?.cardProcessVar as string)
|
|
387
|
+
|| (config?.cardContentVar as string)
|
|
388
|
+
|| DEFAULT_CARD_CONTENT_VAR;
|
|
389
|
+
// 防御 null card(createAICardForTarget 失败返回 null,调用方可能用 as any 绕过类型检查)
|
|
390
|
+
if (!card) {
|
|
391
|
+
log?.warn?.(`[DingTalk][AICard] streamAICard 收到 null card,跳过更新`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// 确保 token 有效
|
|
395
|
+
if (config) {
|
|
396
|
+
await ensureValidToken(card, config);
|
|
397
|
+
}
|
|
398
|
+
if (!card.inputingStarted) {
|
|
399
|
+
// 等待全局限流令牌(INPUTING 状态切换也消耗 QPS)
|
|
400
|
+
const inputingWaitMs = await cardRateLimiter.waitForToken();
|
|
401
|
+
if (inputingWaitMs > 0) {
|
|
402
|
+
log?.debug?.(`[DingTalk][AICard] INPUTING 等待限流令牌 ${inputingWaitMs}ms`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const statusBody = {
|
|
406
|
+
outTrackId: card.cardInstanceId,
|
|
407
|
+
cardData: {
|
|
408
|
+
cardParamMap: {
|
|
409
|
+
flowStatus: AICardStatus.INPUTING,
|
|
410
|
+
[varName]: content,
|
|
411
|
+
staticMsgContent: "",
|
|
412
|
+
sys_full_json_obj: JSON.stringify({
|
|
413
|
+
order: [varName],
|
|
414
|
+
}),
|
|
415
|
+
config: JSON.stringify({ autoLayout: true }),
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
const putInputing = () =>
|
|
420
|
+
dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/instances`, statusBody, {
|
|
421
|
+
headers: {
|
|
422
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
423
|
+
"Content-Type": "application/json",
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
try {
|
|
427
|
+
const statusResp = await putInputing();
|
|
428
|
+
log?.info?.(
|
|
429
|
+
`[DingTalk][AICard] INPUTING 响应:status=${statusResp.status}`,
|
|
430
|
+
);
|
|
431
|
+
} catch (err: any) {
|
|
432
|
+
if (isQpsLimitError(err)) {
|
|
433
|
+
// 与 streaming 分支一致:QPS 限流是瞬时错误,退避后重试一次,
|
|
434
|
+
// 避免首个 chunk 失败就向上抛错触发用户可见的兜底消息。
|
|
435
|
+
cardRateLimiter.triggerBackoff();
|
|
436
|
+
log?.warn?.(
|
|
437
|
+
`[DingTalk][AICard] INPUTING 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`,
|
|
438
|
+
);
|
|
439
|
+
await cardRateLimiter.waitForToken();
|
|
440
|
+
try {
|
|
441
|
+
const retryResp = await putInputing();
|
|
442
|
+
log?.info?.(
|
|
443
|
+
`[DingTalk][AICard] INPUTING 重试成功:status=${retryResp.status}`,
|
|
444
|
+
);
|
|
445
|
+
} catch (retryErr: any) {
|
|
446
|
+
log?.error?.(
|
|
447
|
+
`[DingTalk][AICard] INPUTING 重试失败:${retryErr.message}`,
|
|
448
|
+
);
|
|
449
|
+
throw retryErr;
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
log?.error?.(`[DingTalk][AICard] INPUTING 切换失败:${err.message}`);
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
card.inputingStarted = true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const fixedContent = ensureTableBlankLines(content);
|
|
460
|
+
const body = {
|
|
461
|
+
outTrackId: card.cardInstanceId,
|
|
462
|
+
guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
463
|
+
key: varName,
|
|
464
|
+
content: fixedContent,
|
|
465
|
+
isFull: true,
|
|
466
|
+
isFinalize: finished,
|
|
467
|
+
isError: false,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// 等待全局限流令牌
|
|
471
|
+
const streamWaitMs = await cardRateLimiter.waitForToken();
|
|
472
|
+
if (streamWaitMs > 0) {
|
|
473
|
+
log?.debug?.(`[DingTalk][AICard] streaming 等待限流令牌 ${streamWaitMs}ms`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
log?.info?.(
|
|
477
|
+
`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished}`,
|
|
478
|
+
);
|
|
479
|
+
try {
|
|
480
|
+
const streamResp = await dingtalkHttp.put(
|
|
481
|
+
`${DINGTALK_API}/v1.0/card/streaming`,
|
|
482
|
+
body,
|
|
483
|
+
{
|
|
484
|
+
headers: {
|
|
485
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
486
|
+
"Content-Type": "application/json",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
log?.info?.(
|
|
491
|
+
`[DingTalk][AICard] streaming 响应:status=${streamResp.status}`,
|
|
492
|
+
);
|
|
493
|
+
} catch (err: any) {
|
|
494
|
+
if (isQpsLimitError(err)) {
|
|
495
|
+
// 触发退避后重试一次,确保 finalize 等关键更新不丢失
|
|
496
|
+
cardRateLimiter.triggerBackoff();
|
|
497
|
+
log?.warn?.(`[DingTalk][AICard] streaming 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`);
|
|
498
|
+
await cardRateLimiter.waitForToken();
|
|
499
|
+
try {
|
|
500
|
+
// 重试时更新 guid 避免重复
|
|
501
|
+
body.guid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
502
|
+
await dingtalkHttp.put(
|
|
503
|
+
`${DINGTALK_API}/v1.0/card/streaming`,
|
|
504
|
+
body,
|
|
505
|
+
{
|
|
506
|
+
headers: {
|
|
507
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
508
|
+
"Content-Type": "application/json",
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
log?.info?.(`[DingTalk][AICard] streaming 重试成功`);
|
|
513
|
+
return;
|
|
514
|
+
} catch (retryErr: any) {
|
|
515
|
+
log?.error?.(`[DingTalk][AICard] streaming 重试失败:${retryErr.message}`);
|
|
516
|
+
throw retryErr;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 完成 AI Card
|
|
525
|
+
*/
|
|
526
|
+
export async function finishAICard(
|
|
527
|
+
card: AICardInstance,
|
|
528
|
+
content: string,
|
|
529
|
+
config?: DingtalkConfig,
|
|
530
|
+
log?: any,
|
|
531
|
+
/** 写入内容变量名(默认 cardContentVar) */
|
|
532
|
+
contentVar?: string,
|
|
533
|
+
): Promise<void> {
|
|
534
|
+
const varName = contentVar
|
|
535
|
+
|| (config?.cardContentVar as string)
|
|
536
|
+
|| DEFAULT_CARD_CONTENT_VAR;
|
|
537
|
+
// 确保 token 有效
|
|
538
|
+
if (config) {
|
|
539
|
+
await ensureValidToken(card, config);
|
|
540
|
+
}
|
|
541
|
+
const fixedContent = ensureTableBlankLines(content);
|
|
542
|
+
log?.info?.(
|
|
543
|
+
`[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
await streamAICard(card, fixedContent, true, config, log);
|
|
547
|
+
|
|
548
|
+
const body = {
|
|
549
|
+
outTrackId: card.cardInstanceId,
|
|
550
|
+
cardData: {
|
|
551
|
+
cardParamMap: {
|
|
552
|
+
flowStatus: AICardStatus.FINISHED,
|
|
553
|
+
[varName]: fixedContent,
|
|
554
|
+
staticMsgContent: "",
|
|
555
|
+
sys_full_json_obj: JSON.stringify({
|
|
556
|
+
order: [varName],
|
|
557
|
+
}),
|
|
558
|
+
config: JSON.stringify({ autoLayout: true }),
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
cardUpdateOptions: { updateCardDataByKey: true },
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const putFinished = () =>
|
|
565
|
+
dingtalkHttp.put(`${DINGTALK_API}/v1.0/card/instances`, body, {
|
|
566
|
+
headers: {
|
|
567
|
+
"x-acs-dingtalk-access-token": card.accessToken,
|
|
568
|
+
"Content-Type": "application/json",
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
// Wait for a rate-limiter token before the FINISHED PUT call to avoid
|
|
574
|
+
// exceeding QPS limits when multiple conversations finish concurrently.
|
|
575
|
+
await cardRateLimiter.waitForToken();
|
|
576
|
+
const finishResp = await putFinished();
|
|
577
|
+
log?.info?.(
|
|
578
|
+
`[DingTalk][AICard] FINISHED 响应:status=${finishResp.status}`,
|
|
579
|
+
);
|
|
580
|
+
} catch (err: any) {
|
|
581
|
+
if (isQpsLimitError(err)) {
|
|
582
|
+
// FINISHED 失败会让卡片卡在"思考中"状态(loading 动画不消失),
|
|
583
|
+
// 是最影响用户体验的失败路径,必须退避重试一次以兜底。
|
|
584
|
+
cardRateLimiter.triggerBackoff();
|
|
585
|
+
log?.warn?.(
|
|
586
|
+
`[DingTalk][AICard] FINISHED 触发 QPS 限流,退避 ${QPS_BACKOFF_DURATION_MS}ms 后重试`,
|
|
587
|
+
);
|
|
588
|
+
try {
|
|
589
|
+
await cardRateLimiter.waitForToken();
|
|
590
|
+
const retryResp = await putFinished();
|
|
591
|
+
log?.info?.(
|
|
592
|
+
`[DingTalk][AICard] FINISHED 重试成功:status=${retryResp.status}`,
|
|
593
|
+
);
|
|
594
|
+
return;
|
|
595
|
+
} catch (retryErr: any) {
|
|
596
|
+
log?.error?.(
|
|
597
|
+
`[DingTalk][AICard] FINISHED 重试失败:${retryErr.message}`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
log?.error?.(`[DingTalk][AICard] FINISHED 更新失败:${err.message}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息发送模块统一导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from './send.ts';
|
|
6
|
+
export * from './card.ts';
|
|
7
|
+
export * from './mentions.ts';
|
|
8
|
+
|
|
9
|
+
// 兼容旧实现(`src/services/messaging.ts`)中仍被外部调用的 API。
|
|
10
|
+
// 注意:这里只显式导出函数,避免与 `send.ts/card.ts` 的类型/常量命名冲突。
|
|
11
|
+
export {
|
|
12
|
+
sendMessage,
|
|
13
|
+
sendProactive,
|
|
14
|
+
sendToUser,
|
|
15
|
+
sendToGroup,
|
|
16
|
+
sendTextToDingTalk,
|
|
17
|
+
sendMediaToDingTalk,
|
|
18
|
+
} from '../messaging.ts';
|