openclaw-quiubo 2.6.13 → 2.6.16

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,7 @@ 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");
12451
12454
  var consoleLogger = {
12452
12455
  debug: (...args) => console.debug("[Quiubo]", ...args),
12453
12456
  info: (...args) => console.log("[Quiubo]", ...args),
@@ -12470,8 +12473,16 @@ var RealtimeGateway = class {
12470
12473
  pollTimer = null;
12471
12474
  polling = false;
12472
12475
  cursors = /* @__PURE__ */ new Map();
12473
- /** Message IDs already dispatched (Pusher + poll dedup). Capped to prevent unbounded growth. */
12476
+ /** Message IDs already dispatched (Pusher + poll dedup). FIFO-capped to prevent unbounded growth. */
12474
12477
  dispatched = /* @__PURE__ */ new Set();
12478
+ dispatchedOrder = [];
12479
+ DISPATCHED_MAX = 500;
12480
+ DISPATCHED_EVICT = 100;
12481
+ // Backoff state for poll failures
12482
+ consecutivePollErrors = 0;
12483
+ POLL_CONCURRENCY = 5;
12484
+ MAX_BACKOFF_MS = 6e4;
12485
+ // cap at 60s
12475
12486
  // Cached directory group IDs from listAgentGroups (refreshed every 60s)
12476
12487
  agentGroupsCache = null;
12477
12488
  AGENT_GROUPS_TTL = 6e4;
@@ -12482,6 +12493,11 @@ var RealtimeGateway = class {
12482
12493
  // Heartbeat state
12483
12494
  agentId;
12484
12495
  heartbeatTimer = null;
12496
+ // Cursor save debounce (avoids disk thrash on rapid Pusher events)
12497
+ cursorSaveTimer = null;
12498
+ cursorsFile;
12499
+ // Pusher connection timeout (cancelled on stop to prevent stale firing)
12500
+ pusherConnectTimeout = null;
12485
12501
  // E2EE state
12486
12502
  keyManager;
12487
12503
  e2eeGrantedGroups;
@@ -12492,6 +12508,7 @@ var RealtimeGateway = class {
12492
12508
  constructor(opts) {
12493
12509
  this.client = opts.client;
12494
12510
  this.botIdentityId = opts.botIdentityId;
12511
+ this.cursorsFile = join(CURSORS_DIR, `quiubo-cursors-${opts.accountId}.json`);
12495
12512
  this.pusherConfig = opts.pusher ?? null;
12496
12513
  this.pollIntervalMs = opts.pollIntervalMs ?? 5e3;
12497
12514
  this.onMessage = opts.onMessage;
@@ -12504,15 +12521,64 @@ var RealtimeGateway = class {
12504
12521
  this.agentName = opts.agentName ?? null;
12505
12522
  this.agentDisplayName = opts.agentDisplayName ?? null;
12506
12523
  }
12524
+ /**
12525
+ * Load persisted cursors from disk. Called once before start().
12526
+ * Non-fatal: if file doesn't exist or is corrupt, starts fresh.
12527
+ */
12528
+ async loadCursors() {
12529
+ try {
12530
+ const raw = await readFile(this.cursorsFile, "utf-8");
12531
+ const data = JSON.parse(raw);
12532
+ let count = 0;
12533
+ for (const [groupId, cursor] of Object.entries(data)) {
12534
+ if (typeof cursor === "string" && cursor) {
12535
+ this.cursors.set(groupId, cursor);
12536
+ count++;
12537
+ }
12538
+ }
12539
+ this.log.info?.(`Loaded ${count} persisted cursor(s) from disk`);
12540
+ } catch {
12541
+ this.log.debug?.("No persisted cursors found (first run or corrupt file)");
12542
+ }
12543
+ }
12544
+ /**
12545
+ * Save cursors to disk. Called after each poll cycle and on cursor updates.
12546
+ * Non-fatal: logs warning on failure.
12547
+ */
12548
+ async saveCursors() {
12549
+ try {
12550
+ const data = {};
12551
+ for (const [groupId, cursor] of this.cursors) {
12552
+ data[groupId] = cursor;
12553
+ }
12554
+ await mkdir(CURSORS_DIR, { recursive: true });
12555
+ await writeFile(this.cursorsFile, JSON.stringify(data, null, 2), "utf-8");
12556
+ } catch (err) {
12557
+ this.log.warn?.(`Failed to persist cursors: ${err}`);
12558
+ }
12559
+ }
12560
+ /** Debounced cursor save — batches rapid Pusher updates into one write per 2s */
12561
+ debouncedSaveCursors() {
12562
+ if (this.cursorSaveTimer) return;
12563
+ this.cursorSaveTimer = setTimeout(() => {
12564
+ this.cursorSaveTimer = null;
12565
+ this.saveCursors();
12566
+ }, 2e3);
12567
+ }
12507
12568
  /** Set bot config for a group */
12508
12569
  setBotConfig(groupId, config) {
12509
12570
  this.botConfigCache.set(groupId, config);
12510
12571
  }
12511
- /** Track a dispatched message ID (capped at 500 to prevent unbounded growth) */
12572
+ /** Track a dispatched message ID (FIFO-capped to prevent unbounded growth) */
12512
12573
  markDispatched(messageId) {
12574
+ if (this.dispatched.has(messageId)) return;
12513
12575
  this.dispatched.add(messageId);
12514
- if (this.dispatched.size > 500) {
12515
- this.dispatched.clear();
12576
+ this.dispatchedOrder.push(messageId);
12577
+ if (this.dispatched.size > this.DISPATCHED_MAX) {
12578
+ const toEvict = this.dispatchedOrder.splice(0, this.DISPATCHED_EVICT);
12579
+ for (const id of toEvict) {
12580
+ this.dispatched.delete(id);
12581
+ }
12516
12582
  }
12517
12583
  }
12518
12584
  /** Get bot config for a group (for outbound scope checks) */
@@ -12628,6 +12694,11 @@ var RealtimeGateway = class {
12628
12694
  this.stopHeartbeat();
12629
12695
  this.stopPolling();
12630
12696
  this.stopPusher();
12697
+ if (this.cursorSaveTimer) {
12698
+ clearTimeout(this.cursorSaveTimer);
12699
+ this.cursorSaveTimer = null;
12700
+ }
12701
+ this.saveCursors();
12631
12702
  this.log.info?.("Gateway stopped");
12632
12703
  }
12633
12704
  // ── Heartbeat ────────────────────────────────────────────────────
@@ -12703,7 +12774,8 @@ var RealtimeGateway = class {
12703
12774
  log.error?.(`Failed to subscribe to ${channelName}:`, error);
12704
12775
  if (!this.stopped) this.startPolling();
12705
12776
  });
12706
- setTimeout(() => {
12777
+ this.pusherConnectTimeout = setTimeout(() => {
12778
+ this.pusherConnectTimeout = null;
12707
12779
  if (!this.pusherConnected && !this.stopped) {
12708
12780
  log.warn?.("Pusher connection timeout (15s) \u2014 starting polling as fallback");
12709
12781
  this.startPolling();
@@ -12791,7 +12863,9 @@ var RealtimeGateway = class {
12791
12863
  }).then((shouldProcess) => {
12792
12864
  if (!shouldProcess) return;
12793
12865
  if (data.plaintext) {
12866
+ if (this.dispatched.has(data.messageId)) return;
12794
12867
  this.cursors.set(data.groupId, data.messageId);
12868
+ this.debouncedSaveCursors();
12795
12869
  this.markDispatched(data.messageId);
12796
12870
  Promise.resolve(this.onMessage({
12797
12871
  messageId: data.messageId,
@@ -12816,10 +12890,12 @@ var RealtimeGateway = class {
12816
12890
  return;
12817
12891
  }
12818
12892
  log.info?.(`[e2ee:pusher] group=${data.groupId} got epoch key, decrypting...`);
12893
+ if (this.dispatched.has(data.messageId)) return;
12819
12894
  try {
12820
12895
  const plaintext = decryptGroupMessage(data.ciphertext, cached.key);
12821
12896
  log.info?.(`[e2ee:pusher] group=${data.groupId} decrypted OK: ${plaintext?.length} chars \u2014 routing to onMessage`);
12822
12897
  this.cursors.set(data.groupId, data.messageId);
12898
+ this.debouncedSaveCursors();
12823
12899
  this.markDispatched(data.messageId);
12824
12900
  return Promise.resolve(this.onMessage({
12825
12901
  messageId: data.messageId,
@@ -12857,10 +12933,14 @@ var RealtimeGateway = class {
12857
12933
  */
12858
12934
  async fetchAndRouteMessage(groupId, messageId) {
12859
12935
  try {
12936
+ if (!this.cursors.has(groupId)) {
12937
+ await this.seekToLatest(groupId);
12938
+ }
12860
12939
  const cursor = this.cursors.get(groupId);
12861
12940
  const { messages } = await this.client.listMessages(groupId, cursor, 100);
12862
12941
  if (messages.length > 0) {
12863
12942
  this.cursors.set(groupId, messages[messages.length - 1].id);
12943
+ this.debouncedSaveCursors();
12864
12944
  }
12865
12945
  const msg = messages.find((m) => m.id === messageId);
12866
12946
  if (!msg) {
@@ -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,23 +13075,51 @@ 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
  }
12987
13092
  }
13093
+ /**
13094
+ * Seek cursor to the latest message without processing anything.
13095
+ * Called on cold start (no cursor for a group) to avoid replaying history.
13096
+ * Paginates forward until there are no more messages.
13097
+ */
13098
+ async seekToLatest(groupId) {
13099
+ let cursor;
13100
+ let hasMore = true;
13101
+ while (hasMore) {
13102
+ const result = await this.client.listMessages(groupId, cursor, 100);
13103
+ if (result.messages.length === 0) break;
13104
+ cursor = result.messages[result.messages.length - 1].id;
13105
+ hasMore = result.hasMore;
13106
+ }
13107
+ if (cursor) {
13108
+ this.cursors.set(groupId, cursor);
13109
+ this.log.info?.(`Seeked to latest in group ${groupId}: cursor=${cursor}`);
13110
+ }
13111
+ }
12988
13112
  async pollGroup(groupId) {
12989
13113
  try {
12990
13114
  const cursor = this.cursors.get(groupId);
13115
+ if (!cursor) {
13116
+ await this.seekToLatest(groupId);
13117
+ return;
13118
+ }
12991
13119
  const { messages } = await this.client.listMessages(groupId, cursor);
12992
13120
  if (messages.length === 0) return;
12993
13121
  const lastMessage = messages[messages.length - 1];
12994
13122
  this.cursors.set(groupId, lastMessage.id);
12995
- if (!cursor) return;
12996
13123
  for (const msg of messages) {
12997
13124
  if (msg.senderIdentityId === this.botIdentityId) continue;
12998
13125
  let plaintext = msg.plaintext;
@@ -13147,7 +13274,7 @@ async function readMdAttachments(mediaUrls, source, log, accountId) {
13147
13274
  const filename = basename(url);
13148
13275
  if (!filename.endsWith(".md")) continue;
13149
13276
  try {
13150
- const content = await readFile(url, "utf-8");
13277
+ const content = await readFile2(url, "utf-8");
13151
13278
  if (content.length > 1024 * 1024) {
13152
13279
  log?.warn?.(`[${accountId}] Quiubo: skipping ${filename} \u2014 exceeds 1MB`);
13153
13280
  continue;
@@ -13554,6 +13681,12 @@ var quiuboPlugin = {
13554
13681
  log?.info?.(`[${accountId}] Quiubo: disabled, skipping`);
13555
13682
  return;
13556
13683
  }
13684
+ const existingGateway = gateways.get(accountId);
13685
+ if (existingGateway) {
13686
+ log?.warn?.(`[${accountId}] Quiubo: stopping existing gateway before restart`);
13687
+ existingGateway.stop();
13688
+ gateways.delete(accountId);
13689
+ }
13557
13690
  const apiUrl = quiuboConfig.apiUrl ?? DEFAULT_API_URL;
13558
13691
  const { apiKey, botIdentityId, pollIntervalMs } = quiuboConfig;
13559
13692
  const runtime2 = getQuiuboRuntime();
@@ -13696,6 +13829,7 @@ var quiuboPlugin = {
13696
13829
  const gateway = new RealtimeGateway({
13697
13830
  client,
13698
13831
  botIdentityId,
13832
+ accountId,
13699
13833
  pusher: pusherConfig,
13700
13834
  pollIntervalMs: pollIntervalMs ?? 5e3,
13701
13835
  log: gatewayLog,
@@ -13880,6 +14014,7 @@ var quiuboPlugin = {
13880
14014
  } catch (err) {
13881
14015
  log?.warn?.(`[${accountId}] Quiubo: failed to hydrate bot config (non-fatal): ${err}`);
13882
14016
  }
14017
+ await gateway.loadCursors();
13883
14018
  await gateway.start();
13884
14019
  log?.info?.(`[${accountId}] Quiubo: gateway started`);
13885
14020
  return new Promise((resolve) => {
@@ -13916,12 +14051,12 @@ function resolveOutboundGroupId(ctx) {
13916
14051
  }
13917
14052
  async function getActivityData(runtime2, log, agentId) {
13918
14053
  try {
13919
- const { readFile: readFile2 } = await import("node:fs/promises");
13920
- const { join } = await import("node:path");
14054
+ const { readFile: readFile3 } = await import("node:fs/promises");
14055
+ const { join: join2 } = await import("node:path");
13921
14056
  const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
13922
- const cronPath = join(homeDir, ".openclaw", "cron", "jobs.json");
14057
+ const cronPath = join2(homeDir, ".openclaw", "cron", "jobs.json");
13923
14058
  log?.info?.(`getActivityData: reading ${cronPath} (agentId=${agentId})`);
13924
- const raw = await readFile2(cronPath, "utf-8");
14059
+ const raw = await readFile3(cronPath, "utf-8");
13925
14060
  const parsed = JSON.parse(raw);
13926
14061
  const jobs = parsed?.jobs ?? [];
13927
14062
  const items = jobs.filter((j) => j.enabled !== false && (!agentId || j.agentId === agentId)).map((job) => ({