@huo15/dingtalk-connector-pro 1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +479 -0
  4. package/README.md +209 -0
  5. package/docs/AGENT_ROUTING.md +335 -0
  6. package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
  7. package/docs/DEAP_AGENT_GUIDE.md +115 -0
  8. package/docs/images/dingtalk.svg +1 -0
  9. package/docs/images/image-1.png +0 -0
  10. package/docs/images/image-2.png +0 -0
  11. package/docs/images/image-3.png +0 -0
  12. package/docs/images/image-4.png +0 -0
  13. package/docs/images/image-5.png +0 -0
  14. package/docs/images/image-6.png +0 -0
  15. package/docs/images/image-7.png +0 -0
  16. package/index.ts +28 -0
  17. package/install-beta.sh +438 -0
  18. package/install-npm.sh +167 -0
  19. package/openclaw.plugin.json +498 -0
  20. package/package.json +80 -0
  21. package/src/channel.ts +463 -0
  22. package/src/config/accounts.ts +242 -0
  23. package/src/config/schema.ts +148 -0
  24. package/src/core/connection.ts +722 -0
  25. package/src/core/message-handler.ts +1700 -0
  26. package/src/core/provider.ts +111 -0
  27. package/src/core/state.ts +54 -0
  28. package/src/directory.ts +95 -0
  29. package/src/docs.ts +293 -0
  30. package/src/gateway-methods.ts +404 -0
  31. package/src/onboarding.ts +413 -0
  32. package/src/policy.ts +32 -0
  33. package/src/probe.ts +212 -0
  34. package/src/reply-dispatcher.ts +630 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/sdk/helpers.ts +322 -0
  37. package/src/sdk/types.ts +513 -0
  38. package/src/secret-input.ts +19 -0
  39. package/src/services/media/audio.ts +54 -0
  40. package/src/services/media/chunk-upload.ts +296 -0
  41. package/src/services/media/common.ts +155 -0
  42. package/src/services/media/file.ts +70 -0
  43. package/src/services/media/image.ts +81 -0
  44. package/src/services/media/index.ts +10 -0
  45. package/src/services/media/video.ts +162 -0
  46. package/src/services/media.ts +1136 -0
  47. package/src/services/messaging/card.ts +342 -0
  48. package/src/services/messaging/index.ts +17 -0
  49. package/src/services/messaging/send.ts +141 -0
  50. package/src/services/messaging.ts +1013 -0
  51. package/src/targets.ts +45 -0
  52. package/src/types/index.ts +59 -0
  53. package/src/utils/agent.ts +63 -0
  54. package/src/utils/async.ts +51 -0
  55. package/src/utils/constants.ts +27 -0
  56. package/src/utils/http-client.ts +37 -0
  57. package/src/utils/index.ts +8 -0
  58. package/src/utils/logger.ts +78 -0
  59. package/src/utils/session.ts +147 -0
  60. package/src/utils/token.ts +93 -0
  61. package/src/utils/utils-legacy.ts +454 -0
  62. package/tsconfig.json +20 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * 钉钉插件工具函数
3
+ */
4
+
5
+ import type { DingtalkConfig, ResolvedDingtalkAccount } from '../types/index.ts';
6
+
7
+ // SessionContext 和 buildSessionContext 统一由 session.ts 维护
8
+ export type { SessionContext } from './session.ts';
9
+ export { buildSessionContext } from './session.ts';
10
+
11
+ // ============ 常量 ============
12
+
13
+ /** 默认账号 ID,用于标记单账号模式(无 accounts 配置)时的内部标识 */
14
+ export const DEFAULT_ACCOUNT_ID = '__default__';
15
+
16
+ /** 新会话触发命令 */
17
+ export const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
18
+
19
+ /** 钉钉 API 常量 */
20
+ export const DINGTALK_API = 'https://api.dingtalk.com';
21
+ export const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
22
+
23
+ // ============ 会话管理 ============
24
+
25
+ /**
26
+ * 检查消息是否是新会话命令
27
+ */
28
+ export function normalizeSlashCommand(text: string): string {
29
+ const trimmed = text.trim();
30
+ const lower = trimmed.toLowerCase();
31
+ if (NEW_SESSION_COMMANDS.some((cmd) => lower === cmd.toLowerCase())) {
32
+ return '/new';
33
+ }
34
+ return text;
35
+ }
36
+
37
+ // ============ Access Token 缓存 ============
38
+
39
+ type CachedToken = {
40
+ token: string;
41
+ expiryMs: number;
42
+ };
43
+
44
+ // 注意:这里仍被部分新逻辑引用(如 message-handler),必须支持多账号,不能用全局单例缓存
45
+ const apiTokenCache = new Map<string, CachedToken>();
46
+ const oapiTokenCache = new Map<string, CachedToken>();
47
+
48
+ function cacheKey(config: DingtalkConfig): string {
49
+ const clientId = String((config as any)?.clientId ?? '').trim();
50
+
51
+ // 添加校验
52
+ if (!clientId) {
53
+ throw new Error(
54
+ 'Invalid DingtalkConfig: clientId is required for token caching. ' +
55
+ 'Please ensure your configuration includes a valid clientId.'
56
+ );
57
+ }
58
+
59
+ return clientId;
60
+ }
61
+
62
+ /**
63
+ * 获取钉钉 Access Token(新版 API)
64
+ */
65
+ export async function getAccessToken(config: DingtalkConfig): Promise<string> {
66
+ const now = Date.now();
67
+ const key = cacheKey(config);
68
+ const cached = apiTokenCache.get(key);
69
+ if (cached && cached.expiryMs > now + 60_000) {
70
+ return cached.token;
71
+ }
72
+
73
+ const { dingtalkHttp } = await import('./http-client.ts');
74
+ const response = await dingtalkHttp.post(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
75
+ appKey: config.clientId,
76
+ appSecret: config.clientSecret,
77
+ });
78
+
79
+ const token = response.data.accessToken as string;
80
+ const expireInSec = Number(response.data.expireIn ?? 0);
81
+ apiTokenCache.set(key, { token, expiryMs: now + expireInSec * 1000 });
82
+ return token;
83
+ }
84
+
85
+ /**
86
+ * 获取钉钉 OAPI Access Token(旧版 API,用于媒体上传等)
87
+ */
88
+ export async function getOapiAccessToken(config: DingtalkConfig): Promise<string | null> {
89
+ try {
90
+ const now = Date.now();
91
+ const key = cacheKey(config);
92
+ const cached = oapiTokenCache.get(key);
93
+ if (cached && cached.expiryMs > now + 60_000) {
94
+ return cached.token;
95
+ }
96
+
97
+ const { dingtalkOapiHttp } = await import('./http-client.ts');
98
+ const resp = await dingtalkOapiHttp.get(`${DINGTALK_OAPI}/gettoken`, {
99
+ params: { appkey: config.clientId, appsecret: config.clientSecret },
100
+ });
101
+ if (resp.data?.errcode === 0 && resp.data?.access_token) {
102
+ const token = String(resp.data.access_token);
103
+ const expiresInSec = Number(resp.data.expires_in ?? 7200);
104
+ oapiTokenCache.set(key, { token, expiryMs: now + expiresInSec * 1000 });
105
+ return token;
106
+ }
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ============ 用户 ID 转换 ============
114
+
115
+ /** staffId → unionId 缓存(带过期时间的 LRU 缓存) */
116
+ const MAX_UNION_ID_CACHE_SIZE = 1000;
117
+ const UNION_ID_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时
118
+
119
+ interface UnionIdCacheEntry {
120
+ unionId: string;
121
+ timestamp: number;
122
+ }
123
+
124
+ const unionIdCache = new Map<string, UnionIdCacheEntry>();
125
+
126
+ /**
127
+ * 通过 oapi 旧版接口将 staffId 转换为 unionId
128
+ */
129
+ export async function getUnionId(
130
+ staffId: string,
131
+ config: DingtalkConfig,
132
+ log?: any,
133
+ ): Promise<string | null> {
134
+ // 检查缓存
135
+ const cached = unionIdCache.get(staffId);
136
+ if (cached && Date.now() - cached.timestamp < UNION_ID_CACHE_TTL) {
137
+ return cached.unionId;
138
+ }
139
+
140
+ try {
141
+ const token = await getOapiAccessToken(config);
142
+ if (!token) {
143
+ log?.error?.('[DingTalk] getUnionId: 无法获取 oapi access_token');
144
+ return null;
145
+ }
146
+ const { dingtalkOapiHttp } = await import('./http-client.ts');
147
+ const resp = await dingtalkOapiHttp.get(`${DINGTALK_OAPI}/user/get`, {
148
+ params: { access_token: token, userid: staffId },
149
+ timeout: 10_000,
150
+ });
151
+ const unionId = resp.data?.unionid;
152
+ if (unionId) {
153
+ // 写入缓存前检查大小
154
+ if (unionIdCache.size >= MAX_UNION_ID_CACHE_SIZE) {
155
+ // 删除最旧的条目
156
+ let oldestKey: string | null = null;
157
+ let oldestTime = Date.now();
158
+
159
+ for (const [key, entry] of unionIdCache.entries()) {
160
+ if (entry.timestamp < oldestTime) {
161
+ oldestTime = entry.timestamp;
162
+ oldestKey = key;
163
+ }
164
+ }
165
+
166
+ if (oldestKey) {
167
+ unionIdCache.delete(oldestKey);
168
+ }
169
+ }
170
+
171
+ unionIdCache.set(staffId, { unionId, timestamp: Date.now() });
172
+ log?.info?.(`[DingTalk] getUnionId: ${staffId} → ${unionId}`);
173
+ return unionId;
174
+ }
175
+ log?.error?.(`[DingTalk] getUnionId: 响应中无 unionid 字段: ${JSON.stringify(resp.data)}`);
176
+ return null;
177
+ } catch (err: any) {
178
+ log?.error?.(`[DingTalk] getUnionId 失败: ${err.message}`);
179
+ return null;
180
+ }
181
+ }
182
+
183
+ // ============ 消息去重 ============
184
+
185
+ /** 消息去重缓存 Map<messageId, timestamp> - 防止同一消息被重复处理 */
186
+ const processedMessages = new Map<string, number>();
187
+
188
+ /** 消息去重缓存过期时间(5分钟) */
189
+ const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
190
+
191
+ /** 定时清理器 */
192
+ let cleanupTimer: NodeJS.Timeout | null = null;
193
+
194
+ /**
195
+ * 清理过期的消息去重缓存
196
+ */
197
+ export function cleanupProcessedMessages(): void {
198
+ const now = Date.now();
199
+ for (const [msgId, timestamp] of processedMessages.entries()) {
200
+ if (now - timestamp > MESSAGE_DEDUP_TTL) {
201
+ processedMessages.delete(msgId);
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * 启动定时清理机制
208
+ */
209
+ export function startMessageCleanup(): void {
210
+ if (cleanupTimer) return; // 防止重复启动
211
+
212
+ // 每 5 分钟清理一次
213
+ cleanupTimer = setInterval(() => {
214
+ cleanupProcessedMessages();
215
+ }, 5 * 60 * 1000);
216
+ }
217
+
218
+ /**
219
+ * 停止定时清理机制
220
+ */
221
+ export function stopMessageCleanup(): void {
222
+ if (cleanupTimer) {
223
+ clearInterval(cleanupTimer);
224
+ cleanupTimer = null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 检查消息是否已处理过(去重)
230
+ */
231
+ export function isMessageProcessed(messageId: string): boolean {
232
+ if (!messageId) return false;
233
+ return processedMessages.has(messageId);
234
+ }
235
+
236
+ /**
237
+ * 标记消息为已处理
238
+ */
239
+ export function markMessageProcessed(messageId: string): void {
240
+ if (!messageId) return;
241
+ processedMessages.set(messageId, Date.now());
242
+ // 定期清理(每处理100条消息清理一次)
243
+ if (processedMessages.size >= 100) {
244
+ cleanupProcessedMessages();
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 对钉钉 Stream 消息做双层去重检查,并在首次处理时标记。
250
+ *
251
+ * 背景:钉钉 Stream 模式存在两套消息 ID:
252
+ * - headers.messageId:WebSocket 协议层的投递 ID,每次重发都会生成新值
253
+ * - data.msgId:业务层的用户消息 ID,重发时保持不变
254
+ *
255
+ * 因此必须同时检查两个 ID,才能可靠地拦截钉钉服务端的重发消息:
256
+ * 1. 协议层去重(headers.messageId):拦截同一次投递的重复回调
257
+ * 2. 业务层去重(data.msgId):拦截 ~60 秒后服务端因未收到业务回复而触发的重发
258
+ *
259
+ * 重要:key 必须带 accountId 前缀,避免多账号(多机器人)场景下,
260
+ * 同一条群消息 @多个机器人时,不同机器人收到相同 msgId 导致误判为重复消息。
261
+ *
262
+ * @param accountId - 当前账号 ID(用于命名空间隔离,防止多账号误判)
263
+ * @param protocolMessageId - res.headers.messageId(WebSocket 协议层投递 ID)
264
+ * @param businessMsgId - data.msgId(钉钉业务层消息 ID,来自 JSON.parse(res.data).msgId)
265
+ * @returns true 表示消息已处理过(应跳过),false 表示首次处理(已标记为已处理)
266
+ */
267
+ export function checkAndMarkDingtalkMessage(
268
+ accountId: string,
269
+ protocolMessageId: string | undefined,
270
+ businessMsgId: string | undefined,
271
+ ): boolean {
272
+ // 加 accountId 前缀,确保不同机器人账号的去重缓存互相隔离
273
+ // 场景:群聊 @多个机器人时,钉钉推送给每个机器人的消息 msgId 相同,
274
+ // 若不隔离,第二个机器人会被误判为重复消息而跳过处理。
275
+ const scopedProtocolId = protocolMessageId ? `${accountId}:${protocolMessageId}` : undefined;
276
+ const scopedBusinessId = businessMsgId ? `${accountId}:${businessMsgId}` : undefined;
277
+
278
+ // 先完整检查两个 ID,再决定是否标记
279
+ // 不能提前 return,否则命中去重的那条路径会漏掉对另一个 ID 的标记
280
+ const isProtocolDuplicate = scopedProtocolId ? isMessageProcessed(scopedProtocolId) : false;
281
+ const isBusinessDuplicate = scopedBusinessId ? isMessageProcessed(scopedBusinessId) : false;
282
+
283
+ if (isProtocolDuplicate || isBusinessDuplicate) {
284
+ return true;
285
+ }
286
+
287
+ // 首次处理:同时标记两个 ID,确保后续任意一个 ID 都能命中去重
288
+ if (scopedProtocolId) markMessageProcessed(scopedProtocolId);
289
+ if (scopedBusinessId) markMessageProcessed(scopedBusinessId);
290
+
291
+ return false;
292
+ }
293
+
294
+ // ============ 配置工具 ============
295
+
296
+ /**
297
+ * 获取钉钉配置
298
+ */
299
+ export function getDingtalkConfig(cfg: any): DingtalkConfig {
300
+ return (cfg?.channels as any)?.['dingtalk-connector'] || {};
301
+ }
302
+
303
+ /**
304
+ * 检查是否已配置
305
+ */
306
+ export function isDingtalkConfigured(cfg: any): boolean {
307
+ const config = getDingtalkConfig(cfg);
308
+ return Boolean(config.clientId && config.clientSecret);
309
+ }
310
+
311
+ /**
312
+ * 构建媒体系统提示词
313
+ */
314
+ export function buildMediaSystemPrompt(): string {
315
+ return `## 钉钉图片和文件显示规则
316
+
317
+ 你正在钉钉中与用户对话。
318
+
319
+ ### 一、图片显示
320
+
321
+ 显示图片时,直接使用本地文件路径,系统会自动上传处理。
322
+
323
+ **正确方式**:
324
+ \`\`\`markdown
325
+ ![描述](file:///path/to/image.jpg)
326
+ ![描述](/tmp/screenshot.png)
327
+ ![描述](/Users/xxx/photo.jpg)
328
+ \`\`\`
329
+
330
+ **禁止**:
331
+ - 不要自己执行 curl 上传
332
+ - 不要猜测或构造 URL
333
+ - **不要对路径进行转义(如使用反斜杠 \\ )**
334
+
335
+ 直接输出本地路径即可,系统会自动上传到钉钉。
336
+
337
+ ### 二、视频分享
338
+
339
+ **何时分享视频**:
340
+ - ✅ 用户明确要求**分享、发送、上传**视频时
341
+ - ❌ 仅生成视频保存到本地时,**不需要**分享
342
+
343
+ **视频标记格式**:
344
+ 当需要分享视频时,在回复**末尾**添加:
345
+
346
+ \`\`\`
347
+ [DINGTALK_VIDEO]{"path":"<本地视频路径>"}[/DINGTALK_VIDEO]
348
+ \`\`\`
349
+
350
+ **支持格式**:mp4(最大 20MB)
351
+
352
+ **重要**:
353
+ - 视频大小不得超过 20MB,超过限制时告知用户
354
+ - 仅支持 mp4 格式
355
+ - 系统会自动提取视频时长、分辨率并生成封面
356
+
357
+ ### 三、音频分享
358
+
359
+ **何时分享音频**:
360
+ - ✅ 用户明确要求**分享、发送、上传**音频/语音文件时
361
+ - ❌ 仅生成音频保存到本地时,**不需要**分享
362
+
363
+ **音频标记格式**:
364
+ 当需要分享音频时,在回复**末尾**添加:
365
+
366
+ \`\`\`
367
+ [DINGTALK_AUDIO]{"path":"<本地音频路径>"}[/DINGTALK_AUDIO]
368
+ \`\`\`
369
+
370
+ **支持格式**:ogg、amr(最大 20MB)
371
+
372
+ **重要**:
373
+ - 音频大小不得超过 20MB,超过限制时告知用户
374
+ - 系统会自动提取音频时长
375
+
376
+ ### 四、文件分享
377
+
378
+ **何时分享文件**:
379
+ - ✅ 用户明确要求**分享、发送、上传**文件时
380
+ - ❌ 仅生成文件保存到本地时,**不需要**分享
381
+
382
+ **文件标记格式**:
383
+ 当需要分享文件时,在回复**末尾**添加:
384
+
385
+ \`\`\`
386
+ [DINGTALK_FILE]{"path":"<本地文件路径>","fileName":"<文件名>","fileType":"<扩展名>"}[/DINGTALK_FILE]
387
+ \`\`\`
388
+
389
+ **支持的文件类型**:几乎所有常见格式
390
+
391
+ **重要**:文件大小不得超过 20MB,超过限制时告知用户文件过大。`;
392
+ }
393
+
394
+ // ============ 消息表情回复 ============
395
+
396
+ /**
397
+ * 在用户消息上贴 🤔思考中 表情,表示正在处理
398
+ */
399
+ export async function addEmotionReply(config: DingtalkConfig, data: any, log?: any): Promise<void> {
400
+ if (!data.msgId || !data.conversationId) return;
401
+ try {
402
+ const token = await getAccessToken(config);
403
+ const { dingtalkHttp } = await import('./http-client.ts');
404
+ await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/emotion/reply`, {
405
+ robotCode: data.robotCode ?? config.clientId,
406
+ openMsgId: data.msgId,
407
+ openConversationId: data.conversationId,
408
+ emotionType: 2,
409
+ emotionName: '🤔思考中',
410
+ textEmotion: {
411
+ emotionId: '2659900',
412
+ emotionName: '🤔思考中',
413
+ text: '🤔思考中',
414
+ backgroundId: 'im_bg_1',
415
+ },
416
+ }, {
417
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
418
+ timeout: 5_000,
419
+ });
420
+ log?.info?.(`[DingTalk][Emotion] 贴表情成功: msgId=${data.msgId}`);
421
+ } catch (err: any) {
422
+ log?.warn?.(`[DingTalk][Emotion] 贴表情失败(不影响主流程): ${err.message}`);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 撤回用户消息上的 🤔思考中 表情
428
+ */
429
+ export async function recallEmotionReply(config: DingtalkConfig, data: any, log?: any): Promise<void> {
430
+ if (!data.msgId || !data.conversationId) return;
431
+ try {
432
+ const token = await getAccessToken(config);
433
+ const { dingtalkHttp } = await import('./http-client.ts');
434
+ await dingtalkHttp.post(`${DINGTALK_API}/v1.0/robot/emotion/recall`, {
435
+ robotCode: data.robotCode ?? config.clientId,
436
+ openMsgId: data.msgId,
437
+ openConversationId: data.conversationId,
438
+ emotionType: 2,
439
+ emotionName: '🤔思考中',
440
+ textEmotion: {
441
+ emotionId: '2659900',
442
+ emotionName: '🤔思考中',
443
+ text: '🤔思考中',
444
+ backgroundId: 'im_bg_1',
445
+ },
446
+ }, {
447
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
448
+ timeout: 5_000,
449
+ });
450
+ log?.info?.(`[DingTalk][Emotion] 撤回表情成功: msgId=${data.msgId}`);
451
+ } catch (err: any) {
452
+ log?.warn?.(`[DingTalk][Emotion] 撤回表情失败(不影响主流程): ${err.message}`);
453
+ }
454
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "bundler",
7
+ "esModuleInterop": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "strict": false,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "allowJs": true,
13
+ "noEmit": true,
14
+ "allowImportingTsExtensions": true,
15
+ "types": ["node"],
16
+ "noImplicitAny": false
17
+ },
18
+ "include": ["src/**/*", "index.ts"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }