@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 +23 -1
- package/README.md +22 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/config/schema.ts +13 -0
- package/src/core/connection.ts +22 -6
- package/src/core/message-handler.ts +23 -4
- package/src/probe.ts +42 -9
- package/src/sdk/helpers.ts +1 -1
- package/src/services/messaging/card.ts +31 -1
- package/src/utils/token.ts +11 -2
- package/src/utils/utils-legacy.ts +83 -6
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
|
-
```
|
|
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,
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -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
|
});
|
package/src/core/connection.ts
CHANGED
|
@@ -466,7 +466,23 @@ export async function monitorSingleAccount(
|
|
|
466
466
|
|
|
467
467
|
try {
|
|
468
468
|
// 解析消息数据
|
|
469
|
-
|
|
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.
|
|
569
|
-
process.
|
|
570
|
-
process.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/sdk/helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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}`,
|
package/src/utils/token.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
*/
|