@wu529778790/open-im 1.8.1-beta.8 → 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.
- package/dist/adapters/claude-sdk-adapter.js +17 -1
- package/dist/config-web.js +87 -0
- package/dist/dingtalk/client.js +11 -3
- package/dist/dingtalk/event-handler.js +12 -1
- package/dist/feishu/event-handler.js +115 -1
- package/dist/qq/client.js +105 -87
- package/dist/qq/event-handler.js +12 -1
- package/dist/shared/ai-task.js +13 -1
- package/dist/telegram/client.js +2 -2
- package/dist/telegram/event-handler.js +4 -0
- package/dist/wechat/client.js +47 -4
- package/dist/wechat/event-handler.js +4 -0
- package/dist/wework/client.js +36 -14
- package/dist/wework/event-handler.js +12 -1
- package/package.json +2 -1
|
@@ -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,6 +267,12 @@ export class ClaudeSDKAdapter {
|
|
|
263
267
|
if (abortController.signal.aborted) {
|
|
264
268
|
log.info('Session run aborted');
|
|
265
269
|
clearRunTimeout();
|
|
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}`);
|
|
275
|
+
}
|
|
266
276
|
return;
|
|
267
277
|
}
|
|
268
278
|
runSettled = true;
|
|
@@ -273,6 +283,12 @@ export class ClaudeSDKAdapter {
|
|
|
273
283
|
if (errorObj.stack) {
|
|
274
284
|
log.error(`Error stack: ${errorObj.stack}`);
|
|
275
285
|
}
|
|
286
|
+
// 清理 pending tempId(session 在获取真实 ID 前就失败了)
|
|
287
|
+
const errIdToClean = actualSessionId ?? pendingTempId;
|
|
288
|
+
if (errIdToClean?.startsWith('pending-')) {
|
|
289
|
+
activeSessions.delete(errIdToClean);
|
|
290
|
+
log.info(`Cleaned up pending session after error: ${errIdToClean}`);
|
|
291
|
+
}
|
|
276
292
|
callbacks.onError(msg);
|
|
277
293
|
}
|
|
278
294
|
};
|
package/dist/config-web.js
CHANGED
|
@@ -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
|
}
|
package/dist/dingtalk/client.js
CHANGED
|
@@ -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
|
|
51
|
-
if (!
|
|
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',
|
|
@@ -185,7 +185,18 @@ export function setupDingTalkHandlers(config, sessionManager) {
|
|
|
185
185
|
: undefined;
|
|
186
186
|
log.info(`[AI_REQUEST] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
|
|
187
187
|
const toolId = aiCommand;
|
|
188
|
-
|
|
188
|
+
let msgId;
|
|
189
|
+
try {
|
|
190
|
+
msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, dingtalkTarget);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
log.error('Failed to send thinking message:', err);
|
|
194
|
+
try {
|
|
195
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
196
|
+
}
|
|
197
|
+
catch { /* ignore */ }
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
189
200
|
const stopTyping = startTypingLoop(chatId);
|
|
190
201
|
const taskKey = `${userId}:${msgId}`;
|
|
191
202
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'dingtalk', taskKey }, prompt, toolAdapter, {
|
|
@@ -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,14 +151,39 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
62
151
|
}
|
|
63
152
|
catch (err) {
|
|
64
153
|
log.error('Failed to send thinking card:', err);
|
|
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 */ }
|
|
164
|
+
}
|
|
65
165
|
return;
|
|
66
166
|
}
|
|
67
167
|
const { messageId: msgId, cardId } = cardHandle;
|
|
68
168
|
const stopTyping = startTypingLoop(chatId);
|
|
69
169
|
const taskKey = `${userId}:${cardId}`;
|
|
170
|
+
let consecutiveStreamErrors = 0;
|
|
171
|
+
const MAX_STREAM_ERRORS = 5;
|
|
70
172
|
const streamUpdate = (content, toolNote) => {
|
|
173
|
+
if (consecutiveStreamErrors >= MAX_STREAM_ERRORS)
|
|
174
|
+
return; // 停止尝试
|
|
71
175
|
const note = buildProgressNote(toolNote);
|
|
72
|
-
streamContentUpdate(cardId, content, note).
|
|
176
|
+
streamContentUpdate(cardId, content, note).then(() => {
|
|
177
|
+
consecutiveStreamErrors = 0;
|
|
178
|
+
}).catch((e) => {
|
|
179
|
+
consecutiveStreamErrors++;
|
|
180
|
+
if (consecutiveStreamErrors >= MAX_STREAM_ERRORS) {
|
|
181
|
+
log.warn(`Stream update failed ${consecutiveStreamErrors} times consecutively, giving up: ${e?.message ?? e}`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
log.debug('Stream update failed (will retry on next update):', e?.message ?? e);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
73
187
|
};
|
|
74
188
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'feishu', taskKey }, prompt, toolAdapter, {
|
|
75
189
|
throttleMs: CARDKIT_THROTTLE_MS,
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
await handler(event);
|
|
226
|
+
catch (error) {
|
|
227
|
+
log.error("Failed to handle QQ gateway payload:", error);
|
|
208
228
|
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
log.error("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
connecting = false;
|
|
260
|
+
}
|
|
243
261
|
}
|
|
244
262
|
export function getQQBot() {
|
|
245
263
|
if (!client || !currentConfig) {
|
package/dist/qq/event-handler.js
CHANGED
|
@@ -144,7 +144,18 @@ export function setupQQHandlers(config, sessionManager) {
|
|
|
144
144
|
? sessionManager.getSessionIdForConv(userId, convId, aiCommand)
|
|
145
145
|
: undefined;
|
|
146
146
|
const toolId = aiCommand;
|
|
147
|
-
|
|
147
|
+
let msgId;
|
|
148
|
+
try {
|
|
149
|
+
msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
log.error("Failed to send thinking message:", err);
|
|
153
|
+
try {
|
|
154
|
+
await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
|
|
155
|
+
}
|
|
156
|
+
catch { /* ignore */ }
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
148
159
|
const stopTyping = startTypingLoop();
|
|
149
160
|
const taskKey = `${userId}:${msgId}`;
|
|
150
161
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: "qq", taskKey }, prompt, toolAdapter, {
|
package/dist/shared/ai-task.js
CHANGED
|
@@ -247,7 +247,19 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
247
247
|
startedAt: Date.now(),
|
|
248
248
|
toolId: aiCommand,
|
|
249
249
|
};
|
|
250
|
-
|
|
250
|
+
try {
|
|
251
|
+
startRun();
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (!settled) {
|
|
255
|
+
settled = true;
|
|
256
|
+
cleanup();
|
|
257
|
+
log.error(`[AITask] Synchronous error in startRun: ${err}`);
|
|
258
|
+
platformAdapter.sendError(`内部错误:${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
259
|
+
resolve();
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
251
263
|
platformAdapter.onTaskReady(taskState);
|
|
252
264
|
});
|
|
253
265
|
}
|
package/dist/telegram/client.js
CHANGED
|
@@ -39,8 +39,8 @@ export async function initTelegram(config, setupHandlers) {
|
|
|
39
39
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
40
40
|
return launchWithRetry(attempt + 1);
|
|
41
41
|
}
|
|
42
|
-
log.error("Telegram gave up reconnecting,
|
|
43
|
-
|
|
42
|
+
log.error("Telegram gave up reconnecting, skipping");
|
|
43
|
+
// 不再 exit(1),让其他通道继续运行
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
46
|
void launchWithRetry();
|
|
@@ -107,6 +107,10 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
107
107
|
}
|
|
108
108
|
catch (err) {
|
|
109
109
|
log.error("Failed to send thinking message:", err);
|
|
110
|
+
try {
|
|
111
|
+
await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
|
|
112
|
+
}
|
|
113
|
+
catch { /* ignore */ }
|
|
110
114
|
return;
|
|
111
115
|
}
|
|
112
116
|
const stopTyping = startTypingLoop(chatId);
|
package/dist/wechat/client.js
CHANGED
|
@@ -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 (
|
|
217
|
-
log.error('Max reconnect attempts reached');
|
|
250
|
+
if (reconnectTimer) {
|
|
218
251
|
return;
|
|
219
252
|
}
|
|
220
|
-
|
|
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);
|
|
@@ -130,6 +130,10 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
130
130
|
}
|
|
131
131
|
catch (err) {
|
|
132
132
|
log.error('Failed to send thinking message:', err);
|
|
133
|
+
try {
|
|
134
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
135
|
+
}
|
|
136
|
+
catch { /* ignore */ }
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
const stopTyping = startTypingLoop(chatId);
|
package/dist/wework/client.js
CHANGED
|
@@ -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;
|
|
@@ -46,12 +48,10 @@ export function getConnectionState() {
|
|
|
46
48
|
*/
|
|
47
49
|
export function sendProactiveMessage(chatId, content) {
|
|
48
50
|
if (!ws || connectionState !== 'connected') {
|
|
49
|
-
|
|
50
|
-
return;
|
|
51
|
+
throw new Error('Cannot send proactive message: WebSocket not connected');
|
|
51
52
|
}
|
|
52
53
|
if (!chatId) {
|
|
53
|
-
|
|
54
|
-
return;
|
|
54
|
+
throw new Error('Cannot send proactive message: chatId is required');
|
|
55
55
|
}
|
|
56
56
|
const message = {
|
|
57
57
|
cmd: "aibot_send_msg" /* WeWorkCommand.AIBOT_SEND_MSG */,
|
|
@@ -77,12 +77,10 @@ export function sendProactiveMessage(chatId, content) {
|
|
|
77
77
|
*/
|
|
78
78
|
export function sendWebSocketReply(reqId, body) {
|
|
79
79
|
if (!ws || connectionState !== 'connected') {
|
|
80
|
-
|
|
81
|
-
return;
|
|
80
|
+
throw new Error('Cannot send reply: WebSocket not connected');
|
|
82
81
|
}
|
|
83
82
|
if (!reqId) {
|
|
84
|
-
|
|
85
|
-
return;
|
|
83
|
+
throw new Error('Cannot send reply: req_id is required');
|
|
86
84
|
}
|
|
87
85
|
const message = {
|
|
88
86
|
cmd: "aibot_respond_msg" /* WeWorkCommand.AIBOT_RESPOND_MSG */,
|
|
@@ -177,6 +175,7 @@ async function connectWebSocket() {
|
|
|
177
175
|
}
|
|
178
176
|
});
|
|
179
177
|
ws.on('message', async (data) => {
|
|
178
|
+
lastServerResponseTime = Date.now();
|
|
180
179
|
try {
|
|
181
180
|
const message = JSON.parse(data.toString());
|
|
182
181
|
await handleMessage(message);
|
|
@@ -330,11 +329,30 @@ function updateState(state) {
|
|
|
330
329
|
}
|
|
331
330
|
/**
|
|
332
331
|
* Start heartbeat to keep connection alive
|
|
332
|
+
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
333
333
|
*/
|
|
334
334
|
function startHeartbeat() {
|
|
335
335
|
stopHeartbeat();
|
|
336
|
+
lastServerResponseTime = Date.now();
|
|
336
337
|
heartbeatTimer = setInterval(() => {
|
|
337
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
|
+
}
|
|
338
356
|
const pingMessage = {
|
|
339
357
|
cmd: "ping" /* WeWorkCommand.PING */,
|
|
340
358
|
headers: {
|
|
@@ -363,23 +381,27 @@ function stopHeartbeat() {
|
|
|
363
381
|
}
|
|
364
382
|
/**
|
|
365
383
|
* Schedule reconnection attempt
|
|
384
|
+
* 超过 MAX_RECONNECT_ATTEMPTS 后自动重置计数器继续重试,避免永久断连
|
|
366
385
|
*/
|
|
367
386
|
function scheduleReconnect() {
|
|
368
387
|
if (isStopping || !shouldReconnect) {
|
|
369
388
|
return;
|
|
370
389
|
}
|
|
371
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
372
|
-
log.error('Max reconnect attempts reached');
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
const interval = 5000; // 5秒后重连
|
|
376
390
|
if (reconnectTimer) {
|
|
377
391
|
return;
|
|
378
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);
|
|
379
401
|
reconnectTimer = setTimeout(async () => {
|
|
380
402
|
reconnectTimer = null;
|
|
381
403
|
reconnectAttempts++;
|
|
382
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
|
404
|
+
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} (interval: ${interval}ms)`);
|
|
383
405
|
try {
|
|
384
406
|
await connectWebSocket();
|
|
385
407
|
}
|
|
@@ -185,7 +185,18 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
185
185
|
: undefined;
|
|
186
186
|
log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
|
|
187
187
|
const toolId = aiCommand;
|
|
188
|
-
|
|
188
|
+
let msgId;
|
|
189
|
+
try {
|
|
190
|
+
msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
log.error('Failed to send thinking message:', err);
|
|
194
|
+
try {
|
|
195
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
|
|
196
|
+
}
|
|
197
|
+
catch { /* ignore */ }
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
189
200
|
const stopTyping = startTypingLoop(chatId);
|
|
190
201
|
const taskKey = `${userId}:${msgId}`;
|
|
191
202
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.8.1
|
|
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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
|
50
50
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
51
|
+
"@wu529778790/open-im": "^1.8.1-beta.8",
|
|
51
52
|
"centrifuge": "^5.3.0",
|
|
52
53
|
"dingtalk-stream": "^2.1.4",
|
|
53
54
|
"prompts": "^2.4.2",
|