openclaw-quiubo 2.6.13 → 2.6.15

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/index.js CHANGED
@@ -8952,7 +8952,7 @@ function getQuiuboRuntime() {
8952
8952
  }
8953
8953
 
8954
8954
  // src/channel.ts
8955
- import { readFile } from "fs/promises";
8955
+ import { readFile as readFile2 } from "fs/promises";
8956
8956
  import { basename } from "path";
8957
8957
 
8958
8958
  // src/api.ts
@@ -9228,6 +9228,8 @@ var QuiuboApiClient = class {
9228
9228
 
9229
9229
  // src/realtime-gateway.ts
9230
9230
  var import_pusher_js = __toESM(require_pusher(), 1);
9231
+ import { readFile, writeFile, mkdir } from "fs/promises";
9232
+ import { join } from "path";
9231
9233
 
9232
9234
  // ../../node_modules/.pnpm/@noble+hashes@1.8.0/node_modules/@noble/hashes/esm/cryptoNode.js
9233
9235
  import * as nc from "node:crypto";
@@ -12448,6 +12450,8 @@ function signChallenge(challengeBase64, signingPrivateKey) {
12448
12450
  }
12449
12451
 
12450
12452
  // src/realtime-gateway.ts
12453
+ var CURSORS_DIR = join(process.env.HOME ?? process.env.USERPROFILE ?? "", ".openclaw", "cron");
12454
+ var CURSORS_FILE = join(CURSORS_DIR, "quiubo-cursors.json");
12451
12455
  var consoleLogger = {
12452
12456
  debug: (...args) => console.debug("[Quiubo]", ...args),
12453
12457
  info: (...args) => console.log("[Quiubo]", ...args),
@@ -12470,8 +12474,16 @@ var RealtimeGateway = class {
12470
12474
  pollTimer = null;
12471
12475
  polling = false;
12472
12476
  cursors = /* @__PURE__ */ new Map();
12473
- /** Message IDs already dispatched (Pusher + poll dedup). Capped to prevent unbounded growth. */
12477
+ /** Message IDs already dispatched (Pusher + poll dedup). FIFO-capped to prevent unbounded growth. */
12474
12478
  dispatched = /* @__PURE__ */ new Set();
12479
+ dispatchedOrder = [];
12480
+ DISPATCHED_MAX = 500;
12481
+ DISPATCHED_EVICT = 100;
12482
+ // Backoff state for poll failures
12483
+ consecutivePollErrors = 0;
12484
+ POLL_CONCURRENCY = 5;
12485
+ MAX_BACKOFF_MS = 6e4;
12486
+ // cap at 60s
12475
12487
  // Cached directory group IDs from listAgentGroups (refreshed every 60s)
12476
12488
  agentGroupsCache = null;
12477
12489
  AGENT_GROUPS_TTL = 6e4;
@@ -12482,6 +12494,10 @@ var RealtimeGateway = class {
12482
12494
  // Heartbeat state
12483
12495
  agentId;
12484
12496
  heartbeatTimer = null;
12497
+ // Cursor save debounce (avoids disk thrash on rapid Pusher events)
12498
+ cursorSaveTimer = null;
12499
+ // Pusher connection timeout (cancelled on stop to prevent stale firing)
12500
+ pusherConnectTimeout = null;
12485
12501
  // E2EE state
12486
12502
  keyManager;
12487
12503
  e2eeGrantedGroups;
@@ -12504,15 +12520,64 @@ var RealtimeGateway = class {
12504
12520
  this.agentName = opts.agentName ?? null;
12505
12521
  this.agentDisplayName = opts.agentDisplayName ?? null;
12506
12522
  }
12523
+ /**
12524
+ * Load persisted cursors from disk. Called once before start().
12525
+ * Non-fatal: if file doesn't exist or is corrupt, starts fresh.
12526
+ */
12527
+ async loadCursors() {
12528
+ try {
12529
+ const raw = await readFile(CURSORS_FILE, "utf-8");
12530
+ const data = JSON.parse(raw);
12531
+ let count = 0;
12532
+ for (const [groupId, cursor] of Object.entries(data)) {
12533
+ if (typeof cursor === "string" && cursor) {
12534
+ this.cursors.set(groupId, cursor);
12535
+ count++;
12536
+ }
12537
+ }
12538
+ this.log.info?.(`Loaded ${count} persisted cursor(s) from disk`);
12539
+ } catch {
12540
+ this.log.debug?.("No persisted cursors found (first run or corrupt file)");
12541
+ }
12542
+ }
12543
+ /**
12544
+ * Save cursors to disk. Called after each poll cycle and on cursor updates.
12545
+ * Non-fatal: logs warning on failure.
12546
+ */
12547
+ async saveCursors() {
12548
+ try {
12549
+ const data = {};
12550
+ for (const [groupId, cursor] of this.cursors) {
12551
+ data[groupId] = cursor;
12552
+ }
12553
+ await mkdir(CURSORS_DIR, { recursive: true });
12554
+ await writeFile(CURSORS_FILE, JSON.stringify(data, null, 2), "utf-8");
12555
+ } catch (err) {
12556
+ this.log.warn?.(`Failed to persist cursors: ${err}`);
12557
+ }
12558
+ }
12559
+ /** Debounced cursor save — batches rapid Pusher updates into one write per 2s */
12560
+ debouncedSaveCursors() {
12561
+ if (this.cursorSaveTimer) return;
12562
+ this.cursorSaveTimer = setTimeout(() => {
12563
+ this.cursorSaveTimer = null;
12564
+ this.saveCursors();
12565
+ }, 2e3);
12566
+ }
12507
12567
  /** Set bot config for a group */
12508
12568
  setBotConfig(groupId, config) {
12509
12569
  this.botConfigCache.set(groupId, config);
12510
12570
  }
12511
- /** Track a dispatched message ID (capped at 500 to prevent unbounded growth) */
12571
+ /** Track a dispatched message ID (FIFO-capped to prevent unbounded growth) */
12512
12572
  markDispatched(messageId) {
12573
+ if (this.dispatched.has(messageId)) return;
12513
12574
  this.dispatched.add(messageId);
12514
- if (this.dispatched.size > 500) {
12515
- this.dispatched.clear();
12575
+ this.dispatchedOrder.push(messageId);
12576
+ if (this.dispatched.size > this.DISPATCHED_MAX) {
12577
+ const toEvict = this.dispatchedOrder.splice(0, this.DISPATCHED_EVICT);
12578
+ for (const id of toEvict) {
12579
+ this.dispatched.delete(id);
12580
+ }
12516
12581
  }
12517
12582
  }
12518
12583
  /** Get bot config for a group (for outbound scope checks) */
@@ -12628,6 +12693,11 @@ var RealtimeGateway = class {
12628
12693
  this.stopHeartbeat();
12629
12694
  this.stopPolling();
12630
12695
  this.stopPusher();
12696
+ if (this.cursorSaveTimer) {
12697
+ clearTimeout(this.cursorSaveTimer);
12698
+ this.cursorSaveTimer = null;
12699
+ }
12700
+ this.saveCursors();
12631
12701
  this.log.info?.("Gateway stopped");
12632
12702
  }
12633
12703
  // ── Heartbeat ────────────────────────────────────────────────────
@@ -12703,7 +12773,8 @@ var RealtimeGateway = class {
12703
12773
  log.error?.(`Failed to subscribe to ${channelName}:`, error);
12704
12774
  if (!this.stopped) this.startPolling();
12705
12775
  });
12706
- setTimeout(() => {
12776
+ this.pusherConnectTimeout = setTimeout(() => {
12777
+ this.pusherConnectTimeout = null;
12707
12778
  if (!this.pusherConnected && !this.stopped) {
12708
12779
  log.warn?.("Pusher connection timeout (15s) \u2014 starting polling as fallback");
12709
12780
  this.startPolling();
@@ -12791,7 +12862,9 @@ var RealtimeGateway = class {
12791
12862
  }).then((shouldProcess) => {
12792
12863
  if (!shouldProcess) return;
12793
12864
  if (data.plaintext) {
12865
+ if (this.dispatched.has(data.messageId)) return;
12794
12866
  this.cursors.set(data.groupId, data.messageId);
12867
+ this.debouncedSaveCursors();
12795
12868
  this.markDispatched(data.messageId);
12796
12869
  Promise.resolve(this.onMessage({
12797
12870
  messageId: data.messageId,
@@ -12816,10 +12889,12 @@ var RealtimeGateway = class {
12816
12889
  return;
12817
12890
  }
12818
12891
  log.info?.(`[e2ee:pusher] group=${data.groupId} got epoch key, decrypting...`);
12892
+ if (this.dispatched.has(data.messageId)) return;
12819
12893
  try {
12820
12894
  const plaintext = decryptGroupMessage(data.ciphertext, cached.key);
12821
12895
  log.info?.(`[e2ee:pusher] group=${data.groupId} decrypted OK: ${plaintext?.length} chars \u2014 routing to onMessage`);
12822
12896
  this.cursors.set(data.groupId, data.messageId);
12897
+ this.debouncedSaveCursors();
12823
12898
  this.markDispatched(data.messageId);
12824
12899
  return Promise.resolve(this.onMessage({
12825
12900
  messageId: data.messageId,
@@ -12861,9 +12936,14 @@ var RealtimeGateway = class {
12861
12936
  const { messages } = await this.client.listMessages(groupId, cursor, 100);
12862
12937
  if (messages.length > 0) {
12863
12938
  this.cursors.set(groupId, messages[messages.length - 1].id);
12939
+ this.debouncedSaveCursors();
12864
12940
  }
12865
12941
  const msg = messages.find((m) => m.id === messageId);
12866
12942
  if (!msg) {
12943
+ if (!cursor) {
12944
+ this.log.info?.(`fetchAndRoute: no cursor for group ${groupId}, caught up to latest (skip history)`);
12945
+ return;
12946
+ }
12867
12947
  this.log.warn?.(`Message ${messageId} not found in group ${groupId} (fetched ${messages.length} from cursor)`);
12868
12948
  return;
12869
12949
  }
@@ -12886,6 +12966,10 @@ var RealtimeGateway = class {
12886
12966
  }
12887
12967
  }
12888
12968
  stopPusher() {
12969
+ if (this.pusherConnectTimeout) {
12970
+ clearTimeout(this.pusherConnectTimeout);
12971
+ this.pusherConnectTimeout = null;
12972
+ }
12889
12973
  if (this.pusherClient) {
12890
12974
  this.pusherClient.disconnect();
12891
12975
  this.pusherClient = null;
@@ -12896,12 +12980,27 @@ var RealtimeGateway = class {
12896
12980
  startPolling() {
12897
12981
  if (this.pollTimer) return;
12898
12982
  this.log.info?.(`Polling started: interval=${this.pollIntervalMs}ms`);
12899
- this.poll();
12900
- this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
12983
+ this.schedulePoll();
12984
+ }
12985
+ /** Schedule the next poll with backoff-aware delay */
12986
+ schedulePoll() {
12987
+ if (this.stopped || this.pollTimer) return;
12988
+ const delay = this.getBackoffDelay();
12989
+ this.pollTimer = setTimeout(async () => {
12990
+ this.pollTimer = null;
12991
+ await this.poll();
12992
+ if (!this.stopped) this.schedulePoll();
12993
+ }, delay);
12994
+ }
12995
+ /** Get poll delay: base interval * 2^errors, capped at MAX_BACKOFF_MS */
12996
+ getBackoffDelay() {
12997
+ if (this.consecutivePollErrors === 0) return this.pollIntervalMs;
12998
+ const backoff = this.pollIntervalMs * Math.pow(2, Math.min(this.consecutivePollErrors, 6));
12999
+ return Math.min(backoff, this.MAX_BACKOFF_MS);
12901
13000
  }
12902
13001
  stopPolling() {
12903
13002
  if (this.pollTimer) {
12904
- clearInterval(this.pollTimer);
13003
+ clearTimeout(this.pollTimer);
12905
13004
  this.pollTimer = null;
12906
13005
  }
12907
13006
  }
@@ -12976,11 +13075,17 @@ var RealtimeGateway = class {
12976
13075
  const directoryIds = await this.getDirectoryGroupIds();
12977
13076
  for (const gid of directoryIds) groupIds.add(gid);
12978
13077
  this.log.debug?.(`Poll cycle: partner=${groups.length} directory=${directoryIds.size} total=${groupIds.size}`);
12979
- await Promise.allSettled(
12980
- [...groupIds].map((gid) => this.pollGroup(gid))
12981
- );
13078
+ const allGroupIds = [...groupIds];
13079
+ for (let i = 0; i < allGroupIds.length; i += this.POLL_CONCURRENCY) {
13080
+ const batch = allGroupIds.slice(i, i + this.POLL_CONCURRENCY);
13081
+ await Promise.allSettled(batch.map((gid) => this.pollGroup(gid)));
13082
+ }
13083
+ await this.saveCursors();
13084
+ this.consecutivePollErrors = 0;
12982
13085
  } catch (error) {
12983
- this.log.error?.("Poll cycle failed:", error);
13086
+ this.consecutivePollErrors++;
13087
+ const nextDelay = this.getBackoffDelay();
13088
+ this.log.error?.(`Poll cycle failed (${this.consecutivePollErrors} consecutive, next in ${nextDelay}ms):`, error);
12984
13089
  } finally {
12985
13090
  this.polling = false;
12986
13091
  }
@@ -13147,7 +13252,7 @@ async function readMdAttachments(mediaUrls, source, log, accountId) {
13147
13252
  const filename = basename(url);
13148
13253
  if (!filename.endsWith(".md")) continue;
13149
13254
  try {
13150
- const content = await readFile(url, "utf-8");
13255
+ const content = await readFile2(url, "utf-8");
13151
13256
  if (content.length > 1024 * 1024) {
13152
13257
  log?.warn?.(`[${accountId}] Quiubo: skipping ${filename} \u2014 exceeds 1MB`);
13153
13258
  continue;
@@ -13554,6 +13659,12 @@ var quiuboPlugin = {
13554
13659
  log?.info?.(`[${accountId}] Quiubo: disabled, skipping`);
13555
13660
  return;
13556
13661
  }
13662
+ const existingGateway = gateways.get(accountId);
13663
+ if (existingGateway) {
13664
+ log?.warn?.(`[${accountId}] Quiubo: stopping existing gateway before restart`);
13665
+ existingGateway.stop();
13666
+ gateways.delete(accountId);
13667
+ }
13557
13668
  const apiUrl = quiuboConfig.apiUrl ?? DEFAULT_API_URL;
13558
13669
  const { apiKey, botIdentityId, pollIntervalMs } = quiuboConfig;
13559
13670
  const runtime2 = getQuiuboRuntime();
@@ -13880,6 +13991,7 @@ var quiuboPlugin = {
13880
13991
  } catch (err) {
13881
13992
  log?.warn?.(`[${accountId}] Quiubo: failed to hydrate bot config (non-fatal): ${err}`);
13882
13993
  }
13994
+ await gateway.loadCursors();
13883
13995
  await gateway.start();
13884
13996
  log?.info?.(`[${accountId}] Quiubo: gateway started`);
13885
13997
  return new Promise((resolve) => {
@@ -13916,12 +14028,12 @@ function resolveOutboundGroupId(ctx) {
13916
14028
  }
13917
14029
  async function getActivityData(runtime2, log, agentId) {
13918
14030
  try {
13919
- const { readFile: readFile2 } = await import("node:fs/promises");
13920
- const { join } = await import("node:path");
14031
+ const { readFile: readFile3 } = await import("node:fs/promises");
14032
+ const { join: join2 } = await import("node:path");
13921
14033
  const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
13922
- const cronPath = join(homeDir, ".openclaw", "cron", "jobs.json");
14034
+ const cronPath = join2(homeDir, ".openclaw", "cron", "jobs.json");
13923
14035
  log?.info?.(`getActivityData: reading ${cronPath} (agentId=${agentId})`);
13924
- const raw = await readFile2(cronPath, "utf-8");
14036
+ const raw = await readFile3(cronPath, "utf-8");
13925
14037
  const parsed = JSON.parse(raw);
13926
14038
  const jobs = parsed?.jobs ?? [];
13927
14039
  const items = jobs.filter((j) => j.enabled !== false && (!agentId || j.agentId === agentId)).map((job) => ({