@wu529778790/open-im 1.11.8-beta.8 → 1.11.8
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/README.md +23 -5
- package/dist/adapters/claude-sdk-adapter.d.ts +1 -0
- package/dist/adapters/claude-sdk-adapter.js +1 -0
- package/dist/adapters/codebuddy-adapter.d.ts +1 -0
- package/dist/adapters/codebuddy-adapter.js +1 -0
- package/dist/adapters/codex-adapter.d.ts +1 -0
- package/dist/adapters/codex-adapter.js +1 -0
- package/dist/adapters/opencode-adapter.d.ts +1 -0
- package/dist/adapters/opencode-adapter.js +1 -0
- package/dist/adapters/tool-adapter.interface.d.ts +2 -0
- package/dist/clawbot/client.js +45 -79
- package/dist/clawbot/qr-login.d.ts +1 -0
- package/dist/clawbot/qr-login.js +7 -0
- package/dist/codex/cli-runner.js +72 -24
- package/dist/config/types.d.ts +2 -1
- package/dist/config-web-page-i18n.d.ts +30 -0
- package/dist/config-web-page-i18n.js +30 -0
- package/dist/config-web-payload.d.ts +1 -0
- package/dist/config-web-payload.js +2 -0
- package/dist/config-web.js +28 -1
- package/dist/config.js +8 -3
- package/dist/qq/client.js +45 -53
- package/dist/shared/ai-task.js +20 -14
- package/dist/shared/connection-manager.d.ts +111 -0
- package/dist/shared/connection-manager.js +157 -0
- package/dist/telegram/event-handler.js +6 -16
- package/dist/wework/client.js +79 -127
- package/dist/workbuddy/client.js +40 -74
- package/package.json +3 -1
- package/web/dist/assets/index-DZiLymq8.js +57 -0
- package/web/dist/assets/index-u3TxtMZ1.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BpPiH_ku.js +0 -57
- package/web/dist/assets/index-CFRrY7hP.css +0 -1
|
@@ -92,6 +92,7 @@ export function buildInitialPayload(file) {
|
|
|
92
92
|
claudeBaseUrl: claudeEnv.ANTHROPIC_BASE_URL ?? "",
|
|
93
93
|
claudeModel: claudeEnv.ANTHROPIC_MODEL ?? "",
|
|
94
94
|
claudeProxy: file.tools?.claude?.proxy ?? "",
|
|
95
|
+
claudeSkipPermissions: file.tools?.claude?.skipPermissions ?? false,
|
|
95
96
|
codexCliPath: file.tools?.codex?.cliPath ?? "codex",
|
|
96
97
|
codebuddyCliPath: file.tools?.codebuddy?.cliPath ?? "codebuddy",
|
|
97
98
|
opencodeCliPath: file.tools?.opencode?.cliPath ?? "opencode",
|
|
@@ -177,6 +178,7 @@ export function toFileConfig(payload, existing) {
|
|
|
177
178
|
...existing.tools?.claude,
|
|
178
179
|
workDir: clean(payload.ai.claudeWorkDir) ?? process.cwd(),
|
|
179
180
|
proxy: clean(payload.ai.claudeProxy),
|
|
181
|
+
skipPermissions: payload.ai.claudeSkipPermissions,
|
|
180
182
|
// model is now saved to ~/.claude/settings.json as env var
|
|
181
183
|
},
|
|
182
184
|
codex: {
|
package/dist/config-web.js
CHANGED
|
@@ -355,7 +355,7 @@ export async function startWebConfigServer(options) {
|
|
|
355
355
|
try {
|
|
356
356
|
const { startQRLogin } = await import("./clawbot/qr-login.js");
|
|
357
357
|
const session = await startQRLogin();
|
|
358
|
-
json(response, 200, { success: true, qrcodeUrl: session.qrcodeUrl, sessionKey: session.sessionKey }, request);
|
|
358
|
+
json(response, 200, { success: true, qrcodeUrl: session.qrcodeUrl, qrcodeImage: session.qrcodeImage, qrcode: session.qrcode, sessionKey: session.sessionKey }, request);
|
|
359
359
|
}
|
|
360
360
|
catch (error) {
|
|
361
361
|
json(response, 500, { success: false, error: toErrorMessage(error) }, request);
|
|
@@ -375,6 +375,33 @@ export async function startWebConfigServer(options) {
|
|
|
375
375
|
}
|
|
376
376
|
return;
|
|
377
377
|
}
|
|
378
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/workbuddy/qr-login/start") {
|
|
379
|
+
try {
|
|
380
|
+
const { WorkBuddyOAuth } = await import("./workbuddy/oauth.js");
|
|
381
|
+
const QRCode = (await import("qrcode")).default;
|
|
382
|
+
const oauth = new WorkBuddyOAuth();
|
|
383
|
+
const { authUrl, state } = await oauth.fetchAuthState();
|
|
384
|
+
const qrcodeImage = await QRCode.toDataURL(authUrl, { width: 280, margin: 1 });
|
|
385
|
+
json(response, 200, { success: true, qrcodeImage, state, authUrl }, request);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
json(response, 500, { success: false, error: toErrorMessage(error) }, request);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/workbuddy/qr-login/wait") {
|
|
393
|
+
try {
|
|
394
|
+
const body = await readJson(request);
|
|
395
|
+
const { WorkBuddyOAuth } = await import("./workbuddy/oauth.js");
|
|
396
|
+
const oauth = new WorkBuddyOAuth();
|
|
397
|
+
const result = await oauth.pollToken(body.state);
|
|
398
|
+
json(response, 200, { success: true, accessToken: result.accessToken, refreshToken: result.refreshToken, userId: result.userId }, request);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
json(response, 500, { success: false, error: toErrorMessage(error) }, request);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
378
405
|
if (request.method === "GET" && tryServeDashboardStatic(requestUrl, request, response, mergeCors)) {
|
|
379
406
|
return;
|
|
380
407
|
}
|
package/dist/config.js
CHANGED
|
@@ -246,9 +246,14 @@ export function loadConfig() {
|
|
|
246
246
|
const opencodeCliPath = process.env.OPENCODE_CLI_PATH ?? topencode.cliPath ?? resolveWindowsCliPath(AI_TOOL_BY_ID.opencode.cliDefault ?? 'opencode');
|
|
247
247
|
const opencodeModel = process.env.OPENCODE_MODEL ?? topencode.model;
|
|
248
248
|
const claudeWorkDir = process.env.CLAUDE_WORK_DIR ?? tc.workDir ?? process.cwd();
|
|
249
|
-
const skipPermissions =
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
const skipPermissions = (() => {
|
|
250
|
+
const raw = process.env.OPEN_IM_SKIP_PERMISSIONS?.trim().toLowerCase();
|
|
251
|
+
if (raw === 'true' || raw === '1' || raw === 'yes')
|
|
252
|
+
return true;
|
|
253
|
+
if (raw === 'false' || raw === '0' || raw === 'no')
|
|
254
|
+
return false;
|
|
255
|
+
return tc.skipPermissions;
|
|
256
|
+
})();
|
|
252
257
|
const envIdleRaw = process.env.OPEN_IM_CLAUDE_SESSION_IDLE_TTL_MINUTES;
|
|
253
258
|
const envIdleParsed = envIdleRaw !== undefined && envIdleRaw !== '' ? Number.parseInt(envIdleRaw, 10) : NaN;
|
|
254
259
|
const fileIdle = tc.sessionIdleTtlMinutes;
|
package/dist/qq/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ReconnectManager, HeartbeatMonitor } from "../shared/connection-manager.js";
|
|
4
4
|
const log = createLogger("QQ");
|
|
5
5
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
6
6
|
const API_BASE = "https://api.sgroup.qq.com";
|
|
@@ -12,25 +12,23 @@ const INTENTS = {
|
|
|
12
12
|
};
|
|
13
13
|
let client = null;
|
|
14
14
|
let ws = null;
|
|
15
|
-
let heartbeatTimer = null;
|
|
16
|
-
let reconnectTimer = null;
|
|
17
15
|
let stopped = false;
|
|
18
16
|
let seq = null;
|
|
19
17
|
let sessionId = null;
|
|
20
|
-
let reconnectAttempt = 0;
|
|
21
18
|
let connecting = false; // 防止并发 connectWebSocket
|
|
22
19
|
let currentConfig = null;
|
|
23
20
|
let currentHandler = null;
|
|
24
21
|
let tokenState = null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
23
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
24
|
+
const reconnectManager = new ReconnectManager({
|
|
25
|
+
name: 'QQ',
|
|
26
|
+
backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
|
|
27
|
+
onReconnect: () => reconnectFn(),
|
|
28
|
+
});
|
|
29
|
+
async function reconnectFn() {
|
|
30
|
+
if (currentConfig && currentHandler) {
|
|
31
|
+
await connectWebSocket(currentConfig, currentHandler);
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
function buildAuthHeaders(token) {
|
|
@@ -143,28 +141,28 @@ function normalizeInboundEvent(payload) {
|
|
|
143
141
|
}
|
|
144
142
|
return null;
|
|
145
143
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
144
|
+
/**
|
|
145
|
+
* Heartbeat tick: send ping and detect stale connections.
|
|
146
|
+
* The heartbeat interval is dynamic (from QQ gateway HELLO message).
|
|
147
|
+
*/
|
|
148
|
+
let heartbeatIntervalMs = 30000;
|
|
149
|
+
function heartbeatTick() {
|
|
150
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
151
|
+
return;
|
|
152
|
+
if (heartbeatMonitor.isStale(heartbeatIntervalMs * 3)) {
|
|
153
|
+
const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
|
|
154
|
+
log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
|
|
155
|
+
heartbeatMonitor.stop();
|
|
156
|
+
ws?.terminate();
|
|
157
|
+
reconnectFn().catch((err) => log.error('QQ reconnect from heartbeat failed:', err));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
ws.send(JSON.stringify({ op: 1, d: seq }));
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
log.warn('QQ heartbeat send failed:', err);
|
|
165
|
+
}
|
|
168
166
|
}
|
|
169
167
|
async function connectWebSocket(config, handler) {
|
|
170
168
|
// 防止并发连接
|
|
@@ -196,17 +194,17 @@ async function connectWebSocket(config, handler) {
|
|
|
196
194
|
};
|
|
197
195
|
socket.on("open", () => {
|
|
198
196
|
log.info("QQ gateway connected");
|
|
199
|
-
|
|
197
|
+
reconnectManager.reset();
|
|
200
198
|
});
|
|
201
199
|
socket.on("message", async (raw) => {
|
|
202
|
-
|
|
200
|
+
heartbeatMonitor.recordResponse();
|
|
203
201
|
try {
|
|
204
202
|
const payload = JSON.parse(raw.toString());
|
|
205
203
|
if (typeof payload.s === "number")
|
|
206
204
|
seq = payload.s;
|
|
207
205
|
if (payload.op === 10) {
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
heartbeatIntervalMs = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
207
|
+
heartbeatMonitor.start(heartbeatIntervalMs, heartbeatTick);
|
|
210
208
|
socket.send(JSON.stringify({
|
|
211
209
|
op: sessionId ? 6 : 2,
|
|
212
210
|
d: sessionId
|
|
@@ -253,7 +251,7 @@ async function connectWebSocket(config, handler) {
|
|
|
253
251
|
});
|
|
254
252
|
socket.on("close", (code, reason) => {
|
|
255
253
|
settle(() => { }); // 清理 ready timeout
|
|
256
|
-
|
|
254
|
+
heartbeatMonitor.stop();
|
|
257
255
|
ws = null;
|
|
258
256
|
const reasonStr = reason.toString();
|
|
259
257
|
if (code === 4009) {
|
|
@@ -273,19 +271,11 @@ async function connectWebSocket(config, handler) {
|
|
|
273
271
|
seq = null;
|
|
274
272
|
}
|
|
275
273
|
const isFatalClose = code === 4004 || code === 4006 || code === 4007;
|
|
276
|
-
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
277
|
-
const delay = isFatalClose ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
278
274
|
if (isFatalClose) {
|
|
279
|
-
|
|
275
|
+
reconnectManager.setFatal(true);
|
|
276
|
+
log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
|
|
280
277
|
}
|
|
281
|
-
|
|
282
|
-
reconnectTimer = setTimeout(() => {
|
|
283
|
-
if (currentConfig && currentHandler) {
|
|
284
|
-
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
285
|
-
log.error("QQ reconnect failed:", err);
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}, delay);
|
|
278
|
+
reconnectManager.schedule();
|
|
289
279
|
});
|
|
290
280
|
});
|
|
291
281
|
}
|
|
@@ -306,6 +296,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
306
296
|
stopped = false;
|
|
307
297
|
currentConfig = config;
|
|
308
298
|
currentHandler = eventHandler;
|
|
299
|
+
reconnectManager.reset();
|
|
300
|
+
reconnectManager.resume();
|
|
309
301
|
client = {
|
|
310
302
|
sendPrivateMessage: async (openid, content, replyToMessageId) => {
|
|
311
303
|
const res = await apiRequest(config, "POST", `/v2/users/${openid}/messages`, buildMessageBody(content, replyToMessageId));
|
|
@@ -328,7 +320,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
328
320
|
}
|
|
329
321
|
export async function stopQQ() {
|
|
330
322
|
stopped = true;
|
|
331
|
-
|
|
323
|
+
heartbeatMonitor.stop();
|
|
324
|
+
reconnectManager.stop();
|
|
332
325
|
if (ws) {
|
|
333
326
|
ws.close(1000);
|
|
334
327
|
ws = null;
|
|
@@ -339,5 +332,4 @@ export async function stopQQ() {
|
|
|
339
332
|
tokenState = null;
|
|
340
333
|
sessionId = null;
|
|
341
334
|
seq = null;
|
|
342
|
-
lastServerResponseTime = 0;
|
|
343
335
|
}
|
package/dist/shared/ai-task.js
CHANGED
|
@@ -180,6 +180,22 @@ function buildCompletionNote(result, sessionManager, ctx) {
|
|
|
180
180
|
parts.push(ctxWarning);
|
|
181
181
|
return parts.join(' | ');
|
|
182
182
|
}
|
|
183
|
+
function buildRunOptions(config, sessionManager, ctx, aiCommand, toolAdapter) {
|
|
184
|
+
const defaultSkipPermissions = toolAdapter.interactionMode === 'native'
|
|
185
|
+
? (config.skipPermissions ?? false)
|
|
186
|
+
: (config.skipPermissions ?? true);
|
|
187
|
+
return {
|
|
188
|
+
model: aiCommand === 'claude'
|
|
189
|
+
? (sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel)
|
|
190
|
+
: aiCommand === 'opencode'
|
|
191
|
+
? config.opencodeModel
|
|
192
|
+
: undefined,
|
|
193
|
+
chatId: ctx.chatId,
|
|
194
|
+
skipPermissions: defaultSkipPermissions,
|
|
195
|
+
skipAutoResume: sessionManager.isFreshSession(ctx.userId),
|
|
196
|
+
...(aiCommand === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
183
199
|
export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
184
200
|
const { config, sessionManager } = deps;
|
|
185
201
|
return new Promise((resolve) => {
|
|
@@ -295,7 +311,9 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
295
311
|
toolLines.push(notification);
|
|
296
312
|
if (toolLines.length > 5)
|
|
297
313
|
toolLines.shift();
|
|
298
|
-
|
|
314
|
+
// 不强制发送(force=false),让节流机制合并短时间内的多次工具调用,
|
|
315
|
+
// 避免突发大量消息触发平台频率限制(如 ClawBot ret=-2)。
|
|
316
|
+
throttledUpdate(taskState.latestContent, false);
|
|
299
317
|
},
|
|
300
318
|
onComplete: async (result) => {
|
|
301
319
|
log.info(`[AITask] onComplete fired: settled=${settled}, success=${result.success}, platform=${ctx.platform}, taskKey=${ctx.taskKey}`);
|
|
@@ -457,19 +475,7 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
457
475
|
cleanup();
|
|
458
476
|
resolve();
|
|
459
477
|
},
|
|
460
|
-
},
|
|
461
|
-
model: aiCommand === 'claude'
|
|
462
|
-
? (sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel)
|
|
463
|
-
: aiCommand === 'opencode'
|
|
464
|
-
? config.opencodeModel
|
|
465
|
-
: undefined,
|
|
466
|
-
chatId: ctx.chatId,
|
|
467
|
-
// 默认跳过权限确认,保持全自动执行(可通过 config 或环境变量关闭)
|
|
468
|
-
skipPermissions: config.skipPermissions ?? true,
|
|
469
|
-
// /new 后跳过自动恢复 CLI session
|
|
470
|
-
skipAutoResume: sessionManager.isFreshSession(ctx.userId),
|
|
471
|
-
...(aiCommand === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
|
|
472
|
-
});
|
|
478
|
+
}, buildRunOptions(config, sessionManager, ctx, aiCommand, toolAdapter));
|
|
473
479
|
return activeHandle;
|
|
474
480
|
};
|
|
475
481
|
taskState = {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection management infrastructure for self-managing platform clients.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
|
|
5
|
+
* qq, and workbuddy clients:
|
|
6
|
+
*
|
|
7
|
+
* - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
|
|
8
|
+
* - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
|
|
9
|
+
* - **StateManager**: state variable with change-notification callback
|
|
10
|
+
*/
|
|
11
|
+
export interface ReconnectManagerConfig {
|
|
12
|
+
/** Platform name used in log messages. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Delay strategy:
|
|
16
|
+
* - `{ mode: 'exponential', baseMs, maxMs, cap }` — exponential backoff
|
|
17
|
+
* `min(baseMs * 1.5^floor(attempt / cap), maxMs)`.
|
|
18
|
+
* - `{ mode: 'stepped', delays }` — stepped array; last entry repeats.
|
|
19
|
+
*/
|
|
20
|
+
backoff: {
|
|
21
|
+
mode: 'exponential';
|
|
22
|
+
baseMs: number;
|
|
23
|
+
maxMs: number;
|
|
24
|
+
cap?: number;
|
|
25
|
+
} | {
|
|
26
|
+
mode: 'stepped';
|
|
27
|
+
delays: number[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* After this many attempts the counter resets and retries continue
|
|
31
|
+
* (avoids permanent disconnection). 0 = unlimited (no reset).
|
|
32
|
+
*/
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
/** Callback invoked after the computed delay. */
|
|
35
|
+
onReconnect: () => void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
|
|
39
|
+
*
|
|
40
|
+
* Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
|
|
41
|
+
* timer cleanup, attempt counting, backoff computation, jitter, logging.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ReconnectManager {
|
|
44
|
+
private timer;
|
|
45
|
+
private attempt;
|
|
46
|
+
private _fatal;
|
|
47
|
+
private _stopped;
|
|
48
|
+
private readonly log;
|
|
49
|
+
private readonly cfg;
|
|
50
|
+
constructor(cfg: ReconnectManagerConfig);
|
|
51
|
+
get attemptCount(): number;
|
|
52
|
+
get fatal(): boolean;
|
|
53
|
+
get stopped(): boolean;
|
|
54
|
+
/** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
|
|
55
|
+
setFatal(value: boolean): void;
|
|
56
|
+
/** Reset attempt counter and fatal flag after a successful connection. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/**
|
|
59
|
+
* Schedule a reconnect attempt. Clears any pending timer, computes the
|
|
60
|
+
* backoff delay (with jitter), and invokes `onReconnect` after the delay.
|
|
61
|
+
*/
|
|
62
|
+
schedule(): void;
|
|
63
|
+
/** Cancel any pending reconnect timer. */
|
|
64
|
+
stop(): void;
|
|
65
|
+
/** Allow scheduling again (undo `stop`). */
|
|
66
|
+
resume(): void;
|
|
67
|
+
private clearTimer;
|
|
68
|
+
private computeBaseDelay;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Manages a setInterval-based heartbeat or watchdog timer.
|
|
72
|
+
*
|
|
73
|
+
* Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
|
|
74
|
+
* timer cleanup on re-start, clearInterval on stop.
|
|
75
|
+
*/
|
|
76
|
+
export declare class HeartbeatMonitor {
|
|
77
|
+
private timer;
|
|
78
|
+
private _lastResponseTime;
|
|
79
|
+
get lastResponseTime(): number;
|
|
80
|
+
/** Record that a response was received (for watchdog stale detection). */
|
|
81
|
+
recordResponse(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Start a periodic callback. Clears any existing timer first.
|
|
84
|
+
* @param intervalMs Interval in milliseconds.
|
|
85
|
+
* @param onTick Callback invoked on each tick.
|
|
86
|
+
*/
|
|
87
|
+
start(intervalMs: number, onTick: () => void | Promise<void>): void;
|
|
88
|
+
/** Clear the heartbeat timer. */
|
|
89
|
+
stop(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Watchdog convenience: returns true if more than `staleMs` have elapsed
|
|
92
|
+
* since the last `recordResponse()` call.
|
|
93
|
+
*/
|
|
94
|
+
isStale(staleMs: number): boolean;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Manages a connection state variable with change-notification callback.
|
|
98
|
+
*
|
|
99
|
+
* Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
|
|
100
|
+
* patterns across wework, clawbot, and workbuddy clients.
|
|
101
|
+
*/
|
|
102
|
+
export declare class StateManager<T extends string> {
|
|
103
|
+
private _state;
|
|
104
|
+
private onChange?;
|
|
105
|
+
constructor(initialState: T);
|
|
106
|
+
get current(): T;
|
|
107
|
+
/** Register a callback invoked whenever the state changes. */
|
|
108
|
+
setOnChange(handler: ((state: T) => void) | undefined): void;
|
|
109
|
+
/** Transition to a new state and notify the callback if it changed. */
|
|
110
|
+
set(newState: T): void;
|
|
111
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection management infrastructure for self-managing platform clients.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
|
|
5
|
+
* qq, and workbuddy clients:
|
|
6
|
+
*
|
|
7
|
+
* - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
|
|
8
|
+
* - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
|
|
9
|
+
* - **StateManager**: state variable with change-notification callback
|
|
10
|
+
*/
|
|
11
|
+
import { createLogger } from '../logger.js';
|
|
12
|
+
import { jitteredDelay, SLOW_PROBE_MS } from './reconnect.js';
|
|
13
|
+
/**
|
|
14
|
+
* Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
|
|
15
|
+
*
|
|
16
|
+
* Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
|
|
17
|
+
* timer cleanup, attempt counting, backoff computation, jitter, logging.
|
|
18
|
+
*/
|
|
19
|
+
export class ReconnectManager {
|
|
20
|
+
timer = null;
|
|
21
|
+
attempt = 0;
|
|
22
|
+
_fatal = false;
|
|
23
|
+
_stopped = false;
|
|
24
|
+
log;
|
|
25
|
+
cfg;
|
|
26
|
+
constructor(cfg) {
|
|
27
|
+
this.cfg = cfg;
|
|
28
|
+
this.log = createLogger(`${cfg.name}/Reconnect`);
|
|
29
|
+
}
|
|
30
|
+
get attemptCount() { return this.attempt; }
|
|
31
|
+
get fatal() { return this._fatal; }
|
|
32
|
+
get stopped() { return this._stopped; }
|
|
33
|
+
/** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
|
|
34
|
+
setFatal(value) { this._fatal = value; }
|
|
35
|
+
/** Reset attempt counter and fatal flag after a successful connection. */
|
|
36
|
+
reset() {
|
|
37
|
+
this.attempt = 0;
|
|
38
|
+
this._fatal = false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Schedule a reconnect attempt. Clears any pending timer, computes the
|
|
42
|
+
* backoff delay (with jitter), and invokes `onReconnect` after the delay.
|
|
43
|
+
*/
|
|
44
|
+
schedule() {
|
|
45
|
+
this.clearTimer();
|
|
46
|
+
if (this._stopped)
|
|
47
|
+
return;
|
|
48
|
+
const max = this.cfg.maxAttempts;
|
|
49
|
+
if (max && max > 0 && this.attempt >= max) {
|
|
50
|
+
this.log.warn(`Max reconnect attempts (${max}) reached, resetting counter`);
|
|
51
|
+
this.attempt = 0;
|
|
52
|
+
}
|
|
53
|
+
const baseDelay = this.computeBaseDelay();
|
|
54
|
+
const delay = this._fatal ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
55
|
+
this.attempt++;
|
|
56
|
+
const fatalTag = this._fatal ? ' [slow-probe]' : '';
|
|
57
|
+
this.log.info(`Reconnecting in ${delay}ms (attempt ${this.attempt})${fatalTag}...`);
|
|
58
|
+
this.timer = setTimeout(async () => {
|
|
59
|
+
this.timer = null;
|
|
60
|
+
try {
|
|
61
|
+
await this.cfg.onReconnect();
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
this.log.warn(`Reconnect callback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}, delay);
|
|
67
|
+
}
|
|
68
|
+
/** Cancel any pending reconnect timer. */
|
|
69
|
+
stop() {
|
|
70
|
+
this._stopped = true;
|
|
71
|
+
this.clearTimer();
|
|
72
|
+
}
|
|
73
|
+
/** Allow scheduling again (undo `stop`). */
|
|
74
|
+
resume() {
|
|
75
|
+
this._stopped = false;
|
|
76
|
+
}
|
|
77
|
+
clearTimer() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearTimeout(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
computeBaseDelay() {
|
|
84
|
+
const { backoff } = this.cfg;
|
|
85
|
+
if (backoff.mode === 'exponential') {
|
|
86
|
+
const cap = backoff.cap ?? 5;
|
|
87
|
+
return Math.min(backoff.baseMs * Math.pow(1.5, Math.floor(this.attempt / cap)), backoff.maxMs);
|
|
88
|
+
}
|
|
89
|
+
const { delays } = backoff;
|
|
90
|
+
return delays[Math.min(this.attempt, delays.length - 1)];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ── HeartbeatMonitor ─────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Manages a setInterval-based heartbeat or watchdog timer.
|
|
96
|
+
*
|
|
97
|
+
* Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
|
|
98
|
+
* timer cleanup on re-start, clearInterval on stop.
|
|
99
|
+
*/
|
|
100
|
+
export class HeartbeatMonitor {
|
|
101
|
+
timer = null;
|
|
102
|
+
_lastResponseTime = 0;
|
|
103
|
+
get lastResponseTime() { return this._lastResponseTime; }
|
|
104
|
+
/** Record that a response was received (for watchdog stale detection). */
|
|
105
|
+
recordResponse() { this._lastResponseTime = Date.now(); }
|
|
106
|
+
/**
|
|
107
|
+
* Start a periodic callback. Clears any existing timer first.
|
|
108
|
+
* @param intervalMs Interval in milliseconds.
|
|
109
|
+
* @param onTick Callback invoked on each tick.
|
|
110
|
+
*/
|
|
111
|
+
start(intervalMs, onTick) {
|
|
112
|
+
this.stop();
|
|
113
|
+
this.timer = setInterval(() => {
|
|
114
|
+
Promise.resolve(onTick()).catch(() => { });
|
|
115
|
+
}, intervalMs);
|
|
116
|
+
}
|
|
117
|
+
/** Clear the heartbeat timer. */
|
|
118
|
+
stop() {
|
|
119
|
+
if (this.timer) {
|
|
120
|
+
clearInterval(this.timer);
|
|
121
|
+
this.timer = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Watchdog convenience: returns true if more than `staleMs` have elapsed
|
|
126
|
+
* since the last `recordResponse()` call.
|
|
127
|
+
*/
|
|
128
|
+
isStale(staleMs) {
|
|
129
|
+
return this._lastResponseTime > 0 && Date.now() - this._lastResponseTime > staleMs;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── StateManager ─────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Manages a connection state variable with change-notification callback.
|
|
135
|
+
*
|
|
136
|
+
* Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
|
|
137
|
+
* patterns across wework, clawbot, and workbuddy clients.
|
|
138
|
+
*/
|
|
139
|
+
export class StateManager {
|
|
140
|
+
_state;
|
|
141
|
+
onChange;
|
|
142
|
+
constructor(initialState) {
|
|
143
|
+
this._state = initialState;
|
|
144
|
+
}
|
|
145
|
+
get current() { return this._state; }
|
|
146
|
+
/** Register a callback invoked whenever the state changes. */
|
|
147
|
+
setOnChange(handler) {
|
|
148
|
+
this.onChange = handler;
|
|
149
|
+
}
|
|
150
|
+
/** Transition to a new state and notify the callback if it changed. */
|
|
151
|
+
set(newState) {
|
|
152
|
+
if (this._state === newState)
|
|
153
|
+
return;
|
|
154
|
+
this._state = newState;
|
|
155
|
+
this.onChange?.(newState);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -61,14 +61,14 @@ async function downloadTelegramFile(bot, fileId, basenameHint, fallbackExtension
|
|
|
61
61
|
}
|
|
62
62
|
export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
63
63
|
// Create shared platform event context
|
|
64
|
-
const
|
|
64
|
+
const platformCtx = createPlatformEventContext({
|
|
65
65
|
platform: 'telegram',
|
|
66
66
|
allowedUserIds: config.telegramAllowedUserIds,
|
|
67
67
|
config,
|
|
68
68
|
sessionManager,
|
|
69
69
|
sender: { sendTextReply, sendDirectorySelection },
|
|
70
70
|
});
|
|
71
|
-
const { accessControl, requestQueue, runningTasks } =
|
|
71
|
+
const { accessControl, requestQueue, runningTasks } = platformCtx;
|
|
72
72
|
// Telegram-specific sender callbacks for the factory
|
|
73
73
|
const telegramSender = {
|
|
74
74
|
sendThinkingMessage: async (chatId, replyToMessageId, toolId) => {
|
|
@@ -316,24 +316,14 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
316
316
|
if (parts.length >= 3) {
|
|
317
317
|
const choiceNum = parts[2];
|
|
318
318
|
const chatId = String(ctx.chat?.id ?? "");
|
|
319
|
-
// 将用户选择作为消息发送给 AI
|
|
320
|
-
const { handleTextFlow } = await import("../platform/handle-text-flow.js");
|
|
321
319
|
await handleTextFlow({
|
|
322
320
|
platform: "telegram",
|
|
323
321
|
userId,
|
|
324
322
|
chatId,
|
|
325
323
|
text: choiceNum,
|
|
326
|
-
ctx:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
config,
|
|
330
|
-
sessionManager,
|
|
331
|
-
sender: { sendTextReply: async () => { } },
|
|
332
|
-
}),
|
|
333
|
-
handleAIRequest: async () => { },
|
|
334
|
-
sendTextReply: async (c, t) => {
|
|
335
|
-
await sendTextReply(c, t);
|
|
336
|
-
},
|
|
324
|
+
ctx: platformCtx,
|
|
325
|
+
handleAIRequest,
|
|
326
|
+
sendTextReply,
|
|
337
327
|
workDir: sessionManager.getWorkDir(userId),
|
|
338
328
|
convId: sessionManager.getConvId(userId),
|
|
339
329
|
});
|
|
@@ -352,7 +342,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
352
342
|
userId,
|
|
353
343
|
chatId,
|
|
354
344
|
text,
|
|
355
|
-
ctx,
|
|
345
|
+
ctx: platformCtx,
|
|
356
346
|
handleAIRequest,
|
|
357
347
|
sendTextReply,
|
|
358
348
|
replyToMessageId: messageId,
|