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.
@@ -0,0 +1,2129 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { acquireBotLock, releaseBotLock } from "../lock.js";
3
+ import { logger } from "../logger.js";
4
+ import { AICardStatus, } from "../state.js";
5
+ import { getAccessToken, splitMessage, detectMarkdownAndExtractTitle, ensureMarkdownLineBreaks, getPrimaryAgentNames, cycleAgentName, getAgentModel, } from "../utils.js";
6
+ import { ConnectionManager, ConnectionState } from "../connection-manager.js";
7
+ import { MessageQueue, QueueType } from "../message-queue.js";
8
+ import { sendTextMessage, sendMarkdownMessage, } from "./dingtalk-api.js";
9
+ import { TOPIC_ROBOT } from "./types.js";
10
+ import { DingTalkClient } from "./dingtalk-client.js";
11
+ import fs from "node:fs";
12
+ // ============ Debug Logger ============
13
+ /**
14
+ * 调试日志 - 写入文件便于排查问题
15
+ * 设置环境变量 OPENCODE_DINGTALK_DEBUG_DIR 可自定义日志目录
16
+ */
17
+ function debugLog(...args) {
18
+ const logDir = process.env.OPENCODE_DINGTALK_DEBUG_DIR || "/tmp";
19
+ const logFile = `${logDir}/opencode-dingtalk-debug.log`;
20
+ const timestamp = new Date().toISOString();
21
+ const message = args
22
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
23
+ .join(" ");
24
+ const line = `[${timestamp}] ${message}\n`;
25
+ try {
26
+ fs.appendFileSync(logFile, line);
27
+ }
28
+ catch {
29
+ // 忽略文件写入错误
30
+ }
31
+ // 同时打印到控制台
32
+ //logger.log('[opencode-dingtalk-debug]', ...args);
33
+ }
34
+ // ============ Constants ============
35
+ const DINGTALK_API = "https://api.dingtalk.com";
36
+ const CARD_CACHE_TTL = 60 * 60 * 1000; // 1 hour
37
+ // ============ Message Deduplication ============
38
+ const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
39
+ const MESSAGE_DEDUP_CLEANUP_THRESHOLD = 100;
40
+ function cleanupProcessedMessages(state) {
41
+ const now = Date.now();
42
+ for (const [msgId, timestamp] of state.processedInbound.entries()) {
43
+ if (now - timestamp > MESSAGE_DEDUP_TTL) {
44
+ state.processedInbound.delete(msgId);
45
+ }
46
+ }
47
+ }
48
+ function isMessageProcessed(state, messageId) {
49
+ return state.processedInbound.has(messageId);
50
+ }
51
+ function markMessageProcessed(state, messageId) {
52
+ state.processedInbound.set(messageId, Date.now());
53
+ if (state.processedInbound.size >= MESSAGE_DEDUP_CLEANUP_THRESHOLD) {
54
+ cleanupProcessedMessages(state);
55
+ }
56
+ }
57
+ // ============ Group Chat Helpers ============
58
+ /**
59
+ * 解析群聊消息,提取实际内容和 @ 状态
60
+ */
61
+ function parseGroupMessage(text, atList, isAtAll, chatbotUserId, mention, mentionAll) {
62
+ const originalText = text.trim();
63
+ // 钉钉群聊消息,text 字段可能只包含实际内容,不包含 @部分
64
+ // 需要通过其他方式判断是否 @了机器人
65
+ // 检测是否 @所有人
66
+ let parsedIsAtAll = isAtAll || mentionAll || false;
67
+ // 检测是否 @了机器人
68
+ let isAtBot = false;
69
+ // 方式1: 检查 isAtAll 字段
70
+ if (isAtAll || mentionAll) {
71
+ parsedIsAtAll = true;
72
+ }
73
+ // 方式2: 检查 atList 是否包含机器人的 userId
74
+ if (atList && chatbotUserId) {
75
+ isAtBot = atList.includes(chatbotUserId);
76
+ }
77
+ // 方式3: 检查 mention 字段(某些版本的钉钉)
78
+ if (mention && chatbotUserId) {
79
+ isAtBot = mention.includes(chatbotUserId);
80
+ }
81
+ // 方式4: 检查文本是否以 @ 开头(有些钉钉版本会包含 @文本)
82
+ if (originalText.startsWith("@")) {
83
+ isAtBot = true;
84
+ }
85
+ // 方式5: 检查文本中是否包含 @所有人
86
+ if (originalText.includes("@所有人")) {
87
+ parsedIsAtAll = true;
88
+ }
89
+ // 清理内容
90
+ let cleanContent = originalText;
91
+ // 去除 @所有人
92
+ if (parsedIsAtAll) {
93
+ cleanContent = cleanContent.replace(/@所有人\s*/g, "").trim();
94
+ }
95
+ // 去除 @机器人的文本(如果文本中包含)
96
+ if (isAtBot && originalText.startsWith("@")) {
97
+ cleanContent = cleanContent.replace(/^@\S+[\s\n]*/, "").trim();
98
+ }
99
+ // 如果清理后为空,使用原始文本
100
+ if (!cleanContent) {
101
+ cleanContent = originalText;
102
+ }
103
+ // 调试日志
104
+ debugLog("[Group] parseGroupMessage result:", {
105
+ originalText,
106
+ atList,
107
+ isAtAll,
108
+ mention,
109
+ mentionAll,
110
+ chatbotUserId,
111
+ isAtBot,
112
+ parsedIsAtAll,
113
+ cleanContent,
114
+ });
115
+ return {
116
+ cleanContent,
117
+ isAtBot,
118
+ isAtAll: parsedIsAtAll,
119
+ };
120
+ }
121
+ /**
122
+ * 构建群聊消息历史上下文,用于大模型判断相关性
123
+ */
124
+ function buildGroupContext(state, conversationId) {
125
+ const history = state.getGroupMessageHistory(conversationId);
126
+ if (history.length === 0) {
127
+ return "无历史消息记录";
128
+ }
129
+ const recentMessages = history.slice(-10); // 取最近10条
130
+ const lines = recentMessages.map((m) => {
131
+ const atInfo = m.isAtBot ? " [被@]" : m.isAtAll ? " [@所有人]" : "";
132
+ return `${m.senderNick}${atInfo}: ${m.content}`;
133
+ });
134
+ return lines.join("\n");
135
+ }
136
+ // ============ Session Helpers ============
137
+ function formatSessionShort(id) {
138
+ if (!id)
139
+ return "none";
140
+ return `${id.slice(0, 8)}...`;
141
+ }
142
+ /**
143
+ * 验证 session 是否在 Web 端仍然存在
144
+ * @param client OpenCode 客户端
145
+ * @param sessionId 要验证的 session ID
146
+ * @returns true 如果 session 存在,false 如果不存在
147
+ */
148
+ async function isSessionValid(client, sessionId) {
149
+ try {
150
+ const res = await client.session.get({ path: { id: sessionId } });
151
+ return !!res.data;
152
+ }
153
+ catch {
154
+ // 如果获取失败(session 不存在),返回 false
155
+ return false;
156
+ }
157
+ }
158
+ /**
159
+ * 获取有效的 OpenCode Session
160
+ * 如果提供的 sessionId 在 Web 端不存在,则创建新的 session
161
+ * @param state 实例状态
162
+ * @param userId 可选的用户 ID,如果提供则返回该用户对应的 session,否则返回全局 session
163
+ * @param title 可选的 session 标题
164
+ */
165
+ async function ensureActiveSession(state, userId, title) {
166
+ if (!state.client)
167
+ return null;
168
+ // 如果提供了 userId,优先使用该用户对应的 session
169
+ if (userId) {
170
+ const userSessionId = state.getUserSessionId(userId);
171
+ if (userSessionId) {
172
+ // 验证 session 是否在 Web 端仍然存在
173
+ const isValid = await isSessionValid(state.client, userSessionId);
174
+ if (isValid) {
175
+ return userSessionId;
176
+ }
177
+ else {
178
+ // session 不存在,清除本地记录,创建新的
179
+ logger.log(`[opencode-dingtalk] Session ${userSessionId} no longer exists in Web, creating new one`);
180
+ state.setUserSessionId(userId, null);
181
+ }
182
+ }
183
+ }
184
+ // 否则使用全局 session
185
+ if (!state.activeSessionId) {
186
+ try {
187
+ const res = await state.client.session.create({
188
+ body: {
189
+ title: title || "钉钉机器人会话",
190
+ },
191
+ });
192
+ if (res.data?.id) {
193
+ state.activeSessionId = res.data.id;
194
+ }
195
+ }
196
+ catch (err) {
197
+ logger.error("[opencode-dingtalk] Failed to create session:", err);
198
+ }
199
+ }
200
+ return state.activeSessionId;
201
+ }
202
+ function resolvePermissionId(state, prefixOrId) {
203
+ if (state.pendingPermissions.has(prefixOrId))
204
+ return prefixOrId;
205
+ const normalized = prefixOrId.toLowerCase();
206
+ for (const id of state.pendingPermissions.keys()) {
207
+ if (id.toLowerCase().startsWith(normalized))
208
+ return id;
209
+ }
210
+ return null;
211
+ }
212
+ function formatTodosLine(todos) {
213
+ const total = todos.length;
214
+ const done = todos.filter((t) => t.status === "completed").length;
215
+ return `${done}/${total}`;
216
+ }
217
+ /**
218
+ * 格式化持续时间
219
+ *
220
+ * @param ms 毫秒数
221
+ * @returns 格式化的时间字符串
222
+ */
223
+ function formatDuration(ms) {
224
+ if (ms < 1000) {
225
+ return `${ms}ms`;
226
+ }
227
+ const seconds = Math.floor(ms / 1000);
228
+ if (seconds < 60) {
229
+ return `${seconds}s`;
230
+ }
231
+ const minutes = Math.floor(seconds / 60);
232
+ const remainingSeconds = seconds % 60;
233
+ if (minutes < 60) {
234
+ return `${minutes}m ${remainingSeconds}s`;
235
+ }
236
+ const hours = Math.floor(minutes / 60);
237
+ const remainingMinutes = minutes % 60;
238
+ if (hours < 24) {
239
+ return `${hours}h ${remainingMinutes}m`;
240
+ }
241
+ const days = Math.floor(hours / 24);
242
+ const remainingHours = hours % 24;
243
+ return `${days}d ${remainingHours}h`;
244
+ }
245
+ // ============ Outbound Retry & ACK ============
246
+ /**
247
+ * 出站消息重试配置
248
+ */
249
+ const OUTBOUND_RETRY_CONFIG = {
250
+ maxRetries: 3,
251
+ retryDelaysMs: [2000, 5000, 10000], // 指数退避
252
+ retryableErrors: [408, 429, 500, 502, 503, 504], // 可重试的 HTTP 状态码
253
+ };
254
+ /**
255
+ * 重试包装器 - 支持指数退避和可重试错误判断
256
+ */
257
+ async function withRetry(fn, config, context) {
258
+ let lastError = null;
259
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
260
+ try {
261
+ return await fn();
262
+ }
263
+ catch (err) {
264
+ lastError = err;
265
+ // 检查是否可重试
266
+ const status = err.response?.status;
267
+ if (status && !config.retryableErrors.includes(status)) {
268
+ throw err; // 不可重试的错误,直接抛出
269
+ }
270
+ // 最后一次尝试,不再重试
271
+ if (attempt === config.maxRetries) {
272
+ break;
273
+ }
274
+ // 等待后重试
275
+ const delay = config.retryDelaysMs[attempt];
276
+ logger.warn(`[${context}] Retry ${attempt + 1}/${config.maxRetries} after ${delay}ms`);
277
+ await new Promise((resolve) => setTimeout(resolve, delay));
278
+ }
279
+ }
280
+ throw lastError;
281
+ }
282
+ /**
283
+ * 记录消息确认状态
284
+ */
285
+ function recordMessageAck(state, messageId, status, error) {
286
+ const ack = {
287
+ messageId,
288
+ timestamp: Date.now(),
289
+ status,
290
+ error,
291
+ };
292
+ state.messageAcks.set(messageId, ack);
293
+ // 只保留最近 100 条
294
+ if (state.messageAcks.size > 100) {
295
+ const oldest = Array.from(state.messageAcks.entries()).sort(([, a], [, b]) => a.timestamp - b.timestamp)[0];
296
+ if (oldest) {
297
+ state.messageAcks.delete(oldest[0]);
298
+ }
299
+ }
300
+ }
301
+ /**
302
+ * 从 webhook URL 中提取 userId(如果可能)
303
+ */
304
+ function extractUserIdFromWebhook(webhook) {
305
+ // 钉钉 webhook URL 格式通常不包含 userId,返回 null
306
+ // 如果未来有变化,可以在这里添加解析逻辑
307
+ return null;
308
+ }
309
+ // ============ Message Sending ============
310
+ export async function sendBySessionWebhook(webhook, text, log, messageQueue, // 可选的消息队列
311
+ state, // 可选的实例状态(用于回退到 proactive message)
312
+ config) {
313
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(text);
314
+ const payload = useMarkdown
315
+ ? {
316
+ msgtype: "markdown",
317
+ markdown: { title, text: ensureMarkdownLineBreaks(text) },
318
+ }
319
+ : { msgtype: "text", text: { content: text } };
320
+ // 调试日志:打印发送请求
321
+ const webhookShort = webhook ? `${webhook.slice(0, 30)}...` : "EMPTY";
322
+ logger.log(`[opencode-dingtalk][Debug] Sending message via webhook: ${webhookShort}, type=${useMarkdown ? "markdown" : "text"}, text length=${text.length}`);
323
+ const messageId = `webhook_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
324
+ try {
325
+ await withRetry(() => useMarkdown
326
+ ? sendMarkdownMessage(webhook, title, text)
327
+ : sendTextMessage(webhook, text), OUTBOUND_RETRY_CONFIG, "sendBySessionWebhook");
328
+ logger.log(`[opencode-dingtalk][Debug] Webhook sent successfully`);
329
+ log?.debug?.(`[DingTalk] Sent via session webhook, type=${useMarkdown ? "markdown" : "text"}`);
330
+ // 记录发送成功
331
+ if (state) {
332
+ recordMessageAck(state, messageId, "sent");
333
+ }
334
+ }
335
+ catch (err) {
336
+ logger.error(`[opencode-dingtalk][Error] Failed to send via webhook after retries: ${err.message}`, err.response?.data || "");
337
+ // 记录发送失败
338
+ if (state) {
339
+ recordMessageAck(state, messageId, "failed", err.message);
340
+ }
341
+ // 检查是否是 webhook 过期(4xx 错误),尝试回退到 proactive message
342
+ if (err.response?.status >= 400 &&
343
+ err.response?.status < 500 &&
344
+ state &&
345
+ config) {
346
+ logger.warn(`[opencode-dingtalk] Webhook may be expired (status=${err.response.status}), falling back to proactive message`);
347
+ // 从 webhook URL 中提取 userId(如果可能)
348
+ const userId = extractUserIdFromWebhook(webhook);
349
+ if (userId) {
350
+ try {
351
+ await sendProactiveMessage(state, config, userId, text, log);
352
+ logger.log(`[opencode-dingtalk] Fallback to proactive message succeeded for ${userId}`);
353
+ return; // 成功,不抛出异常
354
+ }
355
+ catch (fallbackErr) {
356
+ logger.error(`[opencode-dingtalk] Fallback to proactive message also failed: ${fallbackErr.message}`);
357
+ }
358
+ }
359
+ }
360
+ // 如果有消息队列,添加到队列以便后续重试
361
+ if (messageQueue) {
362
+ messageQueue.enqueue({
363
+ type: QueueType.OUTBOUND,
364
+ content: text,
365
+ target: webhook,
366
+ maxRetries: 3,
367
+ metadata: { useMarkdown, title },
368
+ });
369
+ logger.log(`[opencode-dingtalk] Message added to outbound queue for later retry`);
370
+ }
371
+ throw err;
372
+ }
373
+ }
374
+ async function sendProactiveMessage(state, config, userId, text, log) {
375
+ const token = await getAccessToken(state, config, log);
376
+ const { useMarkdown, title } = detectMarkdownAndExtractTitle(text);
377
+ const msgKey = useMarkdown ? "sampleMarkdown" : "sampleText";
378
+ const msgParam = useMarkdown
379
+ ? JSON.stringify({ title, text: ensureMarkdownLineBreaks(text) })
380
+ : JSON.stringify({ content: text });
381
+ const robotCode = config.robotCode || config.clientId;
382
+ const messageId = `proactive_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
383
+ try {
384
+ await withRetry(async () => {
385
+ const response = await fetch(`${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`, {
386
+ method: "POST",
387
+ headers: {
388
+ "x-acs-dingtalk-access-token": token,
389
+ "Content-Type": "application/json",
390
+ },
391
+ body: JSON.stringify({
392
+ robotCode,
393
+ userIds: [userId],
394
+ msgKey,
395
+ msgParam,
396
+ }),
397
+ });
398
+ if (!response.ok) {
399
+ throw new Error(`Batch send failed: ${response.status}`);
400
+ }
401
+ }, OUTBOUND_RETRY_CONFIG, "sendProactiveMessage");
402
+ log?.debug?.(`[DingTalk] Sent proactive message to ${userId}, type=${msgKey}`);
403
+ // 记录发送成功
404
+ recordMessageAck(state, messageId, "sent");
405
+ }
406
+ catch (err) {
407
+ logger.error(`[opencode-dingtalk][Error] Failed to send proactive message to ${userId} after retries: ${err.message}`);
408
+ // 记录发送失败
409
+ recordMessageAck(state, messageId, "failed", err.message);
410
+ // 添加到出站队列
411
+ if (state.messageQueue) {
412
+ state.messageQueue.enqueue({
413
+ type: QueueType.OUTBOUND,
414
+ content: text,
415
+ target: userId,
416
+ maxRetries: 3,
417
+ metadata: { msgKey, msgParam, robotCode },
418
+ });
419
+ logger.log(`[opencode-dingtalk] Message added to outbound queue for user ${userId}`);
420
+ }
421
+ throw err;
422
+ }
423
+ }
424
+ // ============ AI Card Functions ============
425
+ async function createAICard(state, config, targetKey, conversationId, log) {
426
+ try {
427
+ const token = await getAccessToken(state, config, log);
428
+ const cardInstanceId = `opencode_${Date.now()}_${randomUUID().slice(0, 8)}`;
429
+ const createAndDeliverBody = {
430
+ cardTemplateId: config.cardTemplateId || "8aebdfb9-28f4-4a98-98f5-396c3dde41a0.schema",
431
+ outTrackId: cardInstanceId,
432
+ cardData: { cardParamMap: {} },
433
+ callbackType: "STREAM",
434
+ imRobotOpenSpaceModel: { supportForward: true },
435
+ openSpaceId: `dtv1.card//IM_ROBOT.${conversationId}`,
436
+ userIdType: 1,
437
+ imRobotOpenDeliverModel: { spaceType: "IM_ROBOT" },
438
+ };
439
+ log?.debug?.(`[DingTalk][AICard] Creating card: ${cardInstanceId}`);
440
+ const response = await fetch(`${DINGTALK_API}/v1.0/card/instances/createAndDeliver`, {
441
+ method: "POST",
442
+ headers: {
443
+ "x-acs-dingtalk-access-token": token,
444
+ "Content-Type": "application/json",
445
+ },
446
+ body: JSON.stringify(createAndDeliverBody),
447
+ });
448
+ if (!response.ok) {
449
+ const errorText = await response.text();
450
+ throw new Error(`Card creation failed: ${response.status} ${errorText}`);
451
+ }
452
+ const aiCardInstance = {
453
+ cardInstanceId,
454
+ accessToken: token,
455
+ conversationId,
456
+ createdAt: Date.now(),
457
+ lastUpdated: Date.now(),
458
+ state: AICardStatus.PROCESSING,
459
+ };
460
+ state.aiCardInstances.set(cardInstanceId, aiCardInstance);
461
+ state.activeCardsByTarget.set(targetKey, cardInstanceId);
462
+ log?.debug?.(`[DingTalk][AICard] Created card: ${cardInstanceId}`);
463
+ return aiCardInstance;
464
+ }
465
+ catch (err) {
466
+ log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
467
+ return null;
468
+ }
469
+ }
470
+ async function streamAICard(state, card, content, finished = false, log) {
471
+ const config = state.botConfig;
472
+ const tokenAge = Date.now() - card.createdAt;
473
+ if (tokenAge > 90 * 60 * 1000 && config) {
474
+ try {
475
+ card.accessToken = await getAccessToken(state, config, log);
476
+ }
477
+ catch (err) {
478
+ log?.warn?.(`[DingTalk][AICard] Token refresh failed: ${err.message}`);
479
+ }
480
+ }
481
+ const streamBody = {
482
+ outTrackId: card.cardInstanceId,
483
+ guid: randomUUID(),
484
+ key: "content",
485
+ content,
486
+ isFull: true,
487
+ isFinalize: finished,
488
+ isError: false,
489
+ };
490
+ try {
491
+ const response = await fetch(`${DINGTALK_API}/v1.0/card/streaming`, {
492
+ method: "PUT",
493
+ headers: {
494
+ "x-acs-dingtalk-access-token": card.accessToken,
495
+ "Content-Type": "application/json",
496
+ },
497
+ body: JSON.stringify(streamBody),
498
+ });
499
+ if (!response.ok) {
500
+ throw new Error(`Card stream update failed: ${response.status}`);
501
+ }
502
+ card.lastUpdated = Date.now();
503
+ if (finished) {
504
+ card.state = AICardStatus.FINISHED;
505
+ }
506
+ else if (card.state === AICardStatus.PROCESSING) {
507
+ card.state = AICardStatus.INPUTING;
508
+ }
509
+ }
510
+ catch (err) {
511
+ const error = err instanceof Error ? err : new Error(String(err));
512
+ if (error.message.includes("401") && config) {
513
+ try {
514
+ card.accessToken = await getAccessToken(state, config, log);
515
+ const retryResponse = await fetch(`${DINGTALK_API}/v1.0/card/streaming`, {
516
+ method: "PUT",
517
+ headers: {
518
+ "x-acs-dingtalk-access-token": card.accessToken,
519
+ "Content-Type": "application/json",
520
+ },
521
+ body: JSON.stringify(streamBody),
522
+ });
523
+ if (!retryResponse.ok) {
524
+ throw new Error(`Card stream retry failed: ${retryResponse.status}`);
525
+ }
526
+ card.lastUpdated = Date.now();
527
+ if (finished)
528
+ card.state = AICardStatus.FINISHED;
529
+ else if (card.state === AICardStatus.PROCESSING)
530
+ card.state = AICardStatus.INPUTING;
531
+ return;
532
+ }
533
+ catch (retryErr) {
534
+ const retryError = retryErr instanceof Error ? retryErr : new Error(String(retryErr));
535
+ log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryError.message}`);
536
+ }
537
+ }
538
+ card.state = AICardStatus.FAILED;
539
+ card.lastUpdated = Date.now();
540
+ log?.error?.(`[DingTalk][AICard] Streaming failed: ${error.message}`);
541
+ throw err;
542
+ }
543
+ }
544
+ async function finishAICard(state, card, content, log) {
545
+ await streamAICard(state, card, content, true, log);
546
+ }
547
+ function cleanupCardCache(state) {
548
+ const now = Date.now();
549
+ for (const [cardId, instance] of state.aiCardInstances.entries()) {
550
+ const isTerminal = instance.state === AICardStatus.FINISHED ||
551
+ instance.state === AICardStatus.FAILED;
552
+ if (isTerminal && now - instance.lastUpdated > CARD_CACHE_TTL) {
553
+ state.aiCardInstances.delete(cardId);
554
+ for (const [targetKey, mappedId] of state.activeCardsByTarget.entries()) {
555
+ if (mappedId === cardId) {
556
+ state.activeCardsByTarget.delete(targetKey);
557
+ break;
558
+ }
559
+ }
560
+ }
561
+ }
562
+ }
563
+ // ============ Card Helpers for External Use ============
564
+ export async function streamCardToUser(state, userId, content, finished, log) {
565
+ const cardId = state.activeCardsByTarget.get(userId);
566
+ if (!cardId)
567
+ return false;
568
+ const card = state.aiCardInstances.get(cardId);
569
+ if (!card ||
570
+ card.state === AICardStatus.FINISHED ||
571
+ card.state === AICardStatus.FAILED) {
572
+ return false;
573
+ }
574
+ try {
575
+ if (finished) {
576
+ await finishAICard(state, card, content, log);
577
+ }
578
+ else {
579
+ await streamAICard(state, card, content, false, log);
580
+ }
581
+ return true;
582
+ }
583
+ catch (err) {
584
+ log?.error?.(`[DingTalk] Card stream to ${userId} failed: ${err.message}`);
585
+ if (finished)
586
+ card.state = AICardStatus.FAILED;
587
+ return false;
588
+ }
589
+ }
590
+ export async function streamCardToAllActive(state, content, finished, log) {
591
+ if (state.botConfig?.messageType !== "card")
592
+ return 0;
593
+ let count = 0;
594
+ for (const user of state.activeUsers.values()) {
595
+ const ok = await streamCardToUser(state, user.userId, content, finished, log);
596
+ if (ok)
597
+ count++;
598
+ }
599
+ return count;
600
+ }
601
+ // ============ Unified Send Function ============
602
+ async function sendMessageToUser(state, userId, text, options = {}) {
603
+ const config = state.botConfig;
604
+ if (!config)
605
+ throw new Error("Bot not configured");
606
+ const { sessionWebhook, log } = options;
607
+ const messageType = config.messageType || "markdown";
608
+ if (messageType === "card") {
609
+ const activeCardId = state.activeCardsByTarget.get(userId);
610
+ if (activeCardId) {
611
+ const activeCard = state.aiCardInstances.get(activeCardId);
612
+ if (activeCard &&
613
+ activeCard.state !== AICardStatus.FINISHED &&
614
+ activeCard.state !== AICardStatus.FAILED) {
615
+ try {
616
+ await streamAICard(state, activeCard, text, false, log);
617
+ return;
618
+ }
619
+ catch (err) {
620
+ log?.warn?.(`[DingTalk] AI Card streaming failed, fallback: ${err.message}`);
621
+ activeCard.state = AICardStatus.FAILED;
622
+ }
623
+ }
624
+ }
625
+ }
626
+ if (sessionWebhook) {
627
+ await sendBySessionWebhook(sessionWebhook, text, log);
628
+ }
629
+ else {
630
+ await sendProactiveMessage(state, config, userId, text, log);
631
+ }
632
+ }
633
+ // ============ Command Handlers ============
634
+ /**
635
+ * 判断 @所有人的消息是否与机器人相关
636
+ * 通过向大模型发送上下文,让其判断
637
+ */
638
+ async function checkIfAtAllMessageIsRelevant(state, conversationId, message, log) {
639
+ if (!state.client)
640
+ return false;
641
+ try {
642
+ const context = buildGroupContext(state, conversationId);
643
+ const prompt = `你是钉钉群聊助手。请判断以下新消息是否与你(AI编程助手)相关。
644
+
645
+ 群聊最近消息:
646
+ ${context}
647
+
648
+ 新消息:${message}
649
+
650
+ 判断标准:
651
+ - 如果消息是针对AI的提问、请求、@或需要AI回复的,返回 "YES"
652
+ - 如果消息是人类之间的普通聊天、与AI无关,返回 "NO"
653
+ - 如果不确定,返回 "NO"
654
+
655
+ 请只回复 "YES" 或 "NO",不要有其他内容。`;
656
+ // 创建一个临时会话来询问
657
+ const sessionId = await state.ensureGroupActiveSession(conversationId);
658
+ if (!sessionId)
659
+ return false;
660
+ // 直接调用 session.promptAsync 获取判断结果
661
+ const response = await state.client.session.promptAsync({
662
+ path: { id: sessionId },
663
+ body: {
664
+ parts: [{ type: "text", text: prompt }],
665
+ },
666
+ });
667
+ // 等待响应(这里简化处理,实际需要等待事件回调)
668
+ // 由于是同步判断,我们用另一种方式:直接让大模型处理
669
+ // 这里我们改用简单策略:如果消息包含编程相关关键词,认为相关
670
+ // 从配置中读取关键词,如果没有配置则使用默认关键词
671
+ const configKeywords = state.botConfig?.atAllKeywords || [];
672
+ const defaultKeywords = [
673
+ "代码",
674
+ "程序",
675
+ "bug",
676
+ "错误",
677
+ "修复",
678
+ "开发",
679
+ "函数",
680
+ "变量",
681
+ "class",
682
+ "function",
683
+ "import",
684
+ "export",
685
+ "def",
686
+ "const",
687
+ "let",
688
+ "python",
689
+ "javascript",
690
+ "java",
691
+ "typescript",
692
+ "go",
693
+ "rust",
694
+ "git",
695
+ "提交",
696
+ "分支",
697
+ "merge",
698
+ "请求",
699
+ "pr",
700
+ "cr",
701
+ "测试",
702
+ "test",
703
+ "单元测试",
704
+ "集成测试",
705
+ "部署",
706
+ "deploy",
707
+ "docker",
708
+ "k8s",
709
+ "kubernetes",
710
+ "api",
711
+ "接口",
712
+ "请求",
713
+ "响应",
714
+ "http",
715
+ "https",
716
+ "数据库",
717
+ "sql",
718
+ "mysql",
719
+ "redis",
720
+ "mongodb",
721
+ "linux",
722
+ "服务器",
723
+ "nginx",
724
+ "apache",
725
+ "编译",
726
+ "build",
727
+ "打包",
728
+ "构建",
729
+ "帮助",
730
+ "怎么",
731
+ "如何",
732
+ "为什么",
733
+ "?",
734
+ ];
735
+ const keywords = configKeywords.length > 0 ? configKeywords : defaultKeywords;
736
+ const lowerMessage = message.toLowerCase();
737
+ for (const keyword of keywords) {
738
+ if (lowerMessage.includes(keyword.toLowerCase())) {
739
+ return true;
740
+ }
741
+ }
742
+ // 默认不响应(保守策略)
743
+ return false;
744
+ }
745
+ catch (error) {
746
+ log?.error?.(`[Group] Error checking relevance: ${error}`);
747
+ return false;
748
+ }
749
+ }
750
+ /**
751
+ * 处理群聊消息
752
+ *
753
+ * 注意:根据钉钉机制,只有 @机器人 的消息才会推送给机器人
754
+ * 因此收到群聊消息就意味着是 @机器人 的消息,直接触发响应
755
+ */
756
+ async function handleGroupMessage(state, data) {
757
+ const text = data.text?.content?.trim();
758
+ if (!text) {
759
+ debugLog("[Group] Empty message, ignored");
760
+ return;
761
+ }
762
+ // 发送者是机器人自己,跳过
763
+ if (data.senderId === data.chatbotUserId ||
764
+ data.senderStaffId === data.chatbotUserId) {
765
+ debugLog("[Group] Message from bot itself, ignored");
766
+ return;
767
+ }
768
+ const conversationId = data.conversationId;
769
+ const senderId = data.senderStaffId || data.senderId;
770
+ const senderName = data.senderNick || "Unknown";
771
+ const sessionWebhook = data.sessionWebhook;
772
+ const conversationType = data.conversationType;
773
+ // 调试日志:记录完整的消息上下文
774
+ debugLog("[Group] Received message:", {
775
+ conversationId,
776
+ conversationType,
777
+ senderId,
778
+ senderName,
779
+ text: text.slice(0, 100),
780
+ hasClient: !!state.client,
781
+ sessionWebhook: sessionWebhook ? "exists" : "MISSING",
782
+ chatbotUserId: data.chatbotUserId,
783
+ });
784
+ logger.log(`[opencode-dingtalk][Group] Message received - conversationId=${conversationId}, sessionWebhook=${sessionWebhook ? "EXISTS" : "MISSING"}`);
785
+ // 追踪用户,区分单聊和群聊的 sessionWebhook
786
+ state.trackUser(senderId, senderName, undefined, conversationId, sessionWebhook);
787
+ // 命令处理(群聊中以 / 开头的消息)
788
+ if (text.startsWith("/")) {
789
+ const parts = text.split(/\s+/);
790
+ const command = parts[0].slice(1).toLowerCase();
791
+ const args = parts.slice(1);
792
+ await handleCommand(state, command, args, senderId, sessionWebhook, {
793
+ conversationId: data.conversationId,
794
+ conversationType: "2",
795
+ });
796
+ return;
797
+ }
798
+ // 检查 OpenCode 客户端是否连接
799
+ if (!state.client) {
800
+ logger.error("[opencode-dingtalk][Group] OpenCode client not connected, cannot process message");
801
+ try {
802
+ if (sessionWebhook) {
803
+ await sendBySessionWebhook(sessionWebhook, "OpenCode 未连接,请确保 OpenCode 服务正常运行", logger);
804
+ }
805
+ }
806
+ catch { }
807
+ return;
808
+ }
809
+ // 根据钉钉机制:收到群聊消息 = @机器人,直接触发响应
810
+ await handleGroupPrompt(state, data, text, sessionWebhook);
811
+ }
812
+ /**
813
+ * 处理群聊中的 AI 提示(@机器人或相关@所有人消息)
814
+ */
815
+ async function handleGroupPrompt(state, data, promptText, sessionWebhook) {
816
+ const conversationId = data.conversationId;
817
+ const senderId = data.senderStaffId || data.senderId;
818
+ const senderName = data.senderNick || "Unknown";
819
+ if (!state.client) {
820
+ try {
821
+ await sendBySessionWebhook(sessionWebhook, "OpenCode is not connected. Please wait...", logger);
822
+ }
823
+ catch { }
824
+ return;
825
+ }
826
+ // 获取或创建群聊会话,并保存 sessionWebhook
827
+ const groupSession = state.getOrCreateGroupSession(conversationId, data.title);
828
+ if (sessionWebhook) {
829
+ groupSession.sessionWebhook = sessionWebhook;
830
+ logger.log(`[opencode-dingtalk][Group] Saved sessionWebhook for group ${conversationId}`);
831
+ }
832
+ else {
833
+ logger.warn(`[opencode-dingtalk][Group] No sessionWebhook provided for group ${conversationId}!`);
834
+ }
835
+ // 调试:打印当前群会话状态
836
+ logger.log(`[opencode-dingtalk][Group] Group session state - conversationId: ${groupSession.conversationId}, activeSessionId: ${groupSession.activeSessionId}, sessionWebhook: ${groupSession.sessionWebhook ? "saved" : "NOT SAVED"}`);
837
+ // 确保有活跃的 OpenCode 会话
838
+ // 如果有 sessionId,验证是否在 Web 端仍然存在
839
+ if (groupSession.activeSessionId) {
840
+ const isValid = await isSessionValid(state.client, groupSession.activeSessionId);
841
+ if (!isValid) {
842
+ logger.log(`[opencode-dingtalk][Group] Session ${groupSession.activeSessionId} no longer exists in Web, creating new one`);
843
+ state.setGroupSessionId(conversationId, null);
844
+ }
845
+ }
846
+ // 重新获取 groupSession(可能已被更新)
847
+ const currentGroupSession = state.groupSessions.get(conversationId);
848
+ const currentSessionId = currentGroupSession?.activeSessionId;
849
+ if (!currentSessionId) {
850
+ // 获取群聊名称
851
+ const groupTitle = groupSession.title || data.title || conversationId;
852
+ try {
853
+ await sendBySessionWebhook(sessionWebhook, "正在创建会话...", logger);
854
+ }
855
+ catch { }
856
+ try {
857
+ logger.log(`[opencode-dingtalk][Group] Creating new session for group ${conversationId}...`);
858
+ const res = await state.client.session.create({
859
+ body: {
860
+ title: `钉钉群聊: ${groupTitle}`,
861
+ },
862
+ });
863
+ if (res.data) {
864
+ state.setGroupSessionId(conversationId, res.data.id);
865
+ await state.saveGroupChatSessionMapToDisk(); // 保存群聊session映射
866
+ logger.log(`[opencode-dingtalk][Group] Created session: ${res.data.id}`);
867
+ }
868
+ else {
869
+ logger.error(`[opencode-dingtalk][Group] Failed to create session: no data in response`);
870
+ }
871
+ }
872
+ catch (err) {
873
+ const errMsg = err instanceof Error ? err.message : String(err);
874
+ logger.error(`[opencode-dingtalk][Group] Failed to create session: ${errMsg}`);
875
+ try {
876
+ await sendBySessionWebhook(sessionWebhook, `创建会话失败: ${errMsg}`, logger);
877
+ }
878
+ catch { }
879
+ return;
880
+ }
881
+ }
882
+ // 重新获取 sessionId
883
+ const finalGroupSession = state.groupSessions.get(conversationId);
884
+ const sessionId = finalGroupSession?.activeSessionId;
885
+ if (!sessionId) {
886
+ return;
887
+ }
888
+ // 如果是 card 模式,创建卡片
889
+ const config = state.botConfig;
890
+ if (config?.messageType === "card") {
891
+ cleanupCardCache(state);
892
+ // 使用 conversationId 作为卡片的目标key
893
+ const existingCardId = state.activeCardsByTarget.get(conversationId);
894
+ const existingCard = existingCardId
895
+ ? state.aiCardInstances.get(existingCardId)
896
+ : undefined;
897
+ if (!existingCard ||
898
+ existingCard.state === AICardStatus.FINISHED ||
899
+ existingCard.state === AICardStatus.FAILED) {
900
+ await createAICard(state, config, conversationId, conversationId, logger);
901
+ }
902
+ }
903
+ // 发送提示给 OpenCode
904
+ try {
905
+ logger.log(`[opencode-dingtalk][Group] Forwarding prompt from ${senderName} to session ${sessionId}: ${promptText.slice(0, 200)}`);
906
+ const activeAgent = state.getActiveAgent();
907
+ const agentModel = activeAgent
908
+ ? await getAgentModel(state.client, activeAgent)
909
+ : null;
910
+ // 在群聊中,添加发送者信息作为上下文
911
+ const fullPrompt = `[群聊消息] 发送者: ${senderName}\n\n${promptText}`;
912
+ await state.client.session.promptAsync({
913
+ path: { id: sessionId },
914
+ body: {
915
+ ...(activeAgent ? { agent: activeAgent } : {}),
916
+ ...(agentModel ? { model: agentModel } : {}),
917
+ parts: [{ type: "text", text: fullPrompt }],
918
+ },
919
+ });
920
+ // 标记有待响应请求
921
+ state.usersWithPendingRequests.add(conversationId);
922
+ }
923
+ catch (error) {
924
+ const errMsg = error instanceof Error ? error.message : "Unknown error";
925
+ try {
926
+ await sendBySessionWebhook(sessionWebhook, `Error: ${errMsg}`, logger);
927
+ }
928
+ catch { }
929
+ }
930
+ }
931
+ /**
932
+ * 根据 userId 查找并解除 session 绑定
933
+ */
934
+ function unbindSessionByUserId(state, userId) {
935
+ for (const [sessionId, uid] of state.sessionToUserMap.entries()) {
936
+ if (uid === userId) {
937
+ state.sessionToUserMap.delete(sessionId);
938
+ break;
939
+ }
940
+ }
941
+ }
942
+ /**
943
+ * 处理单聊用户创建新 Session
944
+ */
945
+ async function handleNewSingleChatSession(state, userId, reply) {
946
+ // 清除旧 session
947
+ state.setUserSessionId(userId, null);
948
+ unbindSessionByUserId(state, userId);
949
+ // 获取用户名
950
+ const user = state.activeUsers.get(userId);
951
+ const userName = user?.username || userId;
952
+ if (!state.client) {
953
+ await reply("OpenCode 未连接,无法创建新会话。");
954
+ return;
955
+ }
956
+ try {
957
+ // 创建新 session
958
+ const res = await state.client.session.create({
959
+ body: {
960
+ title: `钉钉用户: ${userName} (${userId})`,
961
+ },
962
+ });
963
+ if (res.data?.id) {
964
+ const newSessionId = res.data.id;
965
+ state.setUserSessionId(userId, newSessionId);
966
+ state.bindSessionToUser(newSessionId, userId);
967
+ // 持久化
968
+ await state.saveSingleChatSessionMapToDisk();
969
+ state.saveUsersToDisk();
970
+ await reply(`✅ 已创建新会话: ${newSessionId.slice(0, 8)}...\n\n之前的会话已结束,现在可以开始新对话了。`);
971
+ logger.log(`[opencode-dingtalk] Created new session ${newSessionId} for user ${userId}`);
972
+ }
973
+ else {
974
+ await reply("❌ 创建新会话失败:响应中无 session ID");
975
+ }
976
+ }
977
+ catch (err) {
978
+ await reply(`❌ 创建新会话失败: ${err.message}`);
979
+ logger.error(`[opencode-dingtalk] Failed to create new session for user ${userId}: ${err}`);
980
+ }
981
+ }
982
+ /**
983
+ * 处理群聊创建新 Session
984
+ */
985
+ async function handleNewGroupSession(state, conversationId, reply) {
986
+ // 清除旧 session
987
+ state.setGroupSessionId(conversationId, null);
988
+ // 获取群聊信息
989
+ const groupSession = state.groupSessions.get(conversationId);
990
+ const groupTitle = groupSession?.title || conversationId;
991
+ if (!state.client) {
992
+ await reply("OpenCode 未连接,无法创建新会话。");
993
+ return;
994
+ }
995
+ try {
996
+ // 创建新 session
997
+ const res = await state.client.session.create({
998
+ body: {
999
+ title: `钉钉群聊: ${groupTitle}`,
1000
+ },
1001
+ });
1002
+ if (res.data?.id) {
1003
+ const newSessionId = res.data.id;
1004
+ state.setGroupSessionId(conversationId, newSessionId);
1005
+ // 持久化
1006
+ await state.saveGroupChatSessionMapToDisk();
1007
+ await reply(`✅ 已创建新会话: ${newSessionId.slice(0, 8)}...\n\n之前的会话已结束,现在可以开始新对话了。`);
1008
+ logger.log(`[opencode-dingtalk] Created new session ${newSessionId} for group ${conversationId}`);
1009
+ }
1010
+ else {
1011
+ await reply("❌ 创建新会话失败:响应中无 session ID");
1012
+ }
1013
+ }
1014
+ catch (err) {
1015
+ await reply(`❌ 创建新会话失败: ${err.message}`);
1016
+ logger.error(`[opencode-dingtalk] Failed to create new session for group ${conversationId}: ${err}`);
1017
+ }
1018
+ }
1019
+ /**
1020
+ * 处理 /new session 命令
1021
+ */
1022
+ async function handleNewSession(state, senderId, conversationId, conversationType, sessionWebhook) {
1023
+ const log = logger;
1024
+ const reply = async (text) => {
1025
+ try {
1026
+ await sendMessageToUser(state, senderId, text, { sessionWebhook, log });
1027
+ }
1028
+ catch (err) {
1029
+ logger.error(`[opencode-dingtalk] Failed to reply: ${err.message}`);
1030
+ }
1031
+ };
1032
+ // 检查 OpenCode 客户端
1033
+ if (!state.client) {
1034
+ await reply("OpenCode 未连接,无法创建新会话。");
1035
+ return;
1036
+ }
1037
+ // Step 1: 中断当前会话(如果正在执行)
1038
+ try {
1039
+ await state.client.tui.executeCommand({
1040
+ body: { command: "session_interrupt" },
1041
+ });
1042
+ logger.log(`[opencode-dingtalk] Interrupted session for ${conversationType === "1" ? "user" : "group"} ${conversationId}`);
1043
+ }
1044
+ catch (err) {
1045
+ // 中断失败不影响后续创建新 session
1046
+ logger.warn(`[opencode-dingtalk] Interrupt failed: ${err}`);
1047
+ }
1048
+ // Step 2: 根据会话类型创建新 Session
1049
+ if (conversationType === "1") {
1050
+ // 单聊:重建用户 Session
1051
+ await handleNewSingleChatSession(state, senderId, reply);
1052
+ }
1053
+ else if (conversationType === "2") {
1054
+ // 群聊:重建群聊 Session
1055
+ await handleNewGroupSession(state, conversationId, reply);
1056
+ }
1057
+ }
1058
+ async function handleCommand(state, command, args, senderId, sessionWebhook, context) {
1059
+ const log = logger;
1060
+ const reply = async (text) => {
1061
+ try {
1062
+ await sendMessageToUser(state, senderId, text, { sessionWebhook, log });
1063
+ }
1064
+ catch (err) {
1065
+ logger.error(`[opencode-dingtalk] Failed to reply: ${err.message}`);
1066
+ }
1067
+ };
1068
+ switch (command) {
1069
+ case "help": {
1070
+ await reply([
1071
+ "OpenCode DingTalk 命令:",
1072
+ "- /status: 查看连接状态和会话信息",
1073
+ "- /health: 查看系统健康状态",
1074
+ "- /session list: 列出会话",
1075
+ "- /session use <n|id>: 切换会话",
1076
+ "- /session new: 创建新会话",
1077
+ "- /new session: 为当前单聊或群聊创建新会话",
1078
+ "- /approve <id> once|always|reject: 处理权限请求",
1079
+ "- /answer <id> <答案>: 回答 Agent 问题",
1080
+ "- /agent cycle: 切换 Agent",
1081
+ "- /agent list: 列出所有可用 Agent",
1082
+ "- /agent info: 查看当前 Agent 详情",
1083
+ "- /interrupt: 中断当前会话",
1084
+ "- /prompt clear: 清除提示",
1085
+ "- /prompt submit: 提交提示",
1086
+ "- /mute: 减少通知(仅结果+错误+权限)",
1087
+ "- /unmute: 恢复通知",
1088
+ "- /notify <minimal|normal|verbose>: 设置通知级别",
1089
+ "",
1090
+ "发送普通文本消息将作为 AI 提示发送给 OpenCode。",
1091
+ ].join("\n"));
1092
+ return;
1093
+ }
1094
+ case "status": {
1095
+ const connected = state.client ? "Connected" : "Not connected";
1096
+ const sessionShort = formatSessionShort(state.activeSessionId);
1097
+ const status = state.sessionStatus
1098
+ ? state.sessionStatus.status === "retry" && state.sessionStatus.retry
1099
+ ? `retry (attempt=${state.sessionStatus.retry.attempt})`
1100
+ : state.sessionStatus.status
1101
+ : "unknown";
1102
+ const todos = state.activeSessionId
1103
+ ? state.sessionTodos.get(state.activeSessionId)
1104
+ : undefined;
1105
+ const todoLine = todos && todos.length > 0 ? formatTodosLine(todos) : "none";
1106
+ const pendingPermCount = state.pendingPermissions.size;
1107
+ const pendingQuestionsCount = state.pendingQuestions.size;
1108
+ const activeUserCount = state.activeUsers.size;
1109
+ const notifyLevel = state.botConfig?.notifyLevel || "normal";
1110
+ const msgType = state.botConfig?.messageType || "markdown";
1111
+ // 获取连接状态信息
1112
+ let connectionState = "N/A";
1113
+ let connectionUptime = "N/A";
1114
+ let reconnectCount = 0;
1115
+ let lastError = "None";
1116
+ if (state.connectionManager) {
1117
+ const metrics = state.connectionManager.getMetrics();
1118
+ connectionState = state.connectionManager.getState();
1119
+ // 计算连接时长
1120
+ if (metrics.connectedAt) {
1121
+ const uptimeMs = Date.now() - metrics.connectedAt;
1122
+ connectionUptime = formatDuration(uptimeMs);
1123
+ }
1124
+ else if (metrics.totalConnectedMs > 0) {
1125
+ connectionUptime = formatDuration(metrics.totalConnectedMs);
1126
+ }
1127
+ reconnectCount = metrics.reconnectAttempts;
1128
+ lastError = metrics.lastError || "None";
1129
+ }
1130
+ // 获取队列状态信息
1131
+ let queuePending = 0;
1132
+ let queueSending = 0;
1133
+ let queueFailed = 0;
1134
+ let oldestMessageAge = "N/A";
1135
+ if (state.messageQueue) {
1136
+ const inboundStats = state.messageQueue.getInboundStats();
1137
+ const outboundStats = state.messageQueue.getOutboundStats();
1138
+ queuePending = inboundStats.pending + outboundStats.pending;
1139
+ queueSending = inboundStats.processing + outboundStats.processing;
1140
+ queueFailed = inboundStats.failed + outboundStats.failed;
1141
+ const oldestInbound = inboundStats.oldestPendingAge;
1142
+ const oldestOutbound = outboundStats.oldestPendingAge;
1143
+ const oldest = Math.max(oldestInbound, oldestOutbound);
1144
+ if (oldest > 0) {
1145
+ oldestMessageAge = formatDuration(oldest);
1146
+ }
1147
+ }
1148
+ // 获取会话信息
1149
+ const activeSessions = state.groupSessions.size + (state.activeSessionId ? 1 : 0);
1150
+ const groupSessions = state.groupSessions.size;
1151
+ const statusLines = [
1152
+ `📊 **系统状态**`,
1153
+ ``,
1154
+ `**连接状态**`,
1155
+ `- 状态: ${connectionState}`,
1156
+ `- 连接时长: ${connectionUptime}`,
1157
+ `- 重连次数: ${reconnectCount}`,
1158
+ `- 最后错误: ${lastError}`,
1159
+ ``,
1160
+ `**消息队列**`,
1161
+ `- 待处理: ${queuePending}`,
1162
+ `- 发送中: ${queueSending}`,
1163
+ `- 失败: ${queueFailed}`,
1164
+ `- 最老消息: ${oldestMessageAge}`,
1165
+ ``,
1166
+ `**会话信息**`,
1167
+ `- 活跃用户: ${activeUserCount}`,
1168
+ `- 活跃会话: ${activeSessions}`,
1169
+ `- 群聊会话: ${groupSessions}`,
1170
+ ``,
1171
+ `**OpenCode 状态**`,
1172
+ `- 连接: ${connected}`,
1173
+ `- 会话: ${sessionShort}`,
1174
+ `- 状态: ${status}`,
1175
+ `- Todos: ${todoLine}`,
1176
+ `- 待审批: ${pendingPermCount}`,
1177
+ ];
1178
+ if (pendingQuestionsCount > 0) {
1179
+ statusLines.push(`- 待回答问题: ${pendingQuestionsCount}`);
1180
+ }
1181
+ statusLines.push(`- 通知: ${notifyLevel} | 消息: ${msgType}`);
1182
+ await reply(statusLines.join("\n"));
1183
+ return;
1184
+ }
1185
+ case "health": {
1186
+ // 检查连接管理器状态
1187
+ let isHealthy = true;
1188
+ let healthMessage = "";
1189
+ if (state.connectionManager) {
1190
+ const connectionState = state.connectionManager.getState();
1191
+ const metrics = state.connectionManager.getMetrics();
1192
+ if (connectionState === ConnectionState.DISCONNECTED) {
1193
+ isHealthy = false;
1194
+ healthMessage = `❌ 连接已断开: ${metrics.lastError || "未知错误"}`;
1195
+ }
1196
+ else if (connectionState === ConnectionState.RECONNECTING) {
1197
+ isHealthy = false;
1198
+ healthMessage = `⚠️ 正在重连中 (尝试次数: ${metrics.currentReconnectCycle})`;
1199
+ }
1200
+ else if (connectionState === ConnectionState.CONNECTED) {
1201
+ healthMessage = "✅ 连接正常";
1202
+ }
1203
+ else {
1204
+ healthMessage = `⚠️ 连接状态: ${connectionState}`;
1205
+ }
1206
+ }
1207
+ else {
1208
+ // 如果没有连接管理器,检查基本连接状态
1209
+ if (state.client) {
1210
+ healthMessage = "✅ OpenCode 已连接";
1211
+ }
1212
+ else {
1213
+ isHealthy = false;
1214
+ healthMessage = "❌ OpenCode 未连接";
1215
+ }
1216
+ }
1217
+ // 检查消息队列状态
1218
+ if (state.messageQueue) {
1219
+ const inboundStats = state.messageQueue.getInboundStats();
1220
+ const outboundStats = state.messageQueue.getOutboundStats();
1221
+ const totalFailed = inboundStats.failed + outboundStats.failed;
1222
+ if (totalFailed > 0) {
1223
+ isHealthy = false;
1224
+ healthMessage += `\n❌ 有 ${totalFailed} 条消息处理失败`;
1225
+ }
1226
+ const totalPending = inboundStats.pending + outboundStats.pending;
1227
+ if (totalPending > 10) {
1228
+ healthMessage += `\n⚠️ 队列中有 ${totalPending} 条消息待处理`;
1229
+ }
1230
+ }
1231
+ // 如果没有异常,返回正常状态
1232
+ if (isHealthy) {
1233
+ await reply("✅ 系统正常运行");
1234
+ }
1235
+ else {
1236
+ await reply(healthMessage);
1237
+ }
1238
+ return;
1239
+ }
1240
+ case "session": {
1241
+ const sub = args[0];
1242
+ if (!state.client) {
1243
+ await reply("Not connected to OpenCode.");
1244
+ return;
1245
+ }
1246
+ if (sub === "list") {
1247
+ try {
1248
+ const res = await state.client.session.list({});
1249
+ const sessions = res.data || [];
1250
+ if (sessions.length === 0) {
1251
+ await reply("No sessions.");
1252
+ return;
1253
+ }
1254
+ const lines = sessions.map((s, i) => {
1255
+ const active = s.id === state.activeSessionId;
1256
+ const marker = active ? "▶" : " ";
1257
+ const title = s.title || "(untitled)";
1258
+ const date = new Date(s.time.created).toLocaleString("zh-CN", {
1259
+ timeZone: "Asia/Shanghai",
1260
+ month: "2-digit",
1261
+ day: "2-digit",
1262
+ hour: "2-digit",
1263
+ minute: "2-digit",
1264
+ });
1265
+ const stats = s.summary
1266
+ ? ` +${s.summary.additions}/-${s.summary.deletions} ${s.summary.files}files`
1267
+ : "";
1268
+ return `${marker}${i + 1}. **${title}**${stats}\n ${s.id.slice(0, 8)}... | ${date}`;
1269
+ });
1270
+ await reply(`**Sessions (${sessions.length})**\n\n${lines.join("\n")}`);
1271
+ }
1272
+ catch (err) {
1273
+ await reply(`Error: ${err.message}`);
1274
+ }
1275
+ return;
1276
+ }
1277
+ if (sub === "use") {
1278
+ const target = args[1];
1279
+ if (!target) {
1280
+ await reply("Usage: /session use <n|sessionId>");
1281
+ return;
1282
+ }
1283
+ try {
1284
+ const res = await state.client.session.list({});
1285
+ const sessions = res.data || [];
1286
+ if (sessions.length === 0) {
1287
+ await reply("No sessions available.");
1288
+ return;
1289
+ }
1290
+ let id;
1291
+ const n = Number(target);
1292
+ if (Number.isFinite(n)) {
1293
+ if (n >= 1 && n <= sessions.length) {
1294
+ id = sessions[n - 1]?.id;
1295
+ }
1296
+ else {
1297
+ await reply(`Invalid session number. Use 1-${sessions.length}.`);
1298
+ return;
1299
+ }
1300
+ }
1301
+ else {
1302
+ const normalized = target.toLowerCase();
1303
+ id = sessions.find((s) => s.id.toLowerCase() === normalized ||
1304
+ s.id.toLowerCase().startsWith(normalized))?.id;
1305
+ }
1306
+ if (!id) {
1307
+ await reply("Session not found.");
1308
+ return;
1309
+ }
1310
+ state.activeSessionId = id;
1311
+ await reply(`Switched to session ${formatSessionShort(id)}`);
1312
+ }
1313
+ catch (err) {
1314
+ await reply(`Error: ${err.message}`);
1315
+ }
1316
+ return;
1317
+ }
1318
+ if (sub === "new") {
1319
+ try {
1320
+ const res = await state.client.session.create({});
1321
+ if (res.data?.id) {
1322
+ state.activeSessionId = res.data.id;
1323
+ await reply(`Created new session: ${formatSessionShort(res.data.id)}`);
1324
+ }
1325
+ else {
1326
+ await reply("Failed to create session.");
1327
+ }
1328
+ }
1329
+ catch (err) {
1330
+ await reply(`Error: ${err.message}`);
1331
+ }
1332
+ return;
1333
+ }
1334
+ await reply("Usage: /session list | /session use <n|id> | /session new");
1335
+ return;
1336
+ }
1337
+ case "approve": {
1338
+ const prefix = args[0];
1339
+ const decision = args[1];
1340
+ if (!prefix || !decision) {
1341
+ await reply("Usage: /approve <permissionId> once|always|reject");
1342
+ return;
1343
+ }
1344
+ const permissionId = resolvePermissionId(state, prefix);
1345
+ if (!permissionId) {
1346
+ await reply("Permission not found. Use /status to see pending permissions.");
1347
+ return;
1348
+ }
1349
+ const perm = state.pendingPermissions.get(permissionId);
1350
+ // 优先使用权限请求中记录的 sessionID,如果没有则使用当前用户的 session
1351
+ const sessionId = perm?.sessionID || (await ensureActiveSession(state, senderId));
1352
+ if (!state.client || !sessionId) {
1353
+ await reply("Not connected to OpenCode.");
1354
+ return;
1355
+ }
1356
+ if (decision !== "once" &&
1357
+ decision !== "always" &&
1358
+ decision !== "reject") {
1359
+ await reply("Usage: /approve <permissionId> once|always|reject");
1360
+ return;
1361
+ }
1362
+ try {
1363
+ const res = await state.client.postSessionIdPermissionsPermissionId({
1364
+ path: { id: sessionId, permissionID: permissionId },
1365
+ body: { response: decision },
1366
+ });
1367
+ if (res.data) {
1368
+ state.pendingPermissions.delete(permissionId);
1369
+ await reply(`Approved (${decision}) for ${permissionId.slice(0, 8)}...`);
1370
+ }
1371
+ else {
1372
+ await reply("Failed to approve permission.");
1373
+ }
1374
+ }
1375
+ catch (err) {
1376
+ await reply(`Error: ${err.message}`);
1377
+ }
1378
+ return;
1379
+ }
1380
+ case "answer": {
1381
+ const questionIdPrefix = args[0];
1382
+ const answerArgs = args.slice(1);
1383
+ if (!questionIdPrefix || answerArgs.length === 0) {
1384
+ await reply("Usage: /answer <questionId> <answer1> [answer2] ...\n示例: /answer abc123 1 或 /answer abc123 1,2,3 或 /answer abc123 自定义答案");
1385
+ return;
1386
+ }
1387
+ // Resolve question ID (支持前缀匹配)
1388
+ let questionId;
1389
+ const pending = Array.from(state.pendingQuestions.values());
1390
+ if (pending.length === 1 && questionIdPrefix.length < 8) {
1391
+ questionId = pending[0].id;
1392
+ }
1393
+ else {
1394
+ const match = pending.find((q) => q.id.startsWith(questionIdPrefix));
1395
+ questionId = match?.id;
1396
+ }
1397
+ if (!questionId) {
1398
+ await reply("Question not found. Use /status to see pending questions.");
1399
+ return;
1400
+ }
1401
+ const question = state.pendingQuestions.get(questionId);
1402
+ if (!question) {
1403
+ await reply("Question not found.");
1404
+ return;
1405
+ }
1406
+ // 优先使用问题中记录的 sessionID,如果没有则使用当前用户的 session
1407
+ const sessionId = question.sessionID || (await ensureActiveSession(state, senderId));
1408
+ if (!state.client || !sessionId) {
1409
+ await reply("Not connected to OpenCode.");
1410
+ return;
1411
+ }
1412
+ try {
1413
+ // 解析答案
1414
+ const answers = [];
1415
+ for (let i = 0; i < question.questions.length; i++) {
1416
+ const q = question.questions[i];
1417
+ const answerText = answerArgs[i] || "";
1418
+ if (!answerText) {
1419
+ await reply(`错误: 缺少第 ${i + 1} 个问题的答案`);
1420
+ return;
1421
+ }
1422
+ // 检查是否是选项序号(支持逗号分隔的多选)
1423
+ const parts = answerText.split(",").map((s) => s.trim());
1424
+ const isAllNumbers = parts.every((p) => /^\d+$/.test(p));
1425
+ if (isAllNumbers) {
1426
+ // 数字选项
1427
+ const selectedLabels = [];
1428
+ for (const numStr of parts) {
1429
+ const idx = parseInt(numStr, 10) - 1;
1430
+ if (idx < 0 || idx >= q.options.length) {
1431
+ await reply(`错误: 选项 ${numStr} 不存在(有效范围: 1-${q.options.length})`);
1432
+ return;
1433
+ }
1434
+ selectedLabels.push(q.options[idx].label);
1435
+ }
1436
+ // 检查多选限制
1437
+ if (!q.multiple && selectedLabels.length > 1) {
1438
+ await reply(`错误: 问题 "${q.header}" 不支持多选`);
1439
+ return;
1440
+ }
1441
+ answers.push(selectedLabels);
1442
+ }
1443
+ else {
1444
+ // 自定义文本答案
1445
+ if (q.custom === false) {
1446
+ await reply(`错误: 问题 "${q.header}" 不支持自定义答案,请选择选项序号`);
1447
+ return;
1448
+ }
1449
+ // 如果是多个文本部分,合并为一个答案
1450
+ answers.push([parts.join(",")]);
1451
+ }
1452
+ }
1453
+ // 使用 HTTP 客户端直接调用 API(因为 SDK 可能版本不匹配)
1454
+ const url = `/question/${questionId}/reply`;
1455
+ const res = await state.client.client.post({
1456
+ url,
1457
+ body: { answers },
1458
+ headers: { "Content-Type": "application/json" },
1459
+ });
1460
+ if (res.data) {
1461
+ state.pendingQuestions.delete(questionId);
1462
+ await reply(`已回答问题 ${questionId.slice(0, 8)}...`);
1463
+ }
1464
+ else {
1465
+ await reply("Failed to answer question.");
1466
+ }
1467
+ }
1468
+ catch (err) {
1469
+ await reply(`Error: ${err.message}`);
1470
+ }
1471
+ return;
1472
+ }
1473
+ case "agent": {
1474
+ const sub = args[0];
1475
+ if (!state.client) {
1476
+ await reply("Not connected to OpenCode.");
1477
+ return;
1478
+ }
1479
+ if (sub === "cycle") {
1480
+ try {
1481
+ const primaryAgents = await getPrimaryAgentNames(state.client);
1482
+ if (primaryAgents.length === 0) {
1483
+ await reply("No available agents.");
1484
+ return;
1485
+ }
1486
+ const res = await state.client.tui.executeCommand({
1487
+ body: { command: "agent_cycle" },
1488
+ });
1489
+ if (!res.data) {
1490
+ await reply("Failed to cycle agent.");
1491
+ return;
1492
+ }
1493
+ const next = await cycleAgentName(state, state.client, primaryAgents, state.getActiveAgent());
1494
+ if (!next) {
1495
+ await reply("Failed to cycle agent.");
1496
+ return;
1497
+ }
1498
+ state.setActiveAgent(next);
1499
+ const model = await getAgentModel(state.client, next);
1500
+ const modelStr = model
1501
+ ? `${model.providerID}/${model.modelID}`
1502
+ : "default";
1503
+ await reply(`Agent → **${next}** (${modelStr})`);
1504
+ }
1505
+ catch (err) {
1506
+ await reply(`Error: ${err.message}`);
1507
+ }
1508
+ return;
1509
+ }
1510
+ if (sub === "list") {
1511
+ try {
1512
+ const res = await state.client.app.agents();
1513
+ const agents = (res.data ?? []).filter((a) => a.mode !== "subagent" && !a.hidden);
1514
+ if (agents.length === 0) {
1515
+ await reply("No available agents.");
1516
+ return;
1517
+ }
1518
+ const current = state.getActiveAgent();
1519
+ const lines = agents.map((a) => {
1520
+ const marker = a.name === current ? " ← current" : "";
1521
+ const model = a.model
1522
+ ? `${a.model.providerID}/${a.model.modelID}`
1523
+ : "default";
1524
+ return `- **${a.name}** (${model})${marker}`;
1525
+ });
1526
+ await reply(`**Agents (${agents.length})**\n\n${lines.join("\n")}`);
1527
+ }
1528
+ catch (err) {
1529
+ await reply(`Error: ${err.message}`);
1530
+ }
1531
+ return;
1532
+ }
1533
+ if (sub === "info") {
1534
+ try {
1535
+ const current = state.getActiveAgent();
1536
+ if (!current) {
1537
+ await reply("No active agent. Start a session first.");
1538
+ return;
1539
+ }
1540
+ const res = await state.client.app.agents();
1541
+ const agent = (res.data ?? []).find((a) => a.name === current);
1542
+ if (!agent) {
1543
+ await reply(`Agent **${current}** not found.`);
1544
+ return;
1545
+ }
1546
+ const model = agent.model
1547
+ ? `${agent.model.providerID}/${agent.model.modelID}`
1548
+ : "default";
1549
+ const desc = agent.description || "N/A";
1550
+ const mode = agent.mode;
1551
+ const info = [
1552
+ `**Agent: ${agent.name}**`,
1553
+ "",
1554
+ `- **Description:** ${desc}`,
1555
+ `- **Model:** ${model}`,
1556
+ `- **Mode:** ${mode}`,
1557
+ ];
1558
+ await reply(info.join("\n"));
1559
+ }
1560
+ catch (err) {
1561
+ await reply(`Error: ${err.message}`);
1562
+ }
1563
+ return;
1564
+ }
1565
+ await reply("Usage: /agent cycle | list | info");
1566
+ return;
1567
+ }
1568
+ case "interrupt": {
1569
+ if (!state.client) {
1570
+ await reply("Not connected to OpenCode.");
1571
+ return;
1572
+ }
1573
+ try {
1574
+ await state.client.tui.executeCommand({
1575
+ body: { command: "session_interrupt" },
1576
+ });
1577
+ await state.client.tui.executeCommand({
1578
+ body: { command: "session_interrupt" },
1579
+ });
1580
+ await reply("Session interrupted.");
1581
+ }
1582
+ catch (err) {
1583
+ await reply(`Error: ${err.message}`);
1584
+ }
1585
+ return;
1586
+ }
1587
+ case "prompt": {
1588
+ const sub = args[0];
1589
+ if (!state.client) {
1590
+ await reply("Not connected to OpenCode.");
1591
+ return;
1592
+ }
1593
+ if (sub === "clear") {
1594
+ try {
1595
+ const res = await state.client.tui.clearPrompt({});
1596
+ await reply(res.data ? "Prompt cleared." : "Failed to clear prompt.");
1597
+ }
1598
+ catch (err) {
1599
+ await reply(`Error: ${err.message}`);
1600
+ }
1601
+ return;
1602
+ }
1603
+ if (sub === "submit") {
1604
+ try {
1605
+ const res = await state.client.tui.submitPrompt({});
1606
+ await reply(res.data ? "Prompt submitted." : "Failed to submit prompt.");
1607
+ }
1608
+ catch (err) {
1609
+ await reply(`Error: ${err.message}`);
1610
+ }
1611
+ return;
1612
+ }
1613
+ await reply("Usage: /prompt clear | /prompt submit");
1614
+ return;
1615
+ }
1616
+ case "mute": {
1617
+ if (!state.botConfig) {
1618
+ await reply("Bot not configured.");
1619
+ return;
1620
+ }
1621
+ state.botConfig.notifyLevel = "minimal";
1622
+ await reply("Notifications muted (minimal). Only results, errors and permissions will be sent.\nUse /unmute to restore.");
1623
+ return;
1624
+ }
1625
+ case "unmute": {
1626
+ if (!state.botConfig) {
1627
+ await reply("Bot not configured.");
1628
+ return;
1629
+ }
1630
+ state.botConfig.notifyLevel = "normal";
1631
+ await reply("Notifications restored (normal).");
1632
+ return;
1633
+ }
1634
+ case "notify": {
1635
+ if (!state.botConfig) {
1636
+ await reply("Bot not configured.");
1637
+ return;
1638
+ }
1639
+ const level = args[0];
1640
+ if (level === "minimal" || level === "normal" || level === "verbose") {
1641
+ state.botConfig.notifyLevel = level;
1642
+ const desc = {
1643
+ minimal: "Only AI response, errors, permissions",
1644
+ normal: "Above + todo progress (debounced)",
1645
+ verbose: "Above + tool outputs, commands, status",
1646
+ };
1647
+ await reply(`Notify level: ${level}\n${desc[level]}`);
1648
+ }
1649
+ else {
1650
+ await reply("Usage: /notify minimal|normal|verbose\n\nCurrent: " +
1651
+ (state.botConfig.notifyLevel || "normal"));
1652
+ }
1653
+ return;
1654
+ }
1655
+ case "new": {
1656
+ const sub = args[0];
1657
+ if (sub === "session") {
1658
+ // 需要判断是单聊还是群聊
1659
+ if (context?.conversationId && context?.conversationType) {
1660
+ await handleNewSession(state, senderId, context.conversationId, context.conversationType, sessionWebhook);
1661
+ return;
1662
+ }
1663
+ else {
1664
+ await reply("无法确定会话类型,请稍后重试。");
1665
+ return;
1666
+ }
1667
+ }
1668
+ await reply("Usage: /new session");
1669
+ return;
1670
+ }
1671
+ default: {
1672
+ await reply(`Unknown command: /${command}\nUse /help to see available commands.`);
1673
+ return;
1674
+ }
1675
+ }
1676
+ }
1677
+ // ============ Inbound Message Handler ============
1678
+ async function handleInboundMessage(state, data) {
1679
+ // 忽略机器人自己发送的消息
1680
+ if (data.senderId === data.chatbotUserId ||
1681
+ data.senderStaffId === data.chatbotUserId) {
1682
+ return;
1683
+ }
1684
+ // 结构化日志:记录入站消息
1685
+ const messageId = data.msgId || `msg_${Date.now()}`;
1686
+ const senderId = data.senderStaffId || data.senderId;
1687
+ // 发送emoji确认消息,让用户知道消息已被机器人接收
1688
+ try {
1689
+ if (data.sessionWebhook) {
1690
+ await sendBySessionWebhook(data.sessionWebhook, "👌", undefined, undefined, state, state.botConfig || undefined);
1691
+ logger.log(`[opencode-dingtalk][EmojiConfirm] Sent emoji confirmation to ${senderId}`);
1692
+ }
1693
+ }
1694
+ catch (err) {
1695
+ logger.error(`[opencode-dingtalk][EmojiConfirm] Failed to send emoji confirmation: ${err.message}`);
1696
+ }
1697
+ logger.log(JSON.stringify({
1698
+ type: "message",
1699
+ event: "inbound_received",
1700
+ timestamp: Date.now(),
1701
+ msgId: messageId,
1702
+ userId: senderId,
1703
+ conversationType: data.conversationType === "1" ? "single" : "group",
1704
+ conversationId: data.conversationId,
1705
+ }));
1706
+ // 如果有消息队列,将消息入队
1707
+ if (state.messageQueue) {
1708
+ state.messageQueue.enqueue({
1709
+ type: QueueType.INBOUND,
1710
+ content: JSON.stringify(data),
1711
+ target: senderId,
1712
+ maxRetries: 3,
1713
+ metadata: {
1714
+ messageId: messageId,
1715
+ conversationType: data.conversationType,
1716
+ conversationId: data.conversationId,
1717
+ },
1718
+ });
1719
+ }
1720
+ // 根据会话类型分别处理
1721
+ if (data.conversationType === "1") {
1722
+ // 单聊处理
1723
+ await handleSingleChatMessage(state, data);
1724
+ }
1725
+ else if (data.conversationType === "2") {
1726
+ // 群聊处理
1727
+ await handleGroupMessage(state, data);
1728
+ }
1729
+ else {
1730
+ logger.log(`[opencode-dingtalk] Ignoring unknown conversation type: ${data.conversationType}`);
1731
+ }
1732
+ }
1733
+ /**
1734
+ * 处理单聊消息 - 每个用户有独立的 session
1735
+ */
1736
+ async function handleSingleChatMessage(state, data) {
1737
+ const text = data.text?.content?.trim();
1738
+ if (!text)
1739
+ return;
1740
+ const senderId = data.senderStaffId || data.senderId;
1741
+ const senderName = data.senderNick || "Unknown";
1742
+ const sessionWebhook = data.sessionWebhook;
1743
+ // 记录用户信息,包含 sessionWebhook
1744
+ state.trackUser(senderId, senderName, sessionWebhook, data.conversationId);
1745
+ // 命令处理
1746
+ if (text.startsWith("/")) {
1747
+ const parts = text.split(/\s+/);
1748
+ const command = parts[0].slice(1).toLowerCase();
1749
+ const args = parts.slice(1);
1750
+ await handleCommand(state, command, args, senderId, sessionWebhook, {
1751
+ conversationId: data.conversationId,
1752
+ conversationType: "1",
1753
+ });
1754
+ return;
1755
+ }
1756
+ if (!state.client) {
1757
+ try {
1758
+ await sendMessageToUser(state, senderId, "OpenCode is not connected. Please wait...", { sessionWebhook });
1759
+ }
1760
+ catch { }
1761
+ return;
1762
+ }
1763
+ // 获取用户独立的 session,如不存在则创建
1764
+ let userSessionId = state.getUserSessionId(senderId);
1765
+ // 如果有 sessionId,验证是否在 Web 端仍然存在
1766
+ if (userSessionId) {
1767
+ const isValid = await isSessionValid(state.client, userSessionId);
1768
+ if (!isValid) {
1769
+ logger.log(`[opencode-dingtalk] Session ${userSessionId} no longer exists in Web, creating new one`);
1770
+ userSessionId = null;
1771
+ state.setUserSessionId(senderId, null);
1772
+ }
1773
+ }
1774
+ if (!userSessionId) {
1775
+ try {
1776
+ await sendMessageToUser(state, senderId, "正在创建会话...", {
1777
+ sessionWebhook,
1778
+ });
1779
+ }
1780
+ catch { }
1781
+ // Session 创建重试机制:3秒、5秒、10秒
1782
+ const retryDelays = [3000, 5000, 10000];
1783
+ let sessionCreated = false;
1784
+ for (let attempt = 0; attempt < retryDelays.length; attempt++) {
1785
+ try {
1786
+ logger.log(`[opencode-dingtalk] Creating new session for user ${senderId} (attempt ${attempt + 1}/${retryDelays.length})...`);
1787
+ const res = await state.client.session.create({
1788
+ body: {
1789
+ title: `钉钉用户: ${senderName} (${senderId})`,
1790
+ },
1791
+ });
1792
+ if (res.data?.id) {
1793
+ userSessionId = res.data.id;
1794
+ state.setUserSessionId(senderId, userSessionId);
1795
+ logger.log(`[opencode-dingtalk] Created new session ${userSessionId} for user ${senderId} (attempt ${attempt + 1})`);
1796
+ sessionCreated = true;
1797
+ break;
1798
+ }
1799
+ else {
1800
+ logger.warn(`[opencode-dingtalk] Session creation returned no data (attempt ${attempt + 1}/${retryDelays.length})`);
1801
+ }
1802
+ }
1803
+ catch (err) {
1804
+ const errMsg = err instanceof Error ? err.message : String(err);
1805
+ logger.warn(`[opencode-dingtalk] Session creation failed (attempt ${attempt + 1}/${retryDelays.length}): ${errMsg}`);
1806
+ }
1807
+ // 如果不是最后一次尝试,等待后重试
1808
+ if (attempt < retryDelays.length - 1) {
1809
+ logger.log(`[opencode-dingtalk] Retrying in ${retryDelays[attempt]}ms...`);
1810
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
1811
+ }
1812
+ }
1813
+ // 所有重试都失败
1814
+ if (!sessionCreated) {
1815
+ logger.error(`[opencode-dingtalk] Failed to create session for user ${senderId} after ${retryDelays.length} attempts`);
1816
+ try {
1817
+ await sendMessageToUser(state, senderId, "创建会话失败,请稍后重试", {
1818
+ sessionWebhook,
1819
+ });
1820
+ }
1821
+ catch { }
1822
+ return;
1823
+ }
1824
+ }
1825
+ const config = state.botConfig;
1826
+ if (config?.messageType === "card") {
1827
+ cleanupCardCache(state);
1828
+ // 使用 senderId 作为卡片目标key
1829
+ const existingCardId = state.activeCardsByTarget.get(senderId);
1830
+ const existingCard = existingCardId
1831
+ ? state.aiCardInstances.get(existingCardId)
1832
+ : undefined;
1833
+ if (!existingCard ||
1834
+ existingCard.state === AICardStatus.FINISHED ||
1835
+ existingCard.state === AICardStatus.FAILED) {
1836
+ await createAICard(state, config, senderId, data.conversationId, logger);
1837
+ }
1838
+ }
1839
+ // 确保有有效的 sessionId
1840
+ if (!userSessionId) {
1841
+ logger.error(`[opencode-dingtalk] Failed to get or create session for user ${senderId}`);
1842
+ return;
1843
+ }
1844
+ try {
1845
+ logger.log(`[opencode-dingtalk] Forwarding message from ${senderName} (${senderId}) to session ${userSessionId}: ${text.slice(0, 200)}`);
1846
+ // 绑定 sessionId 和 userId,用于响应时找到正确的用户
1847
+ state.bindSessionToUser(userSessionId, senderId);
1848
+ await state.saveSingleChatSessionMapToDisk(); // 保存单聊session映射
1849
+ const activeAgent = state.getActiveAgent();
1850
+ const agentModel = activeAgent
1851
+ ? await getAgentModel(state.client, activeAgent)
1852
+ : null;
1853
+ await state.client.session.promptAsync({
1854
+ path: { id: userSessionId },
1855
+ body: {
1856
+ ...(activeAgent ? { agent: activeAgent } : {}),
1857
+ ...(agentModel ? { model: agentModel } : {}),
1858
+ parts: [{ type: "text", text }],
1859
+ },
1860
+ });
1861
+ state.usersWithPendingRequests.add(senderId);
1862
+ }
1863
+ catch (error) {
1864
+ const errMsg = error instanceof Error ? error.message : "Unknown error";
1865
+ try {
1866
+ await sendMessageToUser(state, senderId, `Error: ${errMsg}`, {
1867
+ sessionWebhook,
1868
+ });
1869
+ }
1870
+ catch { }
1871
+ }
1872
+ }
1873
+ // ============ Connection Watchdog ============
1874
+ const WATCHDOG_INTERVAL_MS = 15_000;
1875
+ const RECONNECT_TIMEOUT_MS = 30_000; // 30秒超时,快速响应连接中断
1876
+ const MAX_RECONNECT_PER_HOUR = 5; // 每小时最多重连 5 次
1877
+ const RECONNECT_COOLDOWN_MS = 60_000; // 重连冷却时间 60 秒
1878
+ // Stub implementation - original watchdog relied on DWClient which has been removed
1879
+ function setupConnectionWatchdog(_state, _config, _client) {
1880
+ // No-op: watchdog not needed without DWClient
1881
+ logger.log("[opencode-dingtalk] Watchdog: stub mode, no-op");
1882
+ }
1883
+ // ============ Bot Lifecycle ============
1884
+ export async function startBot(state, config) {
1885
+ if (state.dwClient) {
1886
+ throw new Error("Bot is already running");
1887
+ }
1888
+ if (!acquireBotLock(config.clientId)) {
1889
+ throw new Error("Bot is already running in another process");
1890
+ }
1891
+ state.botConfig = config;
1892
+ const clientId = config.clientId;
1893
+ // 初始化消息队列
1894
+ if (!state.messageQueue) {
1895
+ state.messageQueue = new MessageQueue({ instanceId: clientId });
1896
+ logger.log(`[opencode-dingtalk] MessageQueue initialized for ${clientId}`);
1897
+ }
1898
+ // 创建真实的 DingTalk Client
1899
+ const client = new DingTalkClient({
1900
+ clientId: config.clientId,
1901
+ clientSecret: config.clientSecret,
1902
+ debug: true, // 开启调试模式
1903
+ subscriptions: [
1904
+ { type: "EVENT", topic: "*" },
1905
+ { type: "CALLBACK", topic: TOPIC_ROBOT },
1906
+ ],
1907
+ });
1908
+ state.dwClient = client;
1909
+ logger.log("[opencode-dingtalk] DingTalk bot starting with real client...");
1910
+ // 初始化连接管理器
1911
+ if (!state.connectionManager) {
1912
+ state.connectionManager = new ConnectionManager(clientId);
1913
+ // 监听连接状态变化事件
1914
+ state.connectionManager.on("stateChange", (oldState, newState) => {
1915
+ logger.log(JSON.stringify({
1916
+ type: "connection",
1917
+ event: "state_change",
1918
+ timestamp: Date.now(),
1919
+ oldState,
1920
+ newState,
1921
+ }));
1922
+ });
1923
+ // 监听重连成功事件
1924
+ state.connectionManager.on("reconnectSuccess", () => {
1925
+ logger.log(JSON.stringify({
1926
+ type: "connection",
1927
+ event: "reconnect_success",
1928
+ timestamp: Date.now(),
1929
+ }));
1930
+ });
1931
+ // 监听重连失败事件
1932
+ state.connectionManager.on("reconnectFailed", (error) => {
1933
+ logger.error(JSON.stringify({
1934
+ type: "connection",
1935
+ event: "reconnect_failed",
1936
+ timestamp: Date.now(),
1937
+ error: error.message,
1938
+ }));
1939
+ });
1940
+ logger.log(`[opencode-dingtalk] ConnectionManager initialized for ${clientId}`);
1941
+ }
1942
+ client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
1943
+ const messageId = res.headers?.messageId;
1944
+ try {
1945
+ if (messageId) {
1946
+ client.socketCallBackResponse(messageId, { success: true });
1947
+ }
1948
+ const data = JSON.parse(res.data);
1949
+ const dedupKey = data.msgId || messageId;
1950
+ if (dedupKey && isMessageProcessed(state, dedupKey)) {
1951
+ return;
1952
+ }
1953
+ if (dedupKey) {
1954
+ markMessageProcessed(state, dedupKey);
1955
+ }
1956
+ await handleInboundMessage(state, data);
1957
+ }
1958
+ catch (error) {
1959
+ logger.error(`[opencode-dingtalk] Error processing message: ${error.message}`);
1960
+ }
1961
+ });
1962
+ state.dwClient = client;
1963
+ logger.log("[opencode-dingtalk] DingTalk bot started with real client");
1964
+ try {
1965
+ await client.connect();
1966
+ logger.log("[opencode-dingtalk] DingTalk client connected successfully");
1967
+ }
1968
+ catch (err) {
1969
+ logger.error("[opencode-dingtalk] Failed to connect to DingTalk:", err);
1970
+ }
1971
+ // 启动连接管理器
1972
+ if (state.connectionManager) {
1973
+ state.connectionManager.start();
1974
+ }
1975
+ await new Promise((resolve) => setTimeout(resolve, 500));
1976
+ // 启动出站队列处理器
1977
+ setupOutboundQueueProcessor(state, config);
1978
+ }
1979
+ const OUTBOUND_QUEUE_INTERVAL = 30_000;
1980
+ function setupOutboundQueueProcessor(state, config) {
1981
+ const timer = setInterval(async () => {
1982
+ if (!state.messageQueue)
1983
+ return;
1984
+ const items = state.messageQueue.drain(QueueType.OUTBOUND, 10);
1985
+ if (items.length === 0)
1986
+ return;
1987
+ logger.log(`[opencode-dingtalk] Processing ${items.length} outbound queue items`);
1988
+ for (const item of items) {
1989
+ try {
1990
+ if (item.target.startsWith("http")) {
1991
+ await sendBySessionWebhook(item.target, item.content, logger, undefined, state, config);
1992
+ }
1993
+ else {
1994
+ await sendProactiveMessage(state, config, item.target, item.content, logger);
1995
+ }
1996
+ state.messageQueue.ack(item.id);
1997
+ logger.log(`[opencode-dingtalk] Outbound queue item ${item.id} sent successfully`);
1998
+ }
1999
+ catch (err) {
2000
+ const error = err instanceof Error ? err : new Error(String(err));
2001
+ state.messageQueue.nack(item.id, error.message);
2002
+ logger.error(`[opencode-dingtalk] Outbound queue item ${item.id} failed: ${error.message}`);
2003
+ }
2004
+ }
2005
+ }, OUTBOUND_QUEUE_INTERVAL);
2006
+ state.outboundQueueTimer = timer;
2007
+ logger.log(`[opencode-dingtalk] Outbound queue processor started (interval=${OUTBOUND_QUEUE_INTERVAL}ms)`);
2008
+ }
2009
+ export async function stopBot(state) {
2010
+ if (!state.dwClient) {
2011
+ throw new Error("Bot is not running");
2012
+ }
2013
+ // Stop watchdog first so it doesn't race with cleanup
2014
+ if (state.watchdogTimer) {
2015
+ clearInterval(state.watchdogTimer);
2016
+ state.watchdogTimer = null;
2017
+ }
2018
+ // 停止出站队列处理器
2019
+ if (state.outboundQueueTimer) {
2020
+ clearInterval(state.outboundQueueTimer);
2021
+ state.outboundQueueTimer = null;
2022
+ logger.log("[opencode-dingtalk] Outbound queue processor stopped");
2023
+ }
2024
+ // 停止连接管理器
2025
+ if (state.connectionManager) {
2026
+ state.connectionManager.stop();
2027
+ state.connectionManager = null;
2028
+ logger.log("[opencode-dingtalk] ConnectionManager stopped");
2029
+ }
2030
+ // 停止消息队列
2031
+ if (state.messageQueue) {
2032
+ state.messageQueue.stop();
2033
+ state.messageQueue = null;
2034
+ logger.log("[opencode-dingtalk] MessageQueue stopped");
2035
+ }
2036
+ const client = state.dwClient;
2037
+ if (client) {
2038
+ try {
2039
+ client.disconnect();
2040
+ }
2041
+ catch (error) {
2042
+ logger.warn(`[opencode-dingtalk] Error during disconnect: ${error instanceof Error ? error.message : error}`);
2043
+ }
2044
+ client.removeAllListeners();
2045
+ }
2046
+ releaseBotLock(state.botConfig?.clientId ?? "");
2047
+ state.dwClient = null;
2048
+ state.botConfig = null;
2049
+ state.processedMessages.clear();
2050
+ state.pendingResponses.clear();
2051
+ state.pendingPermissions.clear();
2052
+ state.usersWithPendingRequests.clear();
2053
+ state.aiCardInstances.clear();
2054
+ state.activeCardsByTarget.clear();
2055
+ logger.log("[opencode-dingtalk] DingTalk bot stopped and cleaned up");
2056
+ }
2057
+ export async function sendToAllActive(state, message) {
2058
+ if (!state.dwClient || !state.botConfig) {
2059
+ throw new Error("Bot is not running");
2060
+ }
2061
+ const chunks = splitMessage(message, 6000);
2062
+ let sent = 0;
2063
+ for (const user of state.activeUsers.values()) {
2064
+ try {
2065
+ for (const chunk of chunks) {
2066
+ await sendProactiveMessage(state, state.botConfig, user.userId, chunk);
2067
+ }
2068
+ sent++;
2069
+ }
2070
+ catch (error) {
2071
+ logger.error(`[opencode-dingtalk] Failed to send to ${user.userId}:`, error);
2072
+ }
2073
+ }
2074
+ return sent;
2075
+ }
2076
+ export async function sendToUser(state, userId, message) {
2077
+ if (!state.dwClient || !state.botConfig) {
2078
+ throw new Error("Bot is not running");
2079
+ }
2080
+ const chunks = splitMessage(message, 6000);
2081
+ try {
2082
+ for (const chunk of chunks) {
2083
+ await sendProactiveMessage(state, state.botConfig, userId, chunk);
2084
+ }
2085
+ return true;
2086
+ }
2087
+ catch (error) {
2088
+ logger.error(`[opencode-dingtalk] Failed to send to ${userId}:`, error);
2089
+ return false;
2090
+ }
2091
+ }
2092
+ /**
2093
+ * 根据 sessionId 精确路由消息到单聊或群聊
2094
+ * 用于权限申请、待办事项、异常等系统消息的发送
2095
+ *
2096
+ * @param state 实例状态
2097
+ * @param message 消息内容
2098
+ * @param sessionId OpenCode session ID,用于查找对应的目标
2099
+ */
2100
+ export async function sendToSession(state, message, sessionId) {
2101
+ if (!state.dwClient || !state.botConfig) {
2102
+ logger.warn("[sendToSession] Bot is not running, cannot send message");
2103
+ return;
2104
+ }
2105
+ // 1. 检查单聊(sessionToUserMap)
2106
+ const userId = state.getUserIdBySession(sessionId);
2107
+ if (userId) {
2108
+ const user = state.activeUsers.get(userId);
2109
+ if (user?.sessionWebhook) {
2110
+ logger.log(`[sendToSession] 路由到单聊用户 ${userId},使用 sessionWebhook`);
2111
+ await sendBySessionWebhook(user.sessionWebhook, message, logger);
2112
+ return;
2113
+ }
2114
+ logger.log(`[sendToSession] 路由到单聊用户 ${userId},使用 proactiveMessage`);
2115
+ await sendToUser(state, userId, message);
2116
+ return;
2117
+ }
2118
+ // 2. 检查群聊(groupSessions)
2119
+ for (const [convId, groupSession] of state.groupSessions) {
2120
+ if (groupSession.activeSessionId === sessionId &&
2121
+ groupSession.sessionWebhook) {
2122
+ logger.log(`[sendToSession] 路由到群聊 ${convId},使用 sessionWebhook`);
2123
+ await sendBySessionWebhook(groupSession.sessionWebhook, message, logger);
2124
+ return;
2125
+ }
2126
+ }
2127
+ // 3. 找不到目标,记日志,不发
2128
+ logger.warn(`[sendToSession] sessionId=${sessionId.slice(0, 8)}... 未找到对应的目标,消息不发送`);
2129
+ }