opencode-dingtalk 0.2.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.
package/dist/state.js ADDED
@@ -0,0 +1,550 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getDataDir } from "./utils.js";
4
+ export const AICardStatus = {
5
+ PROCESSING: "processing",
6
+ INPUTING: "inputing",
7
+ FINISHED: "finished",
8
+ FAILED: "failed",
9
+ };
10
+ /**
11
+ * Per-instance state. Each BotInstance holds its own InstanceState,
12
+ * ensuring complete isolation between multiple DingTalk bots.
13
+ */
14
+ export class InstanceState {
15
+ instanceId;
16
+ dwClient = null;
17
+ botConfig = null;
18
+ watchdogTimer = null;
19
+ client = null;
20
+ serverUrl = null;
21
+ activeUsers = new Map();
22
+ accessToken = null;
23
+ accessTokenExpiry = 0;
24
+ activeSessionId = null;
25
+ // 群聊相关状态
26
+ groupSessions = new Map();
27
+ /** 群聊消息历史记录的最大条数 */
28
+ GROUP_HISTORY_MAX = 20;
29
+ pendingResponses = new Map();
30
+ processedMessages = new Set();
31
+ pendingPermissions = new Map();
32
+ /** Users who sent a message that hasn't received an AI reply yet */
33
+ usersWithPendingRequests = new Set();
34
+ pendingQuestions = new Map();
35
+ sessionStatus = null;
36
+ sessionTodos = new Map();
37
+ sessionAgents = new Map();
38
+ aiCardInstances = new Map();
39
+ activeCardsByTarget = new Map();
40
+ /** sessionId -> userId 映射,用于响应时找到正确的用户 */
41
+ sessionToUserMap = new Map();
42
+ /** Message dedup map (moved from bot.ts module-level) */
43
+ processedInbound = new Map();
44
+ /** 连接管理器 */
45
+ connectionManager = null;
46
+ /** 消息队列 */
47
+ messageQueue = null;
48
+ /** 出站队列定时器 */
49
+ outboundQueueTimer = null;
50
+ /** 消息确认记录 */
51
+ messageAcks = new Map();
52
+ constructor(instanceId) {
53
+ this.instanceId = instanceId;
54
+ this.loadUsersFromDisk();
55
+ this.loadGroupSessionsFromDisk();
56
+ this.loadAllSessionMapsFromDisk(); // 加载session映射(单聊+群聊)
57
+ }
58
+ // ============ Persistence ============
59
+ getUsersPersistPath() {
60
+ return path.join(getDataDir(), `active-users-${this.instanceId}.json`);
61
+ }
62
+ /**
63
+ * 获取群聊 session 持久化文件路径
64
+ */
65
+ getGroupSessionsPersistPath() {
66
+ return path.join(getDataDir(), `group-sessions-${this.instanceId}.json`);
67
+ }
68
+ loadUsersFromDisk() {
69
+ const filePath = this.getUsersPersistPath();
70
+ try {
71
+ const raw = fs.readFileSync(filePath, "utf8");
72
+ const parsed = JSON.parse(raw);
73
+ if (Array.isArray(parsed.users)) {
74
+ this.activeUsers.clear();
75
+ for (const u of parsed.users) {
76
+ if (!u || typeof u.userId !== "string")
77
+ continue;
78
+ this.activeUsers.set(u.userId, {
79
+ userId: u.userId,
80
+ username: typeof u.username === "string" ? u.username : undefined,
81
+ firstSeenAt: typeof u.firstSeenAt === "number" ? u.firstSeenAt : Date.now(),
82
+ lastActiveAt: typeof u.lastActiveAt === "number" ? u.lastActiveAt : Date.now(),
83
+ sessionId: typeof u.sessionId === "string" ? u.sessionId : null,
84
+ sessionWebhook: typeof u.sessionWebhook === "string" ? u.sessionWebhook : undefined,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ }
93
+ /**
94
+ * 从磁盘加载群聊 session
95
+ */
96
+ loadGroupSessionsFromDisk() {
97
+ const filePath = this.getGroupSessionsPersistPath();
98
+ try {
99
+ const raw = fs.readFileSync(filePath, "utf8");
100
+ const parsed = JSON.parse(raw);
101
+ if (Array.isArray(parsed.groupSessions)) {
102
+ for (const gs of parsed.groupSessions) {
103
+ if (!gs || typeof gs.conversationId !== "string")
104
+ continue;
105
+ this.groupSessions.set(gs.conversationId, {
106
+ conversationId: gs.conversationId,
107
+ title: typeof gs.title === "string" ? gs.title : undefined,
108
+ activeSessionId: typeof gs.activeSessionId === "string" ? gs.activeSessionId : null,
109
+ sessionWebhook: undefined,
110
+ createdAt: typeof gs.createdAt === "number" ? gs.createdAt : Date.now(),
111
+ lastActiveAt: typeof gs.lastActiveAt === "number" ? gs.lastActiveAt : Date.now(),
112
+ messageHistory: [],
113
+ });
114
+ }
115
+ }
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ }
121
+ saveUsersToDisk() {
122
+ const filePath = this.getUsersPersistPath();
123
+ try {
124
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ const users = Array.from(this.activeUsers.values());
130
+ try {
131
+ fs.writeFileSync(filePath, JSON.stringify({ users }, null, 2) + "\n", "utf8");
132
+ }
133
+ catch {
134
+ return;
135
+ }
136
+ }
137
+ /**
138
+ * 保存群聊 session 到磁盘
139
+ */
140
+ saveGroupSessionsToDisk() {
141
+ const filePath = this.getGroupSessionsPersistPath();
142
+ try {
143
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ const groupSessions = Array.from(this.groupSessions.values()).map(gs => ({
149
+ conversationId: gs.conversationId,
150
+ title: gs.title,
151
+ activeSessionId: gs.activeSessionId,
152
+ createdAt: gs.createdAt,
153
+ lastActiveAt: gs.lastActiveAt,
154
+ }));
155
+ try {
156
+ fs.writeFileSync(filePath, JSON.stringify({ groupSessions }, null, 2) + "\n", "utf8");
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ }
162
+ // ============ Session Map Persistence ============
163
+ /**
164
+ * 获取session映射持久化文件路径
165
+ */
166
+ getSessionMapPersistPath() {
167
+ return path.join(getDataDir(), `session-map-${this.instanceId}.json`);
168
+ }
169
+ /**
170
+ * 获取文件锁路径
171
+ */
172
+ getLockFilePath() {
173
+ return path.join(getDataDir(), `session-map-${this.instanceId}.lock`);
174
+ }
175
+ /**
176
+ * 获取文件锁
177
+ * @param timeoutMs 锁超时时间(毫秒),默认 5000ms
178
+ * @returns 文件描述符,失败返回 null
179
+ */
180
+ async acquireLock(timeoutMs = 5000) {
181
+ const lockPath = this.getLockFilePath();
182
+ const startTime = Date.now();
183
+ while (Date.now() - startTime < timeoutMs) {
184
+ try {
185
+ // 尝试创建锁文件(独占模式)
186
+ const fd = fs.openSync(lockPath, 'wx');
187
+ // 写入进程ID和时间戳
188
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, timestamp: Date.now() }));
189
+ return fd;
190
+ }
191
+ catch (err) {
192
+ if (err.code === 'EEXIST') {
193
+ // 锁文件已存在,检查是否过期(超过30秒视为过期)
194
+ try {
195
+ const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
196
+ if (Date.now() - lockData.timestamp > 30000) {
197
+ // 锁已过期,删除并重试
198
+ fs.unlinkSync(lockPath);
199
+ continue;
200
+ }
201
+ }
202
+ catch {
203
+ // 锁文件损坏,删除并重试
204
+ try {
205
+ fs.unlinkSync(lockPath);
206
+ }
207
+ catch { }
208
+ continue;
209
+ }
210
+ // 等待100ms后重试
211
+ await new Promise(resolve => setTimeout(resolve, 100));
212
+ }
213
+ else {
214
+ // 其他错误,返回 null
215
+ return null;
216
+ }
217
+ }
218
+ }
219
+ return null; // 超时
220
+ }
221
+ /**
222
+ * 释放文件锁
223
+ * @param fd 文件描述符
224
+ */
225
+ releaseLock(fd) {
226
+ if (fd === null)
227
+ return;
228
+ try {
229
+ fs.closeSync(fd);
230
+ fs.unlinkSync(this.getLockFilePath());
231
+ }
232
+ catch {
233
+ // 忽略释放锁时的错误
234
+ }
235
+ }
236
+ /**
237
+ * 保存单聊session映射到磁盘
238
+ * 使用全局锁避免并发写入
239
+ */
240
+ async saveSingleChatSessionMapToDisk() {
241
+ const fd = await this.acquireLock();
242
+ if (fd === null) {
243
+ return;
244
+ }
245
+ try {
246
+ const filePath = this.getSessionMapPersistPath();
247
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
248
+ // 读取现有数据(如果存在)
249
+ let existingData = {};
250
+ try {
251
+ const raw = fs.readFileSync(filePath, 'utf8');
252
+ existingData = JSON.parse(raw);
253
+ }
254
+ catch {
255
+ // 文件不存在或解析失败,使用空对象
256
+ }
257
+ // 更新单聊部分
258
+ existingData.singleChatSessions = {
259
+ user_session_map: Array.from(this.sessionToUserMap.entries()),
260
+ lastUpdated: Date.now(),
261
+ };
262
+ // 保留群聊部分(如果存在)
263
+ if (!existingData.groupChatSessions) {
264
+ existingData.groupChatSessions = {
265
+ conversation_session_map: [],
266
+ lastUpdated: Date.now(),
267
+ };
268
+ }
269
+ fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2) + "\n", "utf8");
270
+ }
271
+ catch (err) {
272
+ // 忽略保存错误
273
+ }
274
+ finally {
275
+ this.releaseLock(fd);
276
+ }
277
+ }
278
+ /**
279
+ * 保存群聊session映射到磁盘
280
+ * 使用全局锁避免并发写入
281
+ */
282
+ async saveGroupChatSessionMapToDisk() {
283
+ const fd = await this.acquireLock();
284
+ if (fd === null) {
285
+ return;
286
+ }
287
+ try {
288
+ const filePath = this.getSessionMapPersistPath();
289
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
290
+ // 读取现有数据(如果存在)
291
+ let existingData = {};
292
+ try {
293
+ const raw = fs.readFileSync(filePath, 'utf8');
294
+ existingData = JSON.parse(raw);
295
+ }
296
+ catch {
297
+ // 文件不存在或解析失败,使用空对象
298
+ }
299
+ // 更新群聊部分
300
+ existingData.groupChatSessions = {
301
+ conversation_session_map: Array.from(this.groupSessions.entries()).map(([convId, session]) => [convId, session.activeSessionId]),
302
+ lastUpdated: Date.now(),
303
+ };
304
+ // 保留单聊部分(如果存在)
305
+ if (!existingData.singleChatSessions) {
306
+ existingData.singleChatSessions = {
307
+ user_session_map: [],
308
+ lastUpdated: Date.now(),
309
+ };
310
+ }
311
+ fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2) + "\n", "utf8");
312
+ }
313
+ catch (err) {
314
+ // 忽略保存错误
315
+ }
316
+ finally {
317
+ this.releaseLock(fd);
318
+ }
319
+ }
320
+ /**
321
+ * 从磁盘加载所有session映射(单聊 + 群聊)
322
+ * 启动时调用,无需加锁(单线程启动)
323
+ */
324
+ loadAllSessionMapsFromDisk() {
325
+ const filePath = this.getSessionMapPersistPath();
326
+ try {
327
+ const raw = fs.readFileSync(filePath, 'utf8');
328
+ const parsed = JSON.parse(raw);
329
+ // 加载单聊映射
330
+ if (parsed.singleChatSessions) {
331
+ if (Array.isArray(parsed.singleChatSessions.user_session_map)) {
332
+ this.sessionToUserMap = new Map(parsed.singleChatSessions.user_session_map);
333
+ }
334
+ }
335
+ // 群聊的 activeSessionId 已经在 loadGroupSessionsFromDisk 中加载
336
+ // 这里无需重复加载
337
+ }
338
+ catch {
339
+ // 文件不存在或解析失败,忽略
340
+ }
341
+ }
342
+ // ============ User Tracking ============
343
+ /**
344
+ * 记录用户信息
345
+ * @param userId 用户ID
346
+ * @param username 用户名
347
+ * @param sessionWebhook 单聊的 session webhook
348
+ * @param conversationId 群聊会话ID(如果是群聊则传入)
349
+ * @param groupWebhook 群聊的 session webhook(如果是群聊则传入)
350
+ */
351
+ trackUser(userId, username, sessionWebhook, conversationId, groupWebhook) {
352
+ const existing = this.activeUsers.get(userId);
353
+ if (existing) {
354
+ existing.lastActiveAt = Date.now();
355
+ if (username)
356
+ existing.username = username;
357
+ // 单聊的 webhook
358
+ if (sessionWebhook)
359
+ existing.sessionWebhook = sessionWebhook;
360
+ // 群聊的 webhook 需要单独存储,避免覆盖单聊的 webhook
361
+ if (conversationId && groupWebhook) {
362
+ if (!existing.groupSessionWebhooks) {
363
+ existing.groupSessionWebhooks = new Map();
364
+ }
365
+ existing.groupSessionWebhooks.set(conversationId, groupWebhook);
366
+ }
367
+ }
368
+ else {
369
+ const newUser = {
370
+ userId,
371
+ username,
372
+ firstSeenAt: Date.now(),
373
+ lastActiveAt: Date.now(),
374
+ sessionId: null,
375
+ sessionWebhook,
376
+ };
377
+ // 群聊的 webhook
378
+ if (conversationId && groupWebhook) {
379
+ newUser.groupSessionWebhooks = new Map();
380
+ newUser.groupSessionWebhooks.set(conversationId, groupWebhook);
381
+ }
382
+ this.activeUsers.set(userId, newUser);
383
+ }
384
+ this.saveUsersToDisk();
385
+ }
386
+ /**
387
+ * 获取用户在指定群聊中的 session webhook
388
+ */
389
+ getGroupSessionWebhook(userId, conversationId) {
390
+ const user = this.activeUsers.get(userId);
391
+ return user?.groupSessionWebhooks?.get(conversationId);
392
+ }
393
+ /**
394
+ * 获取用户的 OpenCode 会话 ID,如不存在则返回 null
395
+ */
396
+ getUserSessionId(userId) {
397
+ const user = this.activeUsers.get(userId);
398
+ return user?.sessionId;
399
+ }
400
+ /**
401
+ * 设置用户的 OpenCode 会话 ID
402
+ */
403
+ setUserSessionId(userId, sessionId) {
404
+ const user = this.activeUsers.get(userId);
405
+ if (user) {
406
+ user.sessionId = sessionId;
407
+ }
408
+ }
409
+ /**
410
+ * 获取用户消息历史的 key(用于 pendingResponses)
411
+ */
412
+ getUserResponseKey(userId, sessionId) {
413
+ return `user:${userId}:${sessionId}`;
414
+ }
415
+ /**
416
+ * 关联 sessionId 和 userId
417
+ */
418
+ bindSessionToUser(sessionId, userId) {
419
+ this.sessionToUserMap.set(sessionId, userId);
420
+ }
421
+ /**
422
+ * 根据 sessionId 获取 userId
423
+ */
424
+ getUserIdBySession(sessionId) {
425
+ return this.sessionToUserMap.get(sessionId);
426
+ }
427
+ /**
428
+ * 清理 session 到 user 的映射
429
+ */
430
+ unbindSession(sessionId) {
431
+ this.sessionToUserMap.delete(sessionId);
432
+ }
433
+ getActiveUsers() {
434
+ return Array.from(this.activeUsers.values());
435
+ }
436
+ isUserActive(userId) {
437
+ return this.activeUsers.has(userId);
438
+ }
439
+ // ============ Session Agent ============
440
+ setSessionAgent(sessionId, agent) {
441
+ this.sessionAgents.set(sessionId, agent);
442
+ }
443
+ getActiveAgent() {
444
+ if (!this.activeSessionId)
445
+ return null;
446
+ return this.sessionAgents.get(this.activeSessionId) ?? null;
447
+ }
448
+ setActiveAgent(agent) {
449
+ if (this.activeSessionId) {
450
+ this.sessionAgents.set(this.activeSessionId, agent);
451
+ }
452
+ }
453
+ // ============ Group Session Management ============
454
+ /**
455
+ * 获取或创建群聊会话
456
+ */
457
+ getOrCreateGroupSession(conversationId, title) {
458
+ let session = this.groupSessions.get(conversationId);
459
+ if (!session) {
460
+ session = {
461
+ conversationId,
462
+ title,
463
+ activeSessionId: null,
464
+ createdAt: Date.now(),
465
+ lastActiveAt: Date.now(),
466
+ messageHistory: [],
467
+ };
468
+ this.groupSessions.set(conversationId, session);
469
+ }
470
+ else {
471
+ session.lastActiveAt = Date.now();
472
+ if (title)
473
+ session.title = title;
474
+ }
475
+ // 保存到磁盘
476
+ this.saveGroupSessionsToDisk();
477
+ return session;
478
+ }
479
+ /**
480
+ * 更新群聊的 activeSessionId 并持久化
481
+ */
482
+ setGroupSessionId(conversationId, sessionId) {
483
+ const session = this.groupSessions.get(conversationId);
484
+ if (session) {
485
+ session.activeSessionId = sessionId;
486
+ this.saveGroupSessionsToDisk();
487
+ }
488
+ }
489
+ /**
490
+ * 添加群聊消息到历史记录
491
+ */
492
+ addGroupMessage(conversationId, message) {
493
+ const session = this.groupSessions.get(conversationId);
494
+ if (!session)
495
+ return;
496
+ const newMessage = {
497
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
498
+ timestamp: Date.now(),
499
+ ...message,
500
+ };
501
+ session.messageHistory.push(newMessage);
502
+ // 限制历史记录数量
503
+ if (session.messageHistory.length > this.GROUP_HISTORY_MAX) {
504
+ session.messageHistory = session.messageHistory.slice(-this.GROUP_HISTORY_MAX);
505
+ }
506
+ }
507
+ /**
508
+ * 获取群聊消息历史(用于大模型判断相关性)
509
+ */
510
+ getGroupMessageHistory(conversationId) {
511
+ const session = this.groupSessions.get(conversationId);
512
+ return session?.messageHistory || [];
513
+ }
514
+ /**
515
+ * 为群聊创建或获取活跃的 OpenCode 会话
516
+ * @param conversationId 群聊会话ID
517
+ * @param title 可选的会话标题
518
+ */
519
+ async ensureGroupActiveSession(conversationId, title) {
520
+ if (!this.client)
521
+ return null;
522
+ const session = this.groupSessions.get(conversationId);
523
+ if (!session)
524
+ return null;
525
+ if (!session.activeSessionId) {
526
+ const res = await this.client.session.create({
527
+ body: {
528
+ title: title || `钉钉群聊: ${conversationId}`,
529
+ },
530
+ });
531
+ if (res.data?.id) {
532
+ session.activeSessionId = res.data.id;
533
+ }
534
+ }
535
+ return session.activeSessionId;
536
+ }
537
+ }
538
+ // ============ Backward Compatibility ============
539
+ let defaultInstance = null;
540
+ /**
541
+ * Returns the default (singleton) InstanceState.
542
+ * Kept for backward compatibility — new code should use
543
+ * explicit InstanceState instances via BotInstance / registry.
544
+ */
545
+ export function getState() {
546
+ if (!defaultInstance) {
547
+ defaultInstance = new InstanceState("default");
548
+ }
549
+ return defaultInstance;
550
+ }
@@ -0,0 +1,3 @@
1
+ // Stub types for dingtalk-stream removed
2
+ // These types are used in the codebase but the actual DWClient library has been removed
3
+ export const TOPIC_ROBOT = "TOPIC_ROBOT";