@dingtalk-real-ai/dingtalk-connector 0.8.0-beta.0 → 0.8.0-beta.2

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.en.md CHANGED
@@ -263,8 +263,30 @@ Both session routing/message policy options (including `pmpolicy` and `groupPoli
263
263
 
264
264
  Configure multiple bots connected to different agents:
265
265
 
266
- ```json
266
+ ```json5
267
267
  {
268
+ "agents": {
269
+ "list": [
270
+ {
271
+ "agentId": "ding-bot1",
272
+ "model": "your-model-config",
273
+ "persona": {
274
+ "name": "Customer Service Bot",
275
+ "systemPrompt": "You are a professional customer service assistant..."
276
+ }
277
+ // Other agent configurations...
278
+ },
279
+ {
280
+ "agentId": "ding-bot2",
281
+ "model": "your-model-config",
282
+ "persona": {
283
+ "name": "Technical Support Bot",
284
+ "systemPrompt": "You are a technical support expert..."
285
+ }
286
+ // Other agent configurations...
287
+ }
288
+ ]
289
+ },
268
290
  "channels": {
269
291
  "dingtalk-connector": {
270
292
  "enabled": true,
package/README.md CHANGED
@@ -276,6 +276,28 @@ openclaw logs --follow
276
276
 
277
277
  ```json5
278
278
  {
279
+ "agents": {
280
+ "list": [
281
+ {
282
+ "agentId": "ding-bot1",
283
+ "model": "your-model-config",
284
+ "persona": {
285
+ "name": "钉钉客服机器人",
286
+ "systemPrompt": "你是一个专业的客服助手..."
287
+ }
288
+ // 其他 agent 配置...
289
+ },
290
+ {
291
+ "agentId": "ding-bot2",
292
+ "model": "your-model-config",
293
+ "persona": {
294
+ "name": "钉钉技术支持机器人",
295
+ "systemPrompt": "你是一个技术支持专家..."
296
+ }
297
+ // 其他 agent 配置...
298
+ }
299
+ ]
300
+ },
279
301
  "channels": {
280
302
  "dingtalk-connector": {
281
303
  "enabled": true,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dingtalk-connector",
3
3
  "name": "DingTalk Channel",
4
- "version": "0.8.0",
4
+ "version": "0.8.0-beta.2",
5
5
  "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming",
6
6
  "author": "DingTalk Real Team",
7
7
  "main": "index.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingtalk-real-ai/dingtalk-connector",
3
- "version": "0.8.0-beta.0",
3
+ "version": "0.8.0-beta.2",
4
4
  "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -128,4 +128,17 @@ export const DingtalkConfigSchema = z
128
128
  });
129
129
  }
130
130
  }
131
+
132
+ // Validate groupPolicy and groupAllowFrom consistency
133
+ if (value.groupPolicy === "allowlist") {
134
+ const groupAllowFrom = value.groupAllowFrom ?? [];
135
+ if (groupAllowFrom.length === 0) {
136
+ ctx.addIssue({
137
+ code: z.ZodIssueCode.custom,
138
+ path: ["groupAllowFrom"],
139
+ message:
140
+ 'channels.dingtalk-connector.groupPolicy="allowlist" requires channels.dingtalk-connector.groupAllowFrom to contain at least one entry',
141
+ });
142
+ }
143
+ }
131
144
  });
@@ -466,7 +466,23 @@ export async function monitorSingleAccount(
466
466
 
467
467
  try {
468
468
  // 解析消息数据
469
- const data = JSON.parse(res.data);
469
+ let data;
470
+ try {
471
+ data = JSON.parse(res.data);
472
+ } catch (parseError: any) {
473
+ logger.error('Failed to parse response data as JSON:', {
474
+ error: parseError instanceof Error ? parseError.message : String(parseError),
475
+ rawData: typeof res.data === 'string'
476
+ ? res.data.substring(0, 500) // 只记录前 500 字符
477
+ : res.data,
478
+ dataType: typeof res.data,
479
+ });
480
+ throw new Error(
481
+ `Invalid JSON response from DingTalk API. ` +
482
+ `Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. ` +
483
+ `Raw data (first 100 chars): ${String(res.data).substring(0, 100)}`
484
+ );
485
+ }
470
486
 
471
487
  // ===== 第二步:记录解析后的消息详情 =====
472
488
  logger.info(`\n----- 消息详情 -----`);
@@ -564,10 +580,10 @@ export async function monitorSingleAccount(
564
580
  stop();
565
581
  };
566
582
 
567
- // 进程退出时清理
568
- process.on("exit", enhancedCleanup);
569
- process.on("SIGINT", enhancedCleanup);
570
- process.on("SIGTERM", enhancedCleanup);
583
+ // 进程退出时清理(使用 once 确保只执行一次)
584
+ process.once("exit", enhancedCleanup);
585
+ process.once("SIGINT", enhancedCleanup);
586
+ process.once("SIGTERM", enhancedCleanup);
571
587
  } catch (error: any) {
572
588
  cleanup(); // 连接失败时清理资源
573
589
 
@@ -585,7 +601,7 @@ export async function monitorSingleAccount(
585
601
  `[DingTalk][${accountId}] Bad Request (400):\n` +
586
602
  ` - clientId or clientSecret format is invalid\n` +
587
603
  ` - clientId: ${clientIdStr} (type: ${typeof account.clientId}, length: ${clientIdStr.length})\n` +
588
- ` - clientSecret: ${clientSecretStr.substring(0, 8)}... (type: ${typeof account.clientSecret}, length: ${clientSecretStr.length})\n` +
604
+ ` - clientSecret: ****** (type: ${typeof account.clientSecret}, length: ${clientSecretStr.length})\n` +
589
605
  ` - Common issues:\n` +
590
606
  ` 1. clientId/clientSecret must be strings, not numbers\n` +
591
607
  ` 2. Remove any quotes or special characters\n` +
@@ -374,11 +374,33 @@ export async function downloadFileToLocal(
374
374
  const mediaDir = path.join(agentWorkspaceDir, 'media', 'inbound');
375
375
  fs.mkdirSync(mediaDir, { recursive: true });
376
376
 
377
+ // 安全过滤文件名
378
+ const sanitizeFileName = (name: string): string => {
379
+ // 移除路径分隔符,防止目录遍历攻击
380
+ let safe = name.replace(/[/\\]/g, '_');
381
+ // 移除或替换危险字符
382
+ safe = safe.replace(/[<>:"|?*\x00-\x1f]/g, '_');
383
+ // 移除开头的点,防止隐藏文件
384
+ safe = safe.replace(/^\.+/, '');
385
+ // 限制长度
386
+ if (safe.length > 200) {
387
+ const ext = path.extname(safe);
388
+ const base = path.basename(safe, ext);
389
+ safe = base.substring(0, 200 - ext.length) + ext;
390
+ }
391
+ // 如果处理后为空,使用默认名称
392
+ if (!safe) {
393
+ safe = 'unnamed_file';
394
+ }
395
+ return safe;
396
+ };
397
+
377
398
  // 保留原始文件名,但添加时间戳避免冲突
378
399
  const ext = path.extname(fileName);
379
400
  const baseName = path.basename(fileName, ext);
380
401
  const timestamp = Date.now();
381
- const safeFileName = `${baseName}-${timestamp}${ext}`;
402
+ const safeBaseName = sanitizeFileName(baseName);
403
+ const safeFileName = `${safeBaseName}-${timestamp}${ext}`;
382
404
  const localPath = path.join(mediaDir, safeFileName);
383
405
 
384
406
  fs.writeFileSync(localPath, buffer);
@@ -1021,13 +1043,11 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
1021
1043
  dispatchResult = await core.channel.reply.withReplyDispatcher({
1022
1044
  dispatcher,
1023
1045
  onSettled: () => {
1024
- log?.info?.(`onSettled 被调用`);
1025
1046
  log?.info?.(`onSettled 被调用`);
1026
1047
  markDispatchIdle();
1027
1048
  },
1028
1049
  run: async () => {
1029
1050
  log?.info?.(`run 被调用,开始 dispatchReplyFromConfig`);
1030
- log?.info?.(`run 被调用`);
1031
1051
  log?.info?.(`ctxPayload.SessionKey=${ctxPayload.SessionKey}`);
1032
1052
  log?.info?.(`ctxPayload.Body 长度=${ctxPayload.Body?.length || 0}`);
1033
1053
  log?.info?.(`replyOptions keys=${Object.keys(replyOptions).join(',')}`);
@@ -1056,7 +1076,6 @@ async function handleDingTalkMessageInternal(params: HandleMessageParams): Promi
1056
1076
 
1057
1077
  const { queuedFinal, counts } = dispatchResult;
1058
1078
  log?.info?.(`SDK dispatch 完成: queuedFinal=${queuedFinal}, replies=${counts.final}, asyncMode=${asyncMode}`);
1059
- log?.info?.(`SDK dispatch 完成: queuedFinal=${queuedFinal}, replies=${counts.final}`);
1060
1079
 
1061
1080
  // ===== 异步模式:主动推送最终结果 =====
1062
1081
  if (asyncMode) {
package/src/probe.ts CHANGED
@@ -1,11 +1,50 @@
1
1
  import { raceWithTimeoutAndAbort } from "./utils/async.ts";
2
2
  import type { DingtalkProbeResult } from "./types/index.ts";
3
3
 
4
- /** Cache probe results to reduce repeated health-check calls. */
5
- const probeCache = new Map<string, { result: DingtalkProbeResult; expiresAt: number }>();
4
+ /** LRU Cache for probe results to reduce repeated health-check calls. */
5
+ class LRUCache<K, V> {
6
+ private cache = new Map<K, V>();
7
+ private maxSize: number;
8
+
9
+ constructor(maxSize: number) {
10
+ this.maxSize = maxSize;
11
+ }
12
+
13
+ get(key: K): V | undefined {
14
+ const value = this.cache.get(key);
15
+ if (value !== undefined) {
16
+ // 重新插入以更新访问顺序
17
+ this.cache.delete(key);
18
+ this.cache.set(key, value);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ set(key: K, value: V): void {
24
+ // 如果已存在,先删除(更新顺序)
25
+ if (this.cache.has(key)) {
26
+ this.cache.delete(key);
27
+ }
28
+
29
+ this.cache.set(key, value);
30
+
31
+ // 超过大小限制时删除最旧的(最少使用的)
32
+ if (this.cache.size > this.maxSize) {
33
+ const oldest = this.cache.keys().next().value;
34
+ if (oldest !== undefined) {
35
+ this.cache.delete(oldest);
36
+ }
37
+ }
38
+ }
39
+
40
+ clear(): void {
41
+ this.cache.clear();
42
+ }
43
+ }
44
+
45
+ const probeCache = new LRUCache<string, { result: DingtalkProbeResult; expiresAt: number }>(64);
6
46
  const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
7
47
  const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
8
- const MAX_PROBE_CACHE_SIZE = 64;
9
48
  export const DINGTALK_PROBE_REQUEST_TIMEOUT_MS = 10_000;
10
49
  export type ProbeDingtalkOptions = {
11
50
  timeoutMs?: number;
@@ -25,12 +64,6 @@ function setCachedProbeResult(
25
64
  ttlMs: number,
26
65
  ): DingtalkProbeResult {
27
66
  probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
28
- if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
29
- const oldest = probeCache.keys().next().value;
30
- if (oldest !== undefined) {
31
- probeCache.delete(oldest);
32
- }
33
- }
34
67
  return result;
35
68
  }
36
69
 
@@ -13,7 +13,7 @@ import type { SecretInput, SecretInputRef } from "./types/index.ts";
13
13
  /**
14
14
  * 默认账号 ID
15
15
  */
16
- export const DEFAULT_ACCOUNT_ID = "default" as const;
16
+ export const DEFAULT_ACCOUNT_ID = "__default__" as const;
17
17
 
18
18
  /**
19
19
  * 规范化账号 ID
@@ -24,6 +24,7 @@ const AICardStatus = {
24
24
  export interface AICardInstance {
25
25
  cardInstanceId: string;
26
26
  accessToken: string;
27
+ tokenExpireTime: number;
27
28
  inputingStarted: boolean;
28
29
  }
29
30
 
@@ -168,7 +169,10 @@ export async function createAICardForTarget(
168
169
  },
169
170
  );
170
171
 
171
- return { cardInstanceId, accessToken: token, inputingStarted: false };
172
+ // 记录 token 过期时间(钉钉 token 有效期 2 小时)
173
+ const tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000;
174
+
175
+ return { cardInstanceId, accessToken: token, tokenExpireTime, inputingStarted: false };
172
176
  } catch (err: any) {
173
177
  log?.error?.(
174
178
  `[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${err.message}`,
@@ -182,6 +186,22 @@ export async function createAICardForTarget(
182
186
  }
183
187
  }
184
188
 
189
+ /**
190
+ * 确保 Token 有效(自动刷新过期的 Token)
191
+ */
192
+ async function ensureValidToken(
193
+ card: AICardInstance,
194
+ config: DingtalkConfig,
195
+ ): Promise<string> {
196
+ // 如果 token 即将过期(提前 5 分钟刷新)
197
+ if (Date.now() > card.tokenExpireTime - 5 * 60 * 1000) {
198
+ const newToken = await getAccessToken(config);
199
+ card.accessToken = newToken;
200
+ card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000;
201
+ }
202
+ return card.accessToken;
203
+ }
204
+
185
205
  /**
186
206
  * 流式更新 AI Card 内容
187
207
  */
@@ -189,8 +209,13 @@ export async function streamAICard(
189
209
  card: AICardInstance,
190
210
  content: string,
191
211
  finished: boolean = false,
212
+ config?: DingtalkConfig,
192
213
  log?: any,
193
214
  ): Promise<void> {
215
+ // 确保 token 有效
216
+ if (config) {
217
+ await ensureValidToken(card, config);
218
+ }
194
219
  if (!card.inputingStarted) {
195
220
  const statusBody = {
196
221
  outTrackId: card.cardInstanceId,
@@ -267,8 +292,13 @@ export async function streamAICard(
267
292
  export async function finishAICard(
268
293
  card: AICardInstance,
269
294
  content: string,
295
+ config?: DingtalkConfig,
270
296
  log?: any,
271
297
  ): Promise<void> {
298
+ // 确保 token 有效
299
+ if (config) {
300
+ await ensureValidToken(card, config);
301
+ }
272
302
  const fixedContent = ensureTableBlankLines(content);
273
303
  log?.info?.(
274
304
  `[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`,
@@ -25,8 +25,17 @@ const apiTokenCache = new Map<string, CachedToken>();
25
25
  const oapiTokenCache = new Map<string, CachedToken>();
26
26
 
27
27
  function cacheKey(config: DingtalkConfig): string {
28
- // clientId 可能来自多账号合并配置,理论上必填;这里做兜底避免 Map key undefined
29
- return String((config as any)?.clientId ?? '').trim();
28
+ const clientId = String((config as any)?.clientId ?? '').trim();
29
+
30
+ // 添加校验
31
+ if (!clientId) {
32
+ throw new Error(
33
+ 'Invalid DingtalkConfig: clientId is required for token caching. ' +
34
+ 'Please ensure your configuration includes a valid clientId.'
35
+ );
36
+ }
37
+
38
+ return clientId;
30
39
  }
31
40
 
32
41
  /**
@@ -42,7 +42,6 @@ export function buildSessionContext(params: {
42
42
  groupSubject?: string;
43
43
  separateSessionByConversation?: boolean;
44
44
  groupSessionScope?: 'group' | 'group_sender';
45
- // 新版本可选:跨会话共享记忆(旧实现不处理,但允许透传以保持类型兼容)
46
45
  sharedMemoryAcrossConversations?: boolean;
47
46
  }): SessionContext {
48
47
  const {
@@ -54,9 +53,23 @@ export function buildSessionContext(params: {
54
53
  groupSubject,
55
54
  separateSessionByConversation,
56
55
  groupSessionScope,
56
+ sharedMemoryAcrossConversations,
57
57
  } = params;
58
58
  const isDirect = conversationType === '1';
59
59
 
60
+ // sharedMemoryAcrossConversations=true 时,所有会话共享记忆
61
+ if (sharedMemoryAcrossConversations === true) {
62
+ return {
63
+ channel: 'dingtalk-connector',
64
+ accountId,
65
+ chatType: isDirect ? 'direct' : 'group',
66
+ peerId: accountId, // 使用 accountId 作为 peerId,实现跨会话记忆共享
67
+ conversationId: isDirect ? undefined : conversationId,
68
+ senderName,
69
+ groupSubject: isDirect ? undefined : groupSubject,
70
+ };
71
+ }
72
+
60
73
  // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session
61
74
  if (separateSessionByConversation === false) {
62
75
  return {
@@ -130,7 +143,17 @@ const apiTokenCache = new Map<string, CachedToken>();
130
143
  const oapiTokenCache = new Map<string, CachedToken>();
131
144
 
132
145
  function cacheKey(config: DingtalkConfig): string {
133
- return String((config as any)?.clientId ?? '').trim();
146
+ const clientId = String((config as any)?.clientId ?? '').trim();
147
+
148
+ // 添加校验
149
+ if (!clientId) {
150
+ throw new Error(
151
+ 'Invalid DingtalkConfig: clientId is required for token caching. ' +
152
+ 'Please ensure your configuration includes a valid clientId.'
153
+ );
154
+ }
155
+
156
+ return clientId;
134
157
  }
135
158
 
136
159
  /**
@@ -186,8 +209,16 @@ export async function getOapiAccessToken(config: DingtalkConfig): Promise<string
186
209
 
187
210
  // ============ 用户 ID 转换 ============
188
211
 
189
- /** staffId → unionId 缓存 */
190
- const unionIdCache = new Map<string, string>();
212
+ /** staffId → unionId 缓存(带过期时间的 LRU 缓存) */
213
+ const MAX_UNION_ID_CACHE_SIZE = 1000;
214
+ const UNION_ID_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时
215
+
216
+ interface UnionIdCacheEntry {
217
+ unionId: string;
218
+ timestamp: number;
219
+ }
220
+
221
+ const unionIdCache = new Map<string, UnionIdCacheEntry>();
191
222
 
192
223
  /**
193
224
  * 通过 oapi 旧版接口将 staffId 转换为 unionId
@@ -197,8 +228,11 @@ export async function getUnionId(
197
228
  config: DingtalkConfig,
198
229
  log?: any,
199
230
  ): Promise<string | null> {
231
+ // 检查缓存
200
232
  const cached = unionIdCache.get(staffId);
201
- if (cached) return cached;
233
+ if (cached && Date.now() - cached.timestamp < UNION_ID_CACHE_TTL) {
234
+ return cached.unionId;
235
+ }
202
236
 
203
237
  try {
204
238
  const token = await getOapiAccessToken(config);
@@ -213,7 +247,25 @@ export async function getUnionId(
213
247
  });
214
248
  const unionId = resp.data?.unionid;
215
249
  if (unionId) {
216
- unionIdCache.set(staffId, unionId);
250
+ // 写入缓存前检查大小
251
+ if (unionIdCache.size >= MAX_UNION_ID_CACHE_SIZE) {
252
+ // 删除最旧的条目
253
+ let oldestKey: string | null = null;
254
+ let oldestTime = Date.now();
255
+
256
+ for (const [key, entry] of unionIdCache.entries()) {
257
+ if (entry.timestamp < oldestTime) {
258
+ oldestTime = entry.timestamp;
259
+ oldestKey = key;
260
+ }
261
+ }
262
+
263
+ if (oldestKey) {
264
+ unionIdCache.delete(oldestKey);
265
+ }
266
+ }
267
+
268
+ unionIdCache.set(staffId, { unionId, timestamp: Date.now() });
217
269
  log?.info?.(`[DingTalk] getUnionId: ${staffId} → ${unionId}`);
218
270
  return unionId;
219
271
  }
@@ -233,6 +285,9 @@ const processedMessages = new Map<string, number>();
233
285
  /** 消息去重缓存过期时间(5分钟) */
234
286
  const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
235
287
 
288
+ /** 定时清理器 */
289
+ let cleanupTimer: NodeJS.Timeout | null = null;
290
+
236
291
  /**
237
292
  * 清理过期的消息去重缓存
238
293
  */
@@ -245,6 +300,28 @@ export function cleanupProcessedMessages(): void {
245
300
  }
246
301
  }
247
302
 
303
+ /**
304
+ * 启动定时清理机制
305
+ */
306
+ export function startMessageCleanup(): void {
307
+ if (cleanupTimer) return; // 防止重复启动
308
+
309
+ // 每 5 分钟清理一次
310
+ cleanupTimer = setInterval(() => {
311
+ cleanupProcessedMessages();
312
+ }, 5 * 60 * 1000);
313
+ }
314
+
315
+ /**
316
+ * 停止定时清理机制
317
+ */
318
+ export function stopMessageCleanup(): void {
319
+ if (cleanupTimer) {
320
+ clearInterval(cleanupTimer);
321
+ cleanupTimer = null;
322
+ }
323
+ }
324
+
248
325
  /**
249
326
  * 检查消息是否已处理过(去重)
250
327
  */