@soimy/dingtalk 3.0.0-beta.1 → 3.0.1
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.md +68 -3
- package/package.json +1 -1
- package/src/channel.ts +94 -15
- package/src/connection-manager.ts +81 -9
- package/src/group-members-store.ts +45 -0
- package/src/inbound-handler.ts +1 -42
package/README.md
CHANGED
|
@@ -44,10 +44,70 @@ openclaw plugins install -l .
|
|
|
44
44
|
2. 确保包含 `index.ts`, `openclaw.plugin.json` 和 `package.json`。
|
|
45
45
|
3. 运行 `openclaw plugins list` 确认 `dingtalk` 已显示在列表中。
|
|
46
46
|
|
|
47
|
+
### 安装后必做:配置插件信任白名单(`plugins.allow`)
|
|
48
|
+
|
|
49
|
+
从 OpenClaw 新版本开始,如果发现了非内置插件且 `plugins.allow` 为空,会提示:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load ...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
这是一条安全告警(不是安装失败),建议显式写入你信任的插件 id。
|
|
56
|
+
|
|
57
|
+
#### 步骤 1:确认插件 id
|
|
58
|
+
|
|
59
|
+
本插件 id 固定为:`dingtalk`(定义于 `openclaw.plugin.json`)。
|
|
60
|
+
|
|
61
|
+
也可用下面命令查看已发现插件:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
openclaw plugins list
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### 步骤 2:在 `~/.openclaw/openclaw.json` 添加 `plugins.allow`
|
|
68
|
+
|
|
69
|
+
```json5
|
|
70
|
+
{
|
|
71
|
+
"plugins": {
|
|
72
|
+
"enabled": true,
|
|
73
|
+
"allow": ["dingtalk"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
如果你还有其他已安装且需要启用的插件,请一并加入,例如:
|
|
79
|
+
|
|
80
|
+
```json5
|
|
81
|
+
{
|
|
82
|
+
"plugins": {
|
|
83
|
+
"allow": ["dingtalk", "telegram", "voice-call"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### 步骤 3:重启 Gateway
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
openclaw gateway restart
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> 注意:如果你之前已经配置过 `plugins.allow`,但没有 `dingtalk`,那么插件不会被加载。请把 `dingtalk` 加入该列表。
|
|
95
|
+
|
|
47
96
|
## 更新
|
|
48
97
|
|
|
98
|
+
`openclaw plugins update` 使用插件 id(不是 npm 包名),并且仅适用于 npm 安装来源。
|
|
99
|
+
|
|
100
|
+
如果你是通过 npm 安装本插件:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
openclaw plugins update dingtalk
|
|
49
104
|
```
|
|
50
|
-
|
|
105
|
+
|
|
106
|
+
如果你是本地源码/链接安装(`openclaw plugins install -l .`),请在插件目录更新代码后重启 Gateway:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git pull
|
|
110
|
+
openclaw gateway restart
|
|
51
111
|
```
|
|
52
112
|
|
|
53
113
|
## 配置
|
|
@@ -140,12 +200,17 @@ openclaw configure --section channels
|
|
|
140
200
|
|
|
141
201
|
### 方法 2:手动配置文件
|
|
142
202
|
|
|
143
|
-
在 `~/.openclaw/openclaw.json`
|
|
203
|
+
在 `~/.openclaw/openclaw.json` 中添加(仅作参考,交互式配置会自动生成):
|
|
144
204
|
|
|
145
|
-
>
|
|
205
|
+
> 至少包含 `plugins.allow` 和 `channels.dingtalk` 两部分,内容参考上文钉钉开发者配置指南
|
|
146
206
|
|
|
147
207
|
```json5
|
|
148
208
|
{
|
|
209
|
+
"plugins": {
|
|
210
|
+
"enabled": true,
|
|
211
|
+
"allow": ["dingtalk"]
|
|
212
|
+
},
|
|
213
|
+
|
|
149
214
|
...
|
|
150
215
|
"channels": {
|
|
151
216
|
"telegram": { ... },
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -30,6 +30,46 @@ import type {
|
|
|
30
30
|
import { ConnectionState } from "./types";
|
|
31
31
|
import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
|
|
32
32
|
|
|
33
|
+
const processingDedupKeys = new Set<string>();
|
|
34
|
+
const inboundCountersByAccount = new Map<
|
|
35
|
+
string,
|
|
36
|
+
{
|
|
37
|
+
received: number;
|
|
38
|
+
acked: number;
|
|
39
|
+
dedupSkipped: number;
|
|
40
|
+
inflightSkipped: number;
|
|
41
|
+
processed: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
noMessageId: number;
|
|
44
|
+
}
|
|
45
|
+
>();
|
|
46
|
+
const INBOUND_COUNTER_LOG_EVERY = 10;
|
|
47
|
+
|
|
48
|
+
function getInboundCounters(accountId: string) {
|
|
49
|
+
const existing = inboundCountersByAccount.get(accountId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
return existing;
|
|
52
|
+
}
|
|
53
|
+
const created = {
|
|
54
|
+
received: 0,
|
|
55
|
+
acked: 0,
|
|
56
|
+
dedupSkipped: 0,
|
|
57
|
+
inflightSkipped: 0,
|
|
58
|
+
processed: 0,
|
|
59
|
+
failed: 0,
|
|
60
|
+
noMessageId: 0,
|
|
61
|
+
};
|
|
62
|
+
inboundCountersByAccount.set(accountId, created);
|
|
63
|
+
return created;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function logInboundCounters(log: any, accountId: string, reason: string): void {
|
|
67
|
+
const stats = getInboundCounters(accountId);
|
|
68
|
+
log?.info?.(
|
|
69
|
+
`[${accountId}] Inbound counters (${reason}): received=${stats.received}, acked=${stats.acked}, processed=${stats.processed}, dedupSkipped=${stats.dedupSkipped}, inflightSkipped=${stats.inflightSkipped}, failed=${stats.failed}, noMessageId=${stats.noMessageId}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
33
73
|
// DingTalk Channel Definition (assembly layer).
|
|
34
74
|
// Heavy logic is delegated to service modules for maintainability.
|
|
35
75
|
export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
@@ -254,36 +294,75 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
254
294
|
|
|
255
295
|
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
256
296
|
const messageId = res.headers?.messageId;
|
|
297
|
+
const stats = getInboundCounters(account.accountId);
|
|
298
|
+
stats.received += 1;
|
|
257
299
|
try {
|
|
258
300
|
if (messageId) {
|
|
259
301
|
client.socketCallBackResponse(messageId, { success: true });
|
|
302
|
+
stats.acked += 1;
|
|
260
303
|
}
|
|
261
304
|
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
262
305
|
|
|
263
306
|
// Message deduplication key is bot-scoped to avoid cross-account conflicts.
|
|
264
307
|
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
265
308
|
const msgId = data.msgId || messageId;
|
|
309
|
+
const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
|
|
266
310
|
|
|
267
|
-
if (!
|
|
311
|
+
if (!dedupKey) {
|
|
268
312
|
ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
313
|
+
stats.noMessageId += 1;
|
|
314
|
+
await handleDingTalkMessage({
|
|
315
|
+
cfg,
|
|
316
|
+
accountId: account.accountId,
|
|
317
|
+
data,
|
|
318
|
+
sessionWebhook: data.sessionWebhook,
|
|
319
|
+
log: ctx.log,
|
|
320
|
+
dingtalkConfig: config,
|
|
321
|
+
});
|
|
322
|
+
stats.processed += 1;
|
|
323
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
324
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
274
325
|
}
|
|
275
|
-
|
|
326
|
+
return;
|
|
276
327
|
}
|
|
277
328
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
329
|
+
if (isMessageProcessed(dedupKey)) {
|
|
330
|
+
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
331
|
+
stats.dedupSkipped += 1;
|
|
332
|
+
logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (processingDedupKeys.has(dedupKey)) {
|
|
337
|
+
ctx.log?.debug?.(
|
|
338
|
+
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
339
|
+
);
|
|
340
|
+
stats.inflightSkipped += 1;
|
|
341
|
+
logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
processingDedupKeys.add(dedupKey);
|
|
346
|
+
try {
|
|
347
|
+
await handleDingTalkMessage({
|
|
348
|
+
cfg,
|
|
349
|
+
accountId: account.accountId,
|
|
350
|
+
data,
|
|
351
|
+
sessionWebhook: data.sessionWebhook,
|
|
352
|
+
log: ctx.log,
|
|
353
|
+
dingtalkConfig: config,
|
|
354
|
+
});
|
|
355
|
+
stats.processed += 1;
|
|
356
|
+
markMessageProcessed(dedupKey);
|
|
357
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
358
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
processingDedupKeys.delete(dedupKey);
|
|
362
|
+
}
|
|
286
363
|
} catch (error: any) {
|
|
364
|
+
stats.failed += 1;
|
|
365
|
+
logInboundCounters(ctx.log, account.accountId, "failed");
|
|
287
366
|
ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
|
|
288
367
|
}
|
|
289
368
|
});
|
|
@@ -31,6 +31,21 @@ export class ConnectionManager {
|
|
|
31
31
|
private attemptCount: number = 0;
|
|
32
32
|
private reconnectTimer?: NodeJS.Timeout;
|
|
33
33
|
private stopped: boolean = false;
|
|
34
|
+
private connectedAt?: number;
|
|
35
|
+
private consecutiveUnhealthyChecks: number = 0;
|
|
36
|
+
|
|
37
|
+
private static readonly HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
38
|
+
private static readonly HEALTH_CHECK_GRACE_MS = 3000;
|
|
39
|
+
private static readonly HEALTH_CHECK_UNHEALTHY_THRESHOLD = 2;
|
|
40
|
+
private runtimeCounters = {
|
|
41
|
+
healthUnhealthyChecks: 0,
|
|
42
|
+
healthTriggeredReconnects: 0,
|
|
43
|
+
socketCloseEvents: 0,
|
|
44
|
+
runtimeDisconnects: 0,
|
|
45
|
+
reconnectAttempts: 0,
|
|
46
|
+
reconnectSuccess: 0,
|
|
47
|
+
reconnectFailures: 0,
|
|
48
|
+
};
|
|
34
49
|
|
|
35
50
|
// Runtime monitoring resources
|
|
36
51
|
private healthCheckInterval?: NodeJS.Timeout;
|
|
@@ -61,6 +76,13 @@ export class ConnectionManager {
|
|
|
61
76
|
}
|
|
62
77
|
}
|
|
63
78
|
|
|
79
|
+
private logRuntimeCounters(reason: string): void {
|
|
80
|
+
const c = this.runtimeCounters;
|
|
81
|
+
this.log?.info?.(
|
|
82
|
+
`[${this.accountId}] Runtime counters (${reason}): healthUnhealthyChecks=${c.healthUnhealthyChecks}, healthTriggeredReconnects=${c.healthTriggeredReconnects}, socketCloseEvents=${c.socketCloseEvents}, runtimeDisconnects=${c.runtimeDisconnects}, reconnectAttempts=${c.reconnectAttempts}, reconnectSuccess=${c.reconnectSuccess}, reconnectFailures=${c.reconnectFailures}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* Calculate next reconnection delay with exponential backoff and jitter
|
|
66
88
|
* Formula: delay = min(initialDelay * 2^attempt, maxDelay) * (1 ± jitter)
|
|
@@ -130,6 +152,8 @@ export class ConnectionManager {
|
|
|
130
152
|
|
|
131
153
|
// Connection successful
|
|
132
154
|
this.state = ConnectionStateEnum.CONNECTED;
|
|
155
|
+
this.connectedAt = Date.now();
|
|
156
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
133
157
|
this.notifyStateChange();
|
|
134
158
|
const successfulAttempt = this.attemptCount;
|
|
135
159
|
this.attemptCount = 0; // Reset counter on success
|
|
@@ -230,17 +254,51 @@ export class ConnectionManager {
|
|
|
230
254
|
return;
|
|
231
255
|
}
|
|
232
256
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
257
|
+
if (this.state !== ConnectionStateEnum.CONNECTED) {
|
|
258
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const withinGraceWindow =
|
|
264
|
+
this.connectedAt !== undefined &&
|
|
265
|
+
now - this.connectedAt < ConnectionManager.HEALTH_CHECK_GRACE_MS;
|
|
266
|
+
if (withinGraceWindow) {
|
|
267
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const socketReadyState = (client.socket as { readyState?: number } | undefined)?.readyState;
|
|
272
|
+
const socketOpen = socketReadyState === 1;
|
|
273
|
+
const unhealthy = !client.connected && !socketOpen;
|
|
274
|
+
|
|
275
|
+
if (!unhealthy) {
|
|
276
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.consecutiveUnhealthyChecks += 1;
|
|
281
|
+
this.runtimeCounters.healthUnhealthyChecks += 1;
|
|
282
|
+
if (
|
|
283
|
+
this.consecutiveUnhealthyChecks <
|
|
284
|
+
ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD
|
|
285
|
+
) {
|
|
286
|
+
this.log?.debug?.(
|
|
287
|
+
`[${this.accountId}] Connection health check unhealthy (${this.consecutiveUnhealthyChecks}/${ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD}) connected=${String(client.connected)} socketReadyState=${socketReadyState ?? "unknown"}`,
|
|
237
288
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.log?.warn?.(
|
|
293
|
+
`[${this.accountId}] Connection health check failed - detected disconnection`,
|
|
294
|
+
);
|
|
295
|
+
this.runtimeCounters.healthTriggeredReconnects += 1;
|
|
296
|
+
this.logRuntimeCounters("health-triggered-reconnect");
|
|
297
|
+
if (this.healthCheckInterval) {
|
|
298
|
+
clearInterval(this.healthCheckInterval);
|
|
242
299
|
}
|
|
243
|
-
|
|
300
|
+
this.handleRuntimeDisconnection();
|
|
301
|
+
}, ConnectionManager.HEALTH_CHECK_INTERVAL_MS);
|
|
244
302
|
|
|
245
303
|
// Additionally, if we have access to the socket, monitor its events
|
|
246
304
|
// The DWClient uses 'ws' WebSocket library which extends EventEmitter
|
|
@@ -251,9 +309,11 @@ export class ConnectionManager {
|
|
|
251
309
|
|
|
252
310
|
// Handler for socket close event
|
|
253
311
|
this.socketCloseHandler = (code: number, reason: string) => {
|
|
312
|
+
this.runtimeCounters.socketCloseEvents += 1;
|
|
254
313
|
this.log?.warn?.(
|
|
255
314
|
`[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || "none"})`,
|
|
256
315
|
);
|
|
316
|
+
this.logRuntimeCounters("socket-close");
|
|
257
317
|
|
|
258
318
|
// Only trigger reconnection if we were previously connected and not stopping
|
|
259
319
|
if (!this.stopped && this.state === ConnectionStateEnum.CONNECTED) {
|
|
@@ -319,10 +379,13 @@ export class ConnectionManager {
|
|
|
319
379
|
this.log?.warn?.(
|
|
320
380
|
`[${this.accountId}] Runtime disconnection detected, initiating reconnection...`,
|
|
321
381
|
);
|
|
382
|
+
this.runtimeCounters.runtimeDisconnects += 1;
|
|
322
383
|
|
|
323
384
|
this.state = ConnectionStateEnum.DISCONNECTED;
|
|
324
385
|
this.notifyStateChange("Runtime disconnection detected");
|
|
325
386
|
this.attemptCount = 0; // Reset attempt counter for runtime reconnection
|
|
387
|
+
this.connectedAt = undefined;
|
|
388
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
326
389
|
|
|
327
390
|
// Clear any existing timer
|
|
328
391
|
this.clearReconnectTimer();
|
|
@@ -349,17 +412,24 @@ export class ConnectionManager {
|
|
|
349
412
|
}
|
|
350
413
|
|
|
351
414
|
this.log?.info?.(`[${this.accountId}] Attempting to reconnect...`);
|
|
415
|
+
this.runtimeCounters.reconnectAttempts += 1;
|
|
352
416
|
|
|
353
417
|
try {
|
|
354
418
|
await this.connect();
|
|
355
419
|
this.log?.info?.(`[${this.accountId}] Reconnection successful`);
|
|
420
|
+
this.runtimeCounters.reconnectSuccess += 1;
|
|
421
|
+
this.logRuntimeCounters("reconnect-success");
|
|
356
422
|
} catch (err: any) {
|
|
357
423
|
if (this.stopped) {
|
|
358
424
|
return;
|
|
359
425
|
}
|
|
360
426
|
|
|
361
427
|
this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
|
|
428
|
+
this.runtimeCounters.reconnectFailures += 1;
|
|
429
|
+
this.logRuntimeCounters("reconnect-failed");
|
|
362
430
|
this.state = ConnectionStateEnum.FAILED;
|
|
431
|
+
this.connectedAt = undefined;
|
|
432
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
363
433
|
this.notifyStateChange(err.message);
|
|
364
434
|
|
|
365
435
|
// Continue runtime recovery instead of getting stuck in FAILED.
|
|
@@ -387,6 +457,8 @@ export class ConnectionManager {
|
|
|
387
457
|
|
|
388
458
|
this.stopped = true;
|
|
389
459
|
this.state = ConnectionStateEnum.DISCONNECTING;
|
|
460
|
+
this.connectedAt = undefined;
|
|
461
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
390
462
|
|
|
391
463
|
// Clear reconnect timer
|
|
392
464
|
this.clearReconnectTimer();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
function groupMembersFilePath(storePath: string, groupId: string): string {
|
|
5
|
+
const dir = path.join(path.dirname(storePath), "dingtalk-members");
|
|
6
|
+
const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
|
|
7
|
+
return path.join(dir, `${safeId}.json`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function noteGroupMember(
|
|
11
|
+
storePath: string,
|
|
12
|
+
groupId: string,
|
|
13
|
+
userId: string,
|
|
14
|
+
name: string,
|
|
15
|
+
): void {
|
|
16
|
+
if (!userId || !name) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const filePath = groupMembersFilePath(storePath, groupId);
|
|
20
|
+
let roster: Record<string, string> = {};
|
|
21
|
+
try {
|
|
22
|
+
roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
23
|
+
} catch {}
|
|
24
|
+
if (roster[userId] === name) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
roster[userId] = name;
|
|
28
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatGroupMembers(storePath: string, groupId: string): string | undefined {
|
|
33
|
+
const filePath = groupMembersFilePath(storePath, groupId);
|
|
34
|
+
let roster: Record<string, string> = {};
|
|
35
|
+
try {
|
|
36
|
+
roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const entries = Object.entries(roster);
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return entries.map(([id, name]) => `${name} (${id})`).join(", ");
|
|
45
|
+
}
|
package/src/inbound-handler.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
1
|
import axios from "axios";
|
|
4
2
|
import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
|
|
5
3
|
import { getAccessToken } from "./auth";
|
|
@@ -14,6 +12,7 @@ import {
|
|
|
14
12
|
streamAICard,
|
|
15
13
|
} from "./card-service";
|
|
16
14
|
import { resolveGroupConfig } from "./config";
|
|
15
|
+
import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
17
16
|
import { setCurrentLogger } from "./logger-context";
|
|
18
17
|
import { extractMessageContent } from "./message-utils";
|
|
19
18
|
import { registerPeerId } from "./peer-id-registry";
|
|
@@ -23,46 +22,6 @@ import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./t
|
|
|
23
22
|
import { AICardStatus } from "./types";
|
|
24
23
|
import { maskSensitiveData } from "./utils";
|
|
25
24
|
|
|
26
|
-
// ============ Group Members Persistence ============
|
|
27
|
-
|
|
28
|
-
function groupMembersFilePath(storePath: string, groupId: string): string {
|
|
29
|
-
const dir = path.join(path.dirname(storePath), "dingtalk-members");
|
|
30
|
-
const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
|
|
31
|
-
return path.join(dir, `${safeId}.json`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function noteGroupMember(storePath: string, groupId: string, userId: string, name: string): void {
|
|
35
|
-
if (!userId || !name) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const filePath = groupMembersFilePath(storePath, groupId);
|
|
39
|
-
let roster: Record<string, string> = {};
|
|
40
|
-
try {
|
|
41
|
-
roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
42
|
-
} catch {}
|
|
43
|
-
if (roster[userId] === name) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
roster[userId] = name;
|
|
47
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
48
|
-
fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function formatGroupMembers(storePath: string, groupId: string): string | undefined {
|
|
52
|
-
const filePath = groupMembersFilePath(storePath, groupId);
|
|
53
|
-
let roster: Record<string, string> = {};
|
|
54
|
-
try {
|
|
55
|
-
roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
56
|
-
} catch {
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
const entries = Object.entries(roster);
|
|
60
|
-
if (entries.length === 0) {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
return entries.map(([id, name]) => `${name} (${id})`).join(", ");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
25
|
/**
|
|
67
26
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
68
27
|
* Files are stored in the global media inbound directory.
|