@wu529778790/open-im 1.8.1-beta.9 → 1.8.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.
@@ -103,6 +103,7 @@ export class ClaudeSDKAdapter {
103
103
  const abortController = new AbortController();
104
104
  let streamClosed = false;
105
105
  let actualSessionId;
106
+ let pendingTempId; // 记录临时 ID,用于 abort 时清理
106
107
  let runSettled = false;
107
108
  let timeoutId = null;
108
109
  const timeoutMs = options?.timeoutMs ?? 600_000;
@@ -139,7 +140,10 @@ export class ClaudeSDKAdapter {
139
140
  log.info(`[V2] Session: ${sessionId ?? 'new'}, prompt="${prompt.slice(0, 50)}..."`);
140
141
  log.info(`[V2] model param=${String(options?.model ?? '')} baseUrl=${process.env.ANTHROPIC_BASE_URL ?? '(default)'}`);
141
142
  // 获取或创建会话
142
- const { session } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
143
+ const { session, sessionId: returnedId } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
144
+ if (returnedId.startsWith('pending-')) {
145
+ pendingTempId = returnedId;
146
+ }
143
147
  // 发送用户消息
144
148
  await session.send(prompt);
145
149
  // 获取响应流
@@ -263,10 +267,11 @@ export class ClaudeSDKAdapter {
263
267
  if (abortController.signal.aborted) {
264
268
  log.info('Session run aborted');
265
269
  clearRunTimeout();
266
- // 清理 pending tempId
267
- if (actualSessionId?.startsWith('pending-')) {
268
- activeSessions.delete(actualSessionId);
269
- log.info(`Cleaned up pending session: ${actualSessionId}`);
270
+ // 清理 pending tempId(abort 可能在 init 消息之前发生)
271
+ const idToClean = actualSessionId ?? pendingTempId;
272
+ if (idToClean?.startsWith('pending-')) {
273
+ activeSessions.delete(idToClean);
274
+ log.info(`Cleaned up pending session: ${idToClean}`);
270
275
  }
271
276
  return;
272
277
  }
@@ -279,9 +284,10 @@ export class ClaudeSDKAdapter {
279
284
  log.error(`Error stack: ${errorObj.stack}`);
280
285
  }
281
286
  // 清理 pending tempId(session 在获取真实 ID 前就失败了)
282
- if (actualSessionId?.startsWith('pending-')) {
283
- activeSessions.delete(actualSessionId);
284
- log.info(`Cleaned up pending session after error: ${actualSessionId}`);
287
+ const errIdToClean = actualSessionId ?? pendingTempId;
288
+ if (errIdToClean?.startsWith('pending-')) {
289
+ activeSessions.delete(errIdToClean);
290
+ log.info(`Cleaned up pending session after error: ${errIdToClean}`);
285
291
  }
286
292
  callbacks.onError(msg);
287
293
  }
@@ -117,6 +117,7 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
117
117
  const fileQQ = file.platforms?.qq;
118
118
  const fileWework = file.platforms?.wework;
119
119
  const fileDingtalk = file.platforms?.dingtalk;
120
+ const fileWorkbuddy = file.platforms?.workbuddy;
120
121
  const telegramBotToken = env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
121
122
  const feishuAppId = env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
122
123
  const feishuAppSecret = env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
@@ -126,6 +127,9 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
126
127
  const weworkSecret = env.WEWORK_SECRET ?? fileWework?.secret;
127
128
  const dingtalkClientId = env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
128
129
  const dingtalkClientSecret = env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
130
+ const workbuddyAccessToken = fileWorkbuddy?.accessToken;
131
+ const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
132
+ const workbuddyUserId = fileWorkbuddy?.userId;
129
133
  return {
130
134
  telegram: {
131
135
  configured: !!telegramBotToken,
@@ -157,6 +161,12 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
157
161
  healthy: !!(dingtalkClientId && dingtalkClientSecret),
158
162
  message: dingtalkClientId && dingtalkClientSecret ? "Client ID and Secret configured" : "Missing credentials",
159
163
  },
164
+ workbuddy: {
165
+ configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
166
+ enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
167
+ healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
168
+ message: workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId ? "OAuth credentials configured" : "Missing credentials",
169
+ },
160
170
  };
161
171
  }
162
172
  function splitCsv(value) {
@@ -231,6 +241,15 @@ function buildInitialPayload(file) {
231
241
  cardTemplateId: file.platforms?.dingtalk?.cardTemplateId ?? "",
232
242
  allowedUserIds: (file.platforms?.dingtalk?.allowedUserIds ?? []).join(", "),
233
243
  },
244
+ workbuddy: {
245
+ enabled: file.platforms?.workbuddy?.enabled ?? Boolean(file.platforms?.workbuddy?.accessToken && file.platforms?.workbuddy?.refreshToken && file.platforms?.workbuddy?.userId),
246
+ aiCommand: file.platforms?.workbuddy?.aiCommand ?? "",
247
+ accessToken: file.platforms?.workbuddy?.accessToken ?? "",
248
+ refreshToken: file.platforms?.workbuddy?.refreshToken ?? "",
249
+ userId: file.platforms?.workbuddy?.userId ?? "",
250
+ baseUrl: file.platforms?.workbuddy?.baseUrl ?? "",
251
+ allowedUserIds: (file.platforms?.workbuddy?.allowedUserIds ?? []).join(", "),
252
+ },
234
253
  },
235
254
  ai: {
236
255
  aiCommand: file.aiCommand ?? "claude",
@@ -276,6 +295,12 @@ function validatePayload(payload) {
276
295
  errors.push("DingTalk client ID is required.");
277
296
  if (payload.platforms.dingtalk.enabled && !clean(payload.platforms.dingtalk.clientSecret))
278
297
  errors.push("DingTalk client secret is required.");
298
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.accessToken))
299
+ errors.push("WorkBuddy access token is required.");
300
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.refreshToken))
301
+ errors.push("WorkBuddy refresh token is required.");
302
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.userId))
303
+ errors.push("WorkBuddy user ID is required.");
279
304
  if (!clean(payload.ai.claudeWorkDir))
280
305
  errors.push("Default work directory is required.");
281
306
  if (!Number.isFinite(payload.ai.claudeTimeoutMs) || payload.ai.claudeTimeoutMs <= 0)
@@ -330,6 +355,17 @@ function validateConfigForPlatform(platform, config) {
330
355
  errors.push("DingTalk client secret is required and must be a non-empty string.");
331
356
  }
332
357
  break;
358
+ case "workbuddy":
359
+ if (!c.accessToken || typeof c.accessToken !== "string" || !clean(c.accessToken)) {
360
+ errors.push("WorkBuddy access token is required and must be a non-empty string.");
361
+ }
362
+ if (!c.refreshToken || typeof c.refreshToken !== "string" || !clean(c.refreshToken)) {
363
+ errors.push("WorkBuddy refresh token is required and must be a non-empty string.");
364
+ }
365
+ if (!c.userId || typeof c.userId !== "string" || !clean(c.userId)) {
366
+ errors.push("WorkBuddy user ID is required and must be a non-empty string.");
367
+ }
368
+ break;
333
369
  default:
334
370
  errors.push(`Unknown platform: ${platform}`);
335
371
  }
@@ -455,6 +491,34 @@ async function probeDingTalk(config) {
455
491
  }
456
492
  return "DingTalk credentials are valid.";
457
493
  }
494
+ async function probeWorkBuddy(config) {
495
+ const accessToken = clean(String(config.accessToken ?? ""));
496
+ const refreshToken = clean(String(config.refreshToken ?? ""));
497
+ const userId = clean(String(config.userId ?? ""));
498
+ if (!accessToken || !refreshToken || !userId)
499
+ throw new Error("WorkBuddy access token, refresh token, and user ID are required.");
500
+ const baseUrl = clean(String(config.baseUrl ?? "")) || "https://copilot.tencent.com";
501
+ // Validate credentials by attempting to register workspace
502
+ const response = await fetch(`${baseUrl}/api/copilot/workspace/register`, {
503
+ method: "POST",
504
+ headers: {
505
+ "content-type": "application/json",
506
+ "authorization": `Bearer ${accessToken}`,
507
+ },
508
+ body: JSON.stringify({
509
+ userId,
510
+ hostId: "open-im-test",
511
+ workspaceId: "open-im-test-workspace",
512
+ workspaceName: "OpenIM Test Workspace",
513
+ }),
514
+ signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
515
+ });
516
+ if (!response.ok) {
517
+ const body = await response.text();
518
+ throw new Error(`WorkBuddy authentication failed: ${body.slice(0, 200) || `HTTP ${response.status}`}`);
519
+ }
520
+ return "WorkBuddy credentials are valid.";
521
+ }
458
522
  export async function testPlatformConfig(platform, config) {
459
523
  const errors = validateConfigForPlatform(platform, config);
460
524
  if (errors.length > 0) {
@@ -471,6 +535,8 @@ export async function testPlatformConfig(platform, config) {
471
535
  return probeWeWork(config);
472
536
  case "dingtalk":
473
537
  return probeDingTalk(config);
538
+ case "workbuddy":
539
+ return probeWorkBuddy(config);
474
540
  default:
475
541
  throw new Error(`Unknown platform: ${platform}`);
476
542
  }
@@ -557,6 +623,16 @@ function toFileConfig(payload, existing) {
557
623
  cardTemplateId: clean(payload.platforms.dingtalk.cardTemplateId),
558
624
  allowedUserIds: splitCsv(payload.platforms.dingtalk.allowedUserIds),
559
625
  },
626
+ workbuddy: {
627
+ ...existing.platforms?.workbuddy,
628
+ enabled: payload.platforms.workbuddy.enabled,
629
+ aiCommand: clean(payload.platforms.workbuddy.aiCommand),
630
+ accessToken: clean(payload.platforms.workbuddy.accessToken),
631
+ refreshToken: clean(payload.platforms.workbuddy.refreshToken),
632
+ userId: clean(payload.platforms.workbuddy.userId),
633
+ baseUrl: clean(payload.platforms.workbuddy.baseUrl),
634
+ allowedUserIds: splitCsv(payload.platforms.workbuddy.allowedUserIds),
635
+ },
560
636
  },
561
637
  };
562
638
  }
@@ -812,6 +888,7 @@ export async function startWebConfigServer(options) {
812
888
  const fileQQ = file.platforms?.qq;
813
889
  const fileWework = file.platforms?.wework;
814
890
  const fileDingtalk = file.platforms?.dingtalk;
891
+ const fileWorkbuddy = file.platforms?.workbuddy;
815
892
  const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
816
893
  const feishuAppId = process.env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
817
894
  const feishuAppSecret = process.env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
@@ -821,6 +898,9 @@ export async function startWebConfigServer(options) {
821
898
  const weworkSecret = process.env.WEWORK_SECRET ?? fileWework?.secret;
822
899
  const dingtalkClientId = process.env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
823
900
  const dingtalkClientSecret = process.env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
901
+ const workbuddyAccessToken = fileWorkbuddy?.accessToken;
902
+ const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
903
+ const workbuddyUserId = fileWorkbuddy?.userId;
824
904
  const platforms = {};
825
905
  // 检查 Telegram
826
906
  platforms.telegram = {
@@ -857,6 +937,13 @@ export async function startWebConfigServer(options) {
857
937
  healthy: !!(dingtalkClientId && dingtalkClientSecret),
858
938
  message: (dingtalkClientId && dingtalkClientSecret) ? "Client ID and Secret configured" : "Missing credentials"
859
939
  };
940
+ // 检查 WorkBuddy
941
+ platforms.workbuddy = {
942
+ configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
943
+ enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
944
+ healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
945
+ message: (workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) ? "OAuth credentials configured" : "Missing credentials"
946
+ };
860
947
  json(response, 200, { platforms, serviceStatus: getServiceStatus() });
861
948
  return;
862
949
  }
@@ -7,7 +7,9 @@ const TEXT_MSG_KEY = 'sampleText';
7
7
  const DINGTALK_STREAM_HOST = 'wss-open-connection.dingtalk.com';
8
8
  let client = null;
9
9
  let messageHandler = null;
10
+ // sessionWebhook 有过期时间(约 2 小时),需要记录时间戳
10
11
  const sessionWebhookByChat = new Map();
12
+ const WEBHOOK_TTL_MS = 90 * 60 * 1000; // 90 分钟后视为过期
11
13
  const unionIdByUserId = new Map();
12
14
  let dingtalkWarnFilterInstalled = false;
13
15
  export function shouldSuppressDingTalkSocketWarn(args) {
@@ -44,13 +46,19 @@ function getClient() {
44
46
  export function registerSessionWebhook(chatId, sessionWebhook) {
45
47
  if (!chatId || !sessionWebhook)
46
48
  return;
47
- sessionWebhookByChat.set(chatId, sessionWebhook);
49
+ sessionWebhookByChat.set(chatId, { webhook: sessionWebhook, registeredAt: Date.now() });
48
50
  }
49
51
  async function sendByWebhook(chatId, body) {
50
- const sessionWebhook = sessionWebhookByChat.get(chatId);
51
- if (!sessionWebhook) {
52
+ const entry = sessionWebhookByChat.get(chatId);
53
+ if (!entry) {
52
54
  throw new Error(`DingTalk sessionWebhook unavailable for chat ${chatId}`);
53
55
  }
56
+ // 检查 webhook 是否过期
57
+ if (Date.now() - entry.registeredAt > WEBHOOK_TTL_MS) {
58
+ sessionWebhookByChat.delete(chatId);
59
+ throw new Error(`DingTalk sessionWebhook expired for chat ${chatId}`);
60
+ }
61
+ const sessionWebhook = entry.webhook;
54
62
  const accessToken = await getClient().getAccessToken();
55
63
  const res = await fetch(sessionWebhook, {
56
64
  method: 'POST',
@@ -17,6 +17,95 @@ import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
17
17
  import { buildMediaContext } from '../shared/media-context.js';
18
18
  import { buildProgressNote } from '../shared/message-note.js';
19
19
  const log = createLogger('FeishuHandler');
20
+ /**
21
+ * 从异常中提取飞书 API 错误码
22
+ */
23
+ function extractFeishuErrorCode(err) {
24
+ const e = err;
25
+ if (e?.response?.data?.code)
26
+ return e.response.data.code;
27
+ if (e?.code)
28
+ return e.code;
29
+ return undefined;
30
+ }
31
+ /**
32
+ * 根据错误码判断是否为权限不足
33
+ */
34
+ function isPermissionError(err) {
35
+ const code = extractFeishuErrorCode(err);
36
+ if (!code) {
37
+ // 非标准错误:检查 message 中是否包含权限关键词
38
+ const msg = err?.message ?? String(err);
39
+ return /permission|权限|scope|not authorized|no access|forbidden/i.test(msg);
40
+ }
41
+ // 飞书常见权限错误码
42
+ return [
43
+ 99991400, // 权限不足
44
+ 99991401, // 没有API权限
45
+ 99991663, // 应用未获取 scope
46
+ 99991672, // 应用未开通相关能力
47
+ 99991670, // 应用未上架/未授权
48
+ 99991668, // 应用可见性限制
49
+ ].includes(code);
50
+ }
51
+ /**
52
+ * 构建飞书权限配置指引消息
53
+ */
54
+ function buildPermissionGuideMessage(err) {
55
+ const code = extractFeishuErrorCode(err);
56
+ const codeHint = code ? ` (错误码: ${code})` : '';
57
+ return [
58
+ '⚠️ **飞书应用权限不足,无法发送消息**' + codeHint,
59
+ '',
60
+ '请按以下步骤开通所需权限:',
61
+ '',
62
+ '**1. 进入飞书开放平台**',
63
+ '👉 https://open.feishu.cn/app',
64
+ '',
65
+ '**2. 找到你的应用,进入「权限管理」**',
66
+ '',
67
+ '**3. 开通以下权限(搜索权限名称添加):**',
68
+ '• `im:message` — 获取与发送单聊、群组消息',
69
+ '• `im:message:send_as_bot` — 以应用身份发消息',
70
+ '• `im:resource` — 获取与上传图片或文件资源',
71
+ '• `im:chat` — 获取群组信息',
72
+ '',
73
+ '**4. 如需使用卡片打字机效果,还需开通:**',
74
+ '• `cardkit:card` — CardKit 卡片管理',
75
+ '',
76
+ '**5. 发布版本**',
77
+ '权限修改后需点击「创建版本」→「发布」,管理员审批后生效。',
78
+ '',
79
+ '📖 详细文档:https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO04yNxkDN',
80
+ ].join('\n');
81
+ }
82
+ /**
83
+ * 发送权限错误提示,依次尝试:卡片 → 纯文本 → open_id
84
+ */
85
+ async function sendPermissionFallback(chatId, guide) {
86
+ // 1. 先尝试 sendTextReply(发卡片消息)
87
+ try {
88
+ await sendTextReply(chatId, guide);
89
+ return;
90
+ }
91
+ catch { /* 卡片方式失败,降级 */ }
92
+ // 2. 降级为纯文本消息
93
+ try {
94
+ const client = (await import('./client.js')).getClient();
95
+ const plainGuide = guide.replace(/\*\*/g, '').replace(/`/g, '');
96
+ await client.im.message.create({
97
+ data: {
98
+ receive_id: chatId,
99
+ msg_type: 'text',
100
+ content: JSON.stringify({ text: plainGuide }),
101
+ },
102
+ params: { receive_id_type: 'chat_id' },
103
+ });
104
+ return;
105
+ }
106
+ catch { /* 纯文本也失败 */ }
107
+ log.error('All fallback methods failed to send permission guide');
108
+ }
20
109
  async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
21
110
  const targetPath = createMediaTargetPath(options?.fallbackExtension ?? 'bin', options?.basenameHint ?? fileKey);
22
111
  const response = await client.im.messageResource.get({
@@ -62,10 +151,17 @@ export function setupFeishuHandlers(config, sessionManager) {
62
151
  }
63
152
  catch (err) {
64
153
  log.error('Failed to send thinking card:', err);
65
- try {
66
- await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
154
+ // 检测是否为飞书权限不足
155
+ if (isPermissionError(err)) {
156
+ const guide = buildPermissionGuideMessage(err);
157
+ await sendPermissionFallback(chatId, guide).catch(() => { });
158
+ }
159
+ else {
160
+ try {
161
+ await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
162
+ }
163
+ catch { /* ignore */ }
67
164
  }
68
- catch { /* ignore */ }
69
165
  return;
70
166
  }
71
167
  const { messageId: msgId, cardId } = cardHandle;
package/dist/qq/client.js CHANGED
@@ -17,6 +17,7 @@ let stopped = false;
17
17
  let seq = null;
18
18
  let sessionId = null;
19
19
  let reconnectAttempt = 0;
20
+ let connecting = false; // 防止并发 connectWebSocket
20
21
  let currentConfig = null;
21
22
  let currentHandler = null;
22
23
  let tokenState = null;
@@ -147,99 +148,116 @@ function startHeartbeat(intervalMs) {
147
148
  }, intervalMs);
148
149
  }
149
150
  async function connectWebSocket(config, handler) {
150
- const gatewayUrl = await getGatewayUrl(config);
151
- const token = await fetchAccessToken(config);
152
- await new Promise((resolve, reject) => {
153
- const socket = new WebSocket(gatewayUrl);
154
- ws = socket;
155
- let settled = false;
156
- const settle = (fn) => {
157
- if (settled)
158
- return;
159
- settled = true;
160
- fn();
161
- };
162
- socket.on("open", () => {
163
- log.info("QQ gateway connected");
164
- reconnectAttempt = 0;
165
- });
166
- socket.on("message", async (raw) => {
167
- try {
168
- const payload = JSON.parse(raw.toString());
169
- if (typeof payload.s === "number")
170
- seq = payload.s;
171
- if (payload.op === 10) {
172
- const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
173
- startHeartbeat(heartbeatInterval);
174
- socket.send(JSON.stringify({
175
- op: sessionId ? 6 : 2,
176
- d: sessionId
177
- ? {
178
- token: `QQBot ${token}`,
179
- session_id: sessionId,
180
- seq,
181
- }
182
- : {
183
- token: `QQBot ${token}`,
184
- intents: INTENTS.GROUP_AND_C2C |
185
- INTENTS.DIRECT_MESSAGE |
186
- INTENTS.PUBLIC_GUILD_MESSAGES,
187
- properties: {
188
- os: process.platform,
189
- browser: "open-im",
190
- device: "open-im",
191
- },
192
- },
193
- }));
194
- return;
195
- }
196
- if (payload.op === 0 && payload.t === "READY") {
197
- sessionId = String(payload.d?.session_id ?? "");
198
- settle(resolve);
151
+ // 防止并发连接
152
+ if (connecting) {
153
+ log.warn("QQ gateway connection already in progress");
154
+ return;
155
+ }
156
+ connecting = true;
157
+ try {
158
+ const gatewayUrl = await getGatewayUrl(config);
159
+ const token = await fetchAccessToken(config);
160
+ await new Promise((resolve, reject) => {
161
+ const socket = new WebSocket(gatewayUrl);
162
+ ws = socket;
163
+ let settled = false;
164
+ let readyTimeoutId = setTimeout(() => {
165
+ readyTimeoutId = null;
166
+ settle(() => reject(new Error("QQ gateway ready timeout")));
167
+ }, 15000);
168
+ const settle = (fn) => {
169
+ if (settled)
199
170
  return;
171
+ settled = true;
172
+ if (readyTimeoutId) {
173
+ clearTimeout(readyTimeoutId);
174
+ readyTimeoutId = null;
200
175
  }
201
- if (payload.op === 0 && payload.t === "RESUMED") {
202
- settle(resolve);
203
- return;
176
+ fn();
177
+ };
178
+ socket.on("open", () => {
179
+ log.info("QQ gateway connected");
180
+ reconnectAttempt = 0;
181
+ });
182
+ socket.on("message", async (raw) => {
183
+ try {
184
+ const payload = JSON.parse(raw.toString());
185
+ if (typeof payload.s === "number")
186
+ seq = payload.s;
187
+ if (payload.op === 10) {
188
+ const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
189
+ startHeartbeat(heartbeatInterval);
190
+ socket.send(JSON.stringify({
191
+ op: sessionId ? 6 : 2,
192
+ d: sessionId
193
+ ? {
194
+ token: `QQBot ${token}`,
195
+ session_id: sessionId,
196
+ seq,
197
+ }
198
+ : {
199
+ token: `QQBot ${token}`,
200
+ intents: INTENTS.GROUP_AND_C2C |
201
+ INTENTS.DIRECT_MESSAGE |
202
+ INTENTS.PUBLIC_GUILD_MESSAGES,
203
+ properties: {
204
+ os: process.platform,
205
+ browser: "open-im",
206
+ device: "open-im",
207
+ },
208
+ },
209
+ }));
210
+ return;
211
+ }
212
+ if (payload.op === 0 && payload.t === "READY") {
213
+ sessionId = String(payload.d?.session_id ?? "");
214
+ settle(resolve);
215
+ return;
216
+ }
217
+ if (payload.op === 0 && payload.t === "RESUMED") {
218
+ settle(resolve);
219
+ return;
220
+ }
221
+ const event = normalizeInboundEvent(payload);
222
+ if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
223
+ await handler(event);
224
+ }
204
225
  }
205
- const event = normalizeInboundEvent(payload);
206
- if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
207
- await handler(event);
226
+ catch (error) {
227
+ log.error("Failed to handle QQ gateway payload:", error);
208
228
  }
209
- }
210
- catch (error) {
211
- log.error("Failed to handle QQ gateway payload:", error);
212
- }
213
- });
214
- socket.on("error", (error) => {
215
- log.error("QQ gateway error:", error);
216
- settle(() => reject(error));
217
- });
218
- socket.on("close", (code, reason) => {
219
- clearTimers();
220
- ws = null;
221
- log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
222
- if (stopped)
223
- return;
224
- if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
225
- tokenState = null;
226
- sessionId = null;
227
- seq = null;
228
- }
229
- const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
230
- reconnectAttempt += 1;
231
- reconnectTimer = setTimeout(() => {
232
- if (currentConfig && currentHandler) {
233
- connectWebSocket(currentConfig, currentHandler).catch((err) => {
234
- log.error("QQ reconnect failed:", err);
235
- });
229
+ });
230
+ socket.on("error", (error) => {
231
+ log.error("QQ gateway error:", error);
232
+ settle(() => reject(error));
233
+ });
234
+ socket.on("close", (code, reason) => {
235
+ settle(() => { }); // 清理 ready timeout
236
+ clearTimers();
237
+ ws = null;
238
+ log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
239
+ if (stopped)
240
+ return;
241
+ if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
242
+ tokenState = null;
243
+ sessionId = null;
244
+ seq = null;
236
245
  }
237
- }, delay);
246
+ const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
247
+ reconnectAttempt += 1;
248
+ reconnectTimer = setTimeout(() => {
249
+ if (currentConfig && currentHandler) {
250
+ connectWebSocket(currentConfig, currentHandler).catch((err) => {
251
+ log.error("QQ reconnect failed:", err);
252
+ });
253
+ }
254
+ }, delay);
255
+ });
238
256
  });
239
- setTimeout(() => {
240
- settle(() => reject(new Error("QQ gateway ready timeout")));
241
- }, 15000);
242
- });
257
+ }
258
+ finally {
259
+ connecting = false;
260
+ }
243
261
  }
244
262
  export function getQQBot() {
245
263
  if (!client || !currentConfig) {
@@ -8,6 +8,7 @@ import { createLogger } from '../logger.js';
8
8
  const log = createLogger('WeChat');
9
9
  const TOKEN_FILE = 'wechat-token.json';
10
10
  const DEFAULT_WECHAT_WS_URL = 'wss://openclau-wechat.henryxiaoyang.workers.dev';
11
+ const PONG_TIMEOUT_FACTOR = 3; // 3倍心跳间隔无响应则判定连接死亡
11
12
  // Global state
12
13
  let ws = null;
13
14
  let channelState = 'disconnected';
@@ -16,6 +17,9 @@ let heartbeatTimer = null;
16
17
  let reconnectAttempts = 0;
17
18
  let currentToken = null;
18
19
  let tokenStoragePath = null;
20
+ let lastServerResponseTime = 0; // 上次收到服务端消息的时间
21
+ let wsConfigRef = null; // 保存配置供心跳重连使用
22
+ let isStopping = false; // 防止 stop 后重连定时器继续触发
19
23
  // Event handlers
20
24
  let messageHandler = null;
21
25
  let stateChangeHandler = null;
@@ -70,6 +74,7 @@ export async function initWeChat(config, eventHandler, onStateChange) {
70
74
  }
71
75
  messageHandler = eventHandler;
72
76
  stateChangeHandler = onStateChange ?? null;
77
+ isStopping = false;
73
78
  // Set up token storage path
74
79
  const baseDir = config.logDir ?? join(process.env.HOME ?? '', '.open-im');
75
80
  tokenStoragePath = join(baseDir, 'data');
@@ -92,6 +97,7 @@ export async function initWeChat(config, eventHandler, onStateChange) {
92
97
  * Connect to AGP WebSocket server
93
98
  */
94
99
  async function connectWebSocket(config) {
100
+ wsConfigRef = config;
95
101
  if (channelState === 'connecting') {
96
102
  log.warn('WebSocket connection already in progress');
97
103
  return;
@@ -108,6 +114,7 @@ async function connectWebSocket(config) {
108
114
  resolve();
109
115
  });
110
116
  ws.on('message', async (data) => {
117
+ lastServerResponseTime = Date.now();
111
118
  try {
112
119
  const envelope = JSON.parse(data.toString());
113
120
  log.debug('Received AGP message:', envelope.method);
@@ -190,11 +197,35 @@ function updateState(state) {
190
197
  }
191
198
  /**
192
199
  * Start heartbeat to keep connection alive
200
+ * 同时检测服务端是否响应,超时无响应则主动断开触发重连
193
201
  */
194
202
  function startHeartbeat(interval) {
195
203
  stopHeartbeat();
204
+ lastServerResponseTime = Date.now();
196
205
  heartbeatTimer = setInterval(() => {
197
206
  if (channelState === 'connected') {
207
+ // 检测连接是否已死:长时间未收到任何服务端响应
208
+ const elapsed = Date.now() - lastServerResponseTime;
209
+ const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
210
+ if (lastServerResponseTime > 0 && elapsed > pongTimeout) {
211
+ log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
212
+ stopHeartbeat();
213
+ if (ws) {
214
+ try {
215
+ ws.removeAllListeners();
216
+ ws.close();
217
+ }
218
+ catch {
219
+ /* ignore */
220
+ }
221
+ ws = null;
222
+ }
223
+ updateState('disconnected');
224
+ if (wsConfigRef) {
225
+ scheduleReconnect(wsConfigRef);
226
+ }
227
+ return;
228
+ }
198
229
  sendAGPMessage('ping', { timestamp: Date.now() });
199
230
  }
200
231
  }, interval);
@@ -210,17 +241,28 @@ function stopHeartbeat() {
210
241
  }
211
242
  /**
212
243
  * Schedule reconnection attempt
244
+ * 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
213
245
  */
214
246
  function scheduleReconnect(config) {
247
+ if (isStopping)
248
+ return;
215
249
  const maxAttempts = config.maxReconnectAttempts ?? 10;
216
- if (reconnectAttempts >= maxAttempts) {
217
- log.error('Max reconnect attempts reached');
250
+ if (reconnectTimer) {
218
251
  return;
219
252
  }
220
- const interval = config.reconnectInterval ?? 5000;
253
+ // 超过最大重试次数后重置计数器,降低频率继续重试
254
+ if (reconnectAttempts >= maxAttempts) {
255
+ log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter and retrying at lower frequency`);
256
+ reconnectAttempts = 0;
257
+ }
258
+ const baseInterval = config.reconnectInterval ?? 5000;
259
+ // 超过一半次数后逐渐增加间隔,最大 60 秒
260
+ const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(reconnectAttempts / 3)), 60000);
261
+ const interval = Math.round(backoff);
221
262
  reconnectTimer = setTimeout(async () => {
263
+ reconnectTimer = null;
222
264
  reconnectAttempts++;
223
- log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts}`);
265
+ log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts} (interval: ${interval}ms)`);
224
266
  try {
225
267
  await connectWebSocket(config);
226
268
  }
@@ -274,6 +316,7 @@ function saveToken() {
274
316
  * Stop WeChat client
275
317
  */
276
318
  export function stopWeChat() {
319
+ isStopping = true;
277
320
  stopHeartbeat();
278
321
  if (reconnectTimer) {
279
322
  clearTimeout(reconnectTimer);
@@ -13,6 +13,7 @@ import { createLogger } from '../logger.js';
13
13
  const log = createLogger('WeWork');
14
14
  const DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com';
15
15
  const HEARTBEAT_INTERVAL = 30000; // 30秒
16
+ const PONG_TIMEOUT = HEARTBEAT_INTERVAL * 3; // 90秒无任何服务端响应则判定连接死亡
16
17
  const MAX_RECONNECT_ATTEMPTS = 100;
17
18
  // Global state
18
19
  let ws = null;
@@ -22,6 +23,7 @@ let heartbeatTimer = null;
22
23
  let reconnectAttempts = 0;
23
24
  let shouldReconnect = false;
24
25
  let isStopping = false;
26
+ let lastServerResponseTime = 0; // 上次收到服务端消息的时间
25
27
  // Event handlers
26
28
  let messageHandler = null;
27
29
  let stateChangeHandler = null;
@@ -173,6 +175,7 @@ async function connectWebSocket() {
173
175
  }
174
176
  });
175
177
  ws.on('message', async (data) => {
178
+ lastServerResponseTime = Date.now();
176
179
  try {
177
180
  const message = JSON.parse(data.toString());
178
181
  await handleMessage(message);
@@ -326,11 +329,30 @@ function updateState(state) {
326
329
  }
327
330
  /**
328
331
  * Start heartbeat to keep connection alive
332
+ * 同时检测服务端是否响应,超时无响应则主动断开触发重连
329
333
  */
330
334
  function startHeartbeat() {
331
335
  stopHeartbeat();
336
+ lastServerResponseTime = Date.now();
332
337
  heartbeatTimer = setInterval(() => {
333
338
  if (connectionState === 'connected' && ws) {
339
+ // 检测连接是否已死:长时间未收到任何服务端响应
340
+ const elapsed = Date.now() - lastServerResponseTime;
341
+ if (lastServerResponseTime > 0 && elapsed > PONG_TIMEOUT) {
342
+ log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
343
+ stopHeartbeat();
344
+ try {
345
+ ws.removeAllListeners();
346
+ ws.close();
347
+ }
348
+ catch {
349
+ /* ignore */
350
+ }
351
+ ws = null;
352
+ updateState('disconnected');
353
+ scheduleReconnect();
354
+ return;
355
+ }
334
356
  const pingMessage = {
335
357
  cmd: "ping" /* WeWorkCommand.PING */,
336
358
  headers: {
@@ -359,23 +381,27 @@ function stopHeartbeat() {
359
381
  }
360
382
  /**
361
383
  * Schedule reconnection attempt
384
+ * 超过 MAX_RECONNECT_ATTEMPTS 后自动重置计数器继续重试,避免永久断连
362
385
  */
363
386
  function scheduleReconnect() {
364
387
  if (isStopping || !shouldReconnect) {
365
388
  return;
366
389
  }
367
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
368
- log.error('Max reconnect attempts reached');
369
- return;
370
- }
371
- const interval = 5000; // 5秒后重连
372
390
  if (reconnectTimer) {
373
391
  return;
374
392
  }
393
+ // 超过最大重试次数后重置计数器,降低频率继续重试
394
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
395
+ log.warn(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, resetting counter and retrying at lower frequency`);
396
+ reconnectAttempts = 0;
397
+ }
398
+ // 逐步增加间隔,5s → 7.5s → 11s → ... 最大 60s
399
+ const backoff = Math.min(5000 * Math.pow(1.5, Math.floor(reconnectAttempts / 5)), 60000);
400
+ const interval = Math.round(backoff);
375
401
  reconnectTimer = setTimeout(async () => {
376
402
  reconnectTimer = null;
377
403
  reconnectAttempts++;
378
- log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
404
+ log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} (interval: ${interval}ms)`);
379
405
  try {
380
406
  await connectWebSocket();
381
407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.8.1-beta.9",
3
+ "version": "1.8.1",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",