@wu529778790/open-im 1.8.1-beta.2 → 1.8.1-beta.21
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/access/access-control.js +1 -1
- package/dist/adapters/claude-sdk-adapter.js +94 -36
- package/dist/channels/capabilities.js +5 -0
- package/dist/cli.js +5 -2
- package/dist/commands/handler.d.ts +1 -2
- package/dist/commands/handler.js +6 -18
- package/dist/config-web-page-i18n.d.ts +12 -0
- package/dist/config-web-page-i18n.js +12 -0
- package/dist/config-web-page-script.js +1 -0
- package/dist/config-web-page-template.js +48 -1
- package/dist/config-web.js +110 -7
- package/dist/config.d.ts +25 -1
- package/dist/config.js +46 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/dingtalk/client.js +11 -3
- package/dist/dingtalk/event-handler.js +18 -3
- package/dist/dingtalk/message-sender.js +13 -0
- package/dist/feishu/event-handler.js +144 -10
- package/dist/index.js +26 -2
- package/dist/manager-control.js +7 -0
- package/dist/qq/client.js +111 -88
- package/dist/qq/event-handler.js +16 -2
- package/dist/qq/message-sender.js +11 -0
- package/dist/service-control.js +4 -0
- package/dist/session/session-manager.js +11 -1
- package/dist/setup.js +2 -1
- package/dist/shared/active-chats.d.ts +2 -2
- package/dist/shared/ai-task.js +13 -1
- package/dist/shared/chat-user-map.js +11 -0
- package/dist/shared/media-storage.js +27 -0
- package/dist/telegram/client.js +25 -3
- package/dist/telegram/event-handler.js +44 -8
- package/dist/telegram/message-sender.js +13 -0
- package/dist/wechat/auth/qclaw-api.js +1 -1
- package/dist/wechat/client.js +81 -4
- package/dist/wechat/event-handler.js +10 -3
- package/dist/wework/client.js +36 -14
- package/dist/wework/event-handler.js +39 -4
- package/dist/wework/message-sender.js +53 -21
- package/dist/workbuddy/centrifuge-client.d.ts +74 -0
- package/dist/workbuddy/centrifuge-client.js +272 -0
- package/dist/workbuddy/client.d.ts +27 -0
- package/dist/workbuddy/client.js +162 -0
- package/dist/workbuddy/event-handler.d.ts +11 -0
- package/dist/workbuddy/event-handler.js +118 -0
- package/dist/workbuddy/index.d.ts +8 -0
- package/dist/workbuddy/index.js +8 -0
- package/dist/workbuddy/message-sender.d.ts +16 -0
- package/dist/workbuddy/message-sender.js +51 -0
- package/dist/workbuddy/oauth.d.ts +114 -0
- package/dist/workbuddy/oauth.js +310 -0
- package/dist/workbuddy/types.d.ts +86 -0
- package/dist/workbuddy/types.js +4 -0
- package/package.json +4 -2
|
@@ -131,6 +131,33 @@ export async function saveBase64Media(base64, extension, basenameHint) {
|
|
|
131
131
|
return saveBufferMedia(Buffer.from(base64, "base64"), extension, basenameHint);
|
|
132
132
|
}
|
|
133
133
|
export async function downloadMediaFromUrl(url, options) {
|
|
134
|
+
// SSRF protection: validate URL before fetching
|
|
135
|
+
const BLOCKED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '[::1]', '169.254.169.254'];
|
|
136
|
+
let parsedUrl;
|
|
137
|
+
try {
|
|
138
|
+
parsedUrl = new URL(url);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
142
|
+
}
|
|
143
|
+
const protocol = parsedUrl.protocol.toLowerCase();
|
|
144
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
145
|
+
throw new Error(`Unsupported URL protocol: ${protocol}`);
|
|
146
|
+
}
|
|
147
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
148
|
+
for (const blocked of BLOCKED_HOSTS) {
|
|
149
|
+
if (hostname === blocked) {
|
|
150
|
+
throw new Error(`Blocked URL host: ${hostname}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Block link-local and private IPs (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
|
|
154
|
+
const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
155
|
+
if (ipMatch) {
|
|
156
|
+
const [, a, b] = ipMatch.map(Number);
|
|
157
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 0) {
|
|
158
|
+
throw new Error(`Blocked private/internal IP: ${hostname}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
134
161
|
await mkdir(IMAGE_DIR, { recursive: true });
|
|
135
162
|
const response = await fetch(url, { signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS) });
|
|
136
163
|
if (!response.ok) {
|
package/dist/telegram/client.js
CHANGED
|
@@ -20,9 +20,31 @@ export async function initTelegram(config, setupHandlers) {
|
|
|
20
20
|
setupHandlers(bot);
|
|
21
21
|
const me = (await bot.telegram.getMe());
|
|
22
22
|
botUsername = me.username;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const launchWithRetry = async (attempt = 1) => {
|
|
24
|
+
try {
|
|
25
|
+
await bot.launch();
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
log.error("Telegram polling error:", err);
|
|
29
|
+
try {
|
|
30
|
+
bot.stop("Telegram polling error");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
const maxAttempts = 10;
|
|
36
|
+
const delayMs = Math.min(5000 * attempt, 60000);
|
|
37
|
+
if (attempt < maxAttempts) {
|
|
38
|
+
log.info(`Telegram reconnect in ${Math.round(delayMs / 1000)}s (attempt ${attempt}/${maxAttempts})`);
|
|
39
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
40
|
+
return launchWithRetry(attempt + 1);
|
|
41
|
+
}
|
|
42
|
+
log.error("Telegram gave up reconnecting, skipping");
|
|
43
|
+
// 不再 exit(1),让其他通道继续运行
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
void launchWithRetry().catch((err) => {
|
|
47
|
+
log.error("Telegram launchWithRetry failed fatally:", err);
|
|
26
48
|
});
|
|
27
49
|
log.info("Telegram bot launched");
|
|
28
50
|
}
|
|
@@ -107,6 +107,12 @@ 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 (err) {
|
|
114
|
+
log.warn('Failed to send startup error reply:', err);
|
|
115
|
+
}
|
|
110
116
|
return;
|
|
111
117
|
}
|
|
112
118
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -176,7 +182,8 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
176
182
|
throttle.recordSuccess();
|
|
177
183
|
lastUpdateTime = Date.now();
|
|
178
184
|
}
|
|
179
|
-
catch {
|
|
185
|
+
catch (err) {
|
|
186
|
+
log.debug('Stream update failed:', err);
|
|
180
187
|
throttle.recordError();
|
|
181
188
|
}
|
|
182
189
|
finally {
|
|
@@ -190,7 +197,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
};
|
|
193
|
-
|
|
200
|
+
const wrapper = (content, toolNote) => {
|
|
194
201
|
if (content.startsWith("💭 **思考中...**")) {
|
|
195
202
|
return;
|
|
196
203
|
}
|
|
@@ -211,6 +218,17 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
211
218
|
performUpdate(content, toolNote);
|
|
212
219
|
}, Math.max(DEBOUNCE_MS, baseDelay));
|
|
213
220
|
};
|
|
221
|
+
// flush 排队的 debounce 更新,防止 sendComplete 时仍有 streaming 更新在排队
|
|
222
|
+
wrapper.flush = async () => {
|
|
223
|
+
if (debounceTimer) {
|
|
224
|
+
clearTimeout(debounceTimer);
|
|
225
|
+
debounceTimer = null;
|
|
226
|
+
}
|
|
227
|
+
while (updateInProgress) {
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
return wrapper;
|
|
214
232
|
};
|
|
215
233
|
const streamUpdateWrapper = createStreamUpdateWrapper();
|
|
216
234
|
await runAITask({ config, sessionManager }, {
|
|
@@ -228,12 +246,30 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
228
246
|
},
|
|
229
247
|
sendComplete: async (content, note) => {
|
|
230
248
|
throttle.reset();
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
// 先 flush 排队的 streaming 更新,防止它覆盖后续的 done 消息
|
|
250
|
+
await streamUpdateWrapper.flush?.();
|
|
251
|
+
const maxAttempts = 3;
|
|
252
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
await sendFinalMessages(chatId, msgId, content, note, toolId);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
log.error(`Failed to send complete message (attempt ${attempt}/${maxAttempts}):`, err);
|
|
259
|
+
if (attempt < maxAttempts) {
|
|
260
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// 最终失败:尝试发送纯文本作为最后手段
|
|
264
|
+
try {
|
|
265
|
+
await sendTextReply(chatId, `⚠️ 消息更新失败(网络异常),以下是 AI 回复:\n\n${content.slice(0, 4000)}`);
|
|
266
|
+
}
|
|
267
|
+
catch (fallbackErr) {
|
|
268
|
+
log.error("All send attempts failed:", fallbackErr);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
237
273
|
}
|
|
238
274
|
},
|
|
239
275
|
sendError: async (error) => {
|
|
@@ -9,6 +9,15 @@ import { MAX_TELEGRAM_MESSAGE_LENGTH } from "../constants.js";
|
|
|
9
9
|
import { listDirectories, buildDirectoryKeyboard, } from "../commands/handler.js";
|
|
10
10
|
const log = createLogger("TgSender");
|
|
11
11
|
const lastSentByMsg = new Map();
|
|
12
|
+
// Periodic cleanup of orphaned entries (entries not cleaned by done/error)
|
|
13
|
+
const LAST_SENT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
setInterval(() => {
|
|
15
|
+
// lastSentByMsg doesn't store timestamps, so clear all entries periodically
|
|
16
|
+
// since they are just dedup cache entries, clearing is safe
|
|
17
|
+
if (lastSentByMsg.size > 0) {
|
|
18
|
+
lastSentByMsg.clear();
|
|
19
|
+
}
|
|
20
|
+
}, LAST_SENT_MAX_AGE_MS);
|
|
12
21
|
const STATUS_ICONS = {
|
|
13
22
|
thinking: "🔵",
|
|
14
23
|
streaming: "🔵",
|
|
@@ -85,6 +94,10 @@ export async function updateMessage(chatId, messageId, content, status, note, to
|
|
|
85
94
|
}
|
|
86
95
|
else {
|
|
87
96
|
log.error("Failed to update message:", err);
|
|
97
|
+
// 对 done/error 状态的更新失败必须 throw,否则消息永远卡在 streaming
|
|
98
|
+
if (status === "done" || status === "error") {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
if (status === "done" || status === "error") {
|
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,15 +97,35 @@ 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;
|
|
98
104
|
}
|
|
99
105
|
updateState('connecting');
|
|
100
106
|
return new Promise((resolve, reject) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
// Connection timeout to prevent promise from hanging forever
|
|
109
|
+
const connectionTimeout = setTimeout(() => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
const err = new Error('WeChat WebSocket connection timeout');
|
|
114
|
+
log.error(err.message);
|
|
115
|
+
updateState('error');
|
|
116
|
+
try {
|
|
117
|
+
ws?.close();
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ }
|
|
120
|
+
reject(err);
|
|
121
|
+
}, 30000);
|
|
101
122
|
try {
|
|
102
123
|
ws = new WebSocket(config.url);
|
|
103
124
|
ws.on('open', () => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(connectionTimeout);
|
|
104
129
|
log.info('WeChat WebSocket connected');
|
|
105
130
|
reconnectAttempts = 0;
|
|
106
131
|
updateState('connected');
|
|
@@ -108,6 +133,7 @@ async function connectWebSocket(config) {
|
|
|
108
133
|
resolve();
|
|
109
134
|
});
|
|
110
135
|
ws.on('message', async (data) => {
|
|
136
|
+
lastServerResponseTime = Date.now();
|
|
111
137
|
try {
|
|
112
138
|
const envelope = JSON.parse(data.toString());
|
|
113
139
|
log.debug('Received AGP message:', envelope.method);
|
|
@@ -118,18 +144,33 @@ async function connectWebSocket(config) {
|
|
|
118
144
|
}
|
|
119
145
|
});
|
|
120
146
|
ws.on('error', (err) => {
|
|
147
|
+
if (settled) {
|
|
148
|
+
// Late error after connection was established — just log it
|
|
149
|
+
log.error('WeChat WebSocket error (after open):', err);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(connectionTimeout);
|
|
121
154
|
log.error('WeChat WebSocket error:', err);
|
|
122
155
|
updateState('error');
|
|
123
156
|
reject(err);
|
|
124
157
|
});
|
|
125
158
|
ws.on('close', () => {
|
|
159
|
+
clearTimeout(connectionTimeout);
|
|
126
160
|
log.info('WeChat WebSocket closed');
|
|
127
161
|
stopHeartbeat();
|
|
128
162
|
updateState('disconnected');
|
|
163
|
+
if (!settled) {
|
|
164
|
+
settled = true;
|
|
165
|
+
reject(new Error('WeChat WebSocket closed before open'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
129
168
|
scheduleReconnect(config);
|
|
130
169
|
});
|
|
131
170
|
}
|
|
132
171
|
catch (err) {
|
|
172
|
+
settled = true;
|
|
173
|
+
clearTimeout(connectionTimeout);
|
|
133
174
|
log.error('Error creating WebSocket connection:', err);
|
|
134
175
|
updateState('error');
|
|
135
176
|
reject(err);
|
|
@@ -190,11 +231,35 @@ function updateState(state) {
|
|
|
190
231
|
}
|
|
191
232
|
/**
|
|
192
233
|
* Start heartbeat to keep connection alive
|
|
234
|
+
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
193
235
|
*/
|
|
194
236
|
function startHeartbeat(interval) {
|
|
195
237
|
stopHeartbeat();
|
|
238
|
+
lastServerResponseTime = Date.now();
|
|
196
239
|
heartbeatTimer = setInterval(() => {
|
|
197
240
|
if (channelState === 'connected') {
|
|
241
|
+
// 检测连接是否已死:长时间未收到任何服务端响应
|
|
242
|
+
const elapsed = Date.now() - lastServerResponseTime;
|
|
243
|
+
const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
|
|
244
|
+
if (lastServerResponseTime > 0 && elapsed > pongTimeout) {
|
|
245
|
+
log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
246
|
+
stopHeartbeat();
|
|
247
|
+
if (ws) {
|
|
248
|
+
try {
|
|
249
|
+
ws.removeAllListeners();
|
|
250
|
+
ws.close();
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
/* ignore */
|
|
254
|
+
}
|
|
255
|
+
ws = null;
|
|
256
|
+
}
|
|
257
|
+
updateState('disconnected');
|
|
258
|
+
if (wsConfigRef) {
|
|
259
|
+
scheduleReconnect(wsConfigRef);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
198
263
|
sendAGPMessage('ping', { timestamp: Date.now() });
|
|
199
264
|
}
|
|
200
265
|
}, interval);
|
|
@@ -210,17 +275,28 @@ function stopHeartbeat() {
|
|
|
210
275
|
}
|
|
211
276
|
/**
|
|
212
277
|
* Schedule reconnection attempt
|
|
278
|
+
* 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
|
|
213
279
|
*/
|
|
214
280
|
function scheduleReconnect(config) {
|
|
281
|
+
if (isStopping)
|
|
282
|
+
return;
|
|
215
283
|
const maxAttempts = config.maxReconnectAttempts ?? 10;
|
|
216
|
-
if (
|
|
217
|
-
log.error('Max reconnect attempts reached');
|
|
284
|
+
if (reconnectTimer) {
|
|
218
285
|
return;
|
|
219
286
|
}
|
|
220
|
-
|
|
287
|
+
// 超过最大重试次数后重置计数器,降低频率继续重试
|
|
288
|
+
if (reconnectAttempts >= maxAttempts) {
|
|
289
|
+
log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter and retrying at lower frequency`);
|
|
290
|
+
reconnectAttempts = 0;
|
|
291
|
+
}
|
|
292
|
+
const baseInterval = config.reconnectInterval ?? 5000;
|
|
293
|
+
// 超过一半次数后逐渐增加间隔,最大 60 秒
|
|
294
|
+
const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(reconnectAttempts / 3)), 60000);
|
|
295
|
+
const interval = Math.round(backoff);
|
|
221
296
|
reconnectTimer = setTimeout(async () => {
|
|
297
|
+
reconnectTimer = null;
|
|
222
298
|
reconnectAttempts++;
|
|
223
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts}`);
|
|
299
|
+
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts} (interval: ${interval}ms)`);
|
|
224
300
|
try {
|
|
225
301
|
await connectWebSocket(config);
|
|
226
302
|
}
|
|
@@ -274,6 +350,7 @@ function saveToken() {
|
|
|
274
350
|
* Stop WeChat client
|
|
275
351
|
*/
|
|
276
352
|
export function stopWeChat() {
|
|
353
|
+
isStopping = true;
|
|
277
354
|
stopHeartbeat();
|
|
278
355
|
if (reconnectTimer) {
|
|
279
356
|
clearTimeout(reconnectTimer);
|
|
@@ -53,7 +53,8 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
53
53
|
}
|
|
54
54
|
return parsed;
|
|
55
55
|
}
|
|
56
|
-
catch {
|
|
56
|
+
catch (err) {
|
|
57
|
+
log.debug('Failed to parse WeChat incoming message JSON:', err);
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -90,8 +91,8 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
90
91
|
text: contextText,
|
|
91
92
|
});
|
|
92
93
|
}
|
|
93
|
-
catch {
|
|
94
|
-
|
|
94
|
+
catch (err) {
|
|
95
|
+
log.warn('Failed to download WeChat media, falling back to metadata-only prompt:', err);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
return buildMediaMetadataPrompt({
|
|
@@ -130,6 +131,12 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
130
131
|
}
|
|
131
132
|
catch (err) {
|
|
132
133
|
log.error('Failed to send thinking message:', err);
|
|
134
|
+
try {
|
|
135
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
log.warn('Failed to send startup error reply:', err);
|
|
139
|
+
}
|
|
133
140
|
return;
|
|
134
141
|
}
|
|
135
142
|
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
|
}
|
|
@@ -21,6 +21,8 @@ import { buildMediaContext } from '../shared/media-context.js';
|
|
|
21
21
|
import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
|
|
22
22
|
const log = createLogger('WeWorkHandler');
|
|
23
23
|
const WEWORK_MEDIA_TIMEOUT_MS = 60_000;
|
|
24
|
+
// Safety timeout: abort hung tasks before stream expires (5 min TTL → 4.5 min safety)
|
|
25
|
+
const WEWORK_TASK_SAFETY_TIMEOUT_MS = 4.5 * 60 * 1000;
|
|
24
26
|
async function saveWeWorkUrlMedia(payload, fallbackExtension) {
|
|
25
27
|
if (!payload.url) {
|
|
26
28
|
throw new Error("Missing WeWork media URL");
|
|
@@ -119,7 +121,8 @@ export async function buildMediaPrompt(data, kind) {
|
|
|
119
121
|
text: contextText,
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
|
-
catch {
|
|
124
|
+
catch (err) {
|
|
125
|
+
log.warn('Failed to download WeWork image, falling back to URL reference:', err);
|
|
123
126
|
imageReference = `Remote image URL: ${imagePayload.url}`;
|
|
124
127
|
}
|
|
125
128
|
}
|
|
@@ -144,8 +147,8 @@ export async function buildMediaPrompt(data, kind) {
|
|
|
144
147
|
text: contextText,
|
|
145
148
|
});
|
|
146
149
|
}
|
|
147
|
-
catch {
|
|
148
|
-
|
|
150
|
+
catch (err) {
|
|
151
|
+
log.warn('Failed to download WeWork media, falling back to metadata-only prompt:', err);
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
return buildMediaMetadataPrompt({
|
|
@@ -185,9 +188,40 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
185
188
|
: undefined;
|
|
186
189
|
log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
|
|
187
190
|
const toolId = aiCommand;
|
|
188
|
-
|
|
191
|
+
let msgId;
|
|
192
|
+
try {
|
|
193
|
+
msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
log.error('Failed to send thinking message:', err);
|
|
197
|
+
try {
|
|
198
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
log.warn('Failed to send startup error reply:', err);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
189
205
|
const stopTyping = startTypingLoop(chatId);
|
|
190
206
|
const taskKey = `${userId}:${msgId}`;
|
|
207
|
+
// Safety timeout: abort hung tasks before stream expires, unblocking the queue
|
|
208
|
+
let safetyTimer = setTimeout(() => {
|
|
209
|
+
safetyTimer = null;
|
|
210
|
+
const state = runningTasks.get(taskKey);
|
|
211
|
+
if (state) {
|
|
212
|
+
log.warn(`[SAFETY_TIMEOUT] Task ${taskKey} exceeded ${WEWORK_TASK_SAFETY_TIMEOUT_MS}ms, aborting`);
|
|
213
|
+
state.handle.abort();
|
|
214
|
+
runningTasks.delete(taskKey);
|
|
215
|
+
stopTyping();
|
|
216
|
+
sendTextReply(chatId, `AI 处理超时(${Math.round(WEWORK_TASK_SAFETY_TIMEOUT_MS / 1000)}s),已自动取消。请重试。`, reqId).catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
}, WEWORK_TASK_SAFETY_TIMEOUT_MS);
|
|
219
|
+
const clearSafetyTimer = () => {
|
|
220
|
+
if (safetyTimer) {
|
|
221
|
+
clearTimeout(safetyTimer);
|
|
222
|
+
safetyTimer = null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
191
225
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
|
|
192
226
|
throttleMs: WEWORK_THROTTLE_MS,
|
|
193
227
|
streamUpdate: async (content, toolNote) => {
|
|
@@ -206,6 +240,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
206
240
|
await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
|
|
207
241
|
},
|
|
208
242
|
extraCleanup: () => {
|
|
243
|
+
clearSafetyTimer();
|
|
209
244
|
stopTyping();
|
|
210
245
|
runningTasks.delete(taskKey);
|
|
211
246
|
},
|