@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.
@@ -64,6 +64,7 @@ export interface WebConfigPayload {
64
64
  claudeBaseUrl: string;
65
65
  claudeModel: string;
66
66
  claudeProxy: string;
67
+ claudeSkipPermissions: boolean;
67
68
  codexCliPath: string;
68
69
  codebuddyCliPath: string;
69
70
  opencodeCliPath: string;
@@ -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: {
@@ -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 = process.env.OPEN_IM_SKIP_PERMISSIONS === 'false'
250
- ? false
251
- : (tc.skipPermissions ?? true);
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 { jitteredDelay, SLOW_PROBE_MS } from "../shared/reconnect.js";
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
- let lastServerResponseTime = 0;
26
- function clearTimers() {
27
- if (heartbeatTimer) {
28
- clearInterval(heartbeatTimer);
29
- heartbeatTimer = null;
30
- }
31
- if (reconnectTimer) {
32
- clearTimeout(reconnectTimer);
33
- reconnectTimer = null;
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
- function startHeartbeat(intervalMs) {
147
- lastServerResponseTime = Date.now();
148
- if (heartbeatTimer)
149
- clearInterval(heartbeatTimer);
150
- heartbeatTimer = setInterval(() => {
151
- if (!ws || ws.readyState !== WebSocket.OPEN)
152
- return;
153
- const elapsed = Date.now() - lastServerResponseTime;
154
- if (lastServerResponseTime > 0 && elapsed > intervalMs * 3) {
155
- log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
156
- clearTimers();
157
- ws?.terminate();
158
- connectWebSocket(currentConfig, currentHandler);
159
- return;
160
- }
161
- try {
162
- ws.send(JSON.stringify({ op: 1, d: seq }));
163
- }
164
- catch (err) {
165
- log.warn('QQ heartbeat send failed:', err);
166
- }
167
- }, intervalMs);
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
- reconnectAttempt = 0;
197
+ reconnectManager.reset();
200
198
  });
201
199
  socket.on("message", async (raw) => {
202
- lastServerResponseTime = Date.now();
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
- const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
209
- startHeartbeat(heartbeatInterval);
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
- clearTimers();
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
- log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(SLOW_PROBE_MS / 1000)}s 一次)`);
275
+ reconnectManager.setFatal(true);
276
+ log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
280
277
  }
281
- reconnectAttempt += 1;
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
- clearTimers();
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
  }
@@ -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
- throttledUpdate(taskState.latestContent, true);
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 ctx = createPlatformEventContext({
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 } = ctx;
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: createPlatformEventContext({
327
- platform: "telegram",
328
- allowedUserIds: config.telegramAllowedUserIds,
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,