@questionbase/deskfree 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +129 -0
  2. package/dist/channel.d.ts +3 -0
  3. package/dist/channel.d.ts.map +1 -0
  4. package/dist/channel.js +503 -0
  5. package/dist/channel.js.map +1 -0
  6. package/dist/client.d.ts +148 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +255 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/deliver.d.ts +22 -0
  11. package/dist/deliver.d.ts.map +1 -0
  12. package/dist/deliver.js +350 -0
  13. package/dist/deliver.js.map +1 -0
  14. package/dist/gateway.d.ts +13 -0
  15. package/dist/gateway.d.ts.map +1 -0
  16. package/dist/gateway.js +687 -0
  17. package/dist/gateway.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +19 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/llm-definitions.d.ts +116 -0
  23. package/dist/llm-definitions.d.ts.map +1 -0
  24. package/dist/llm-definitions.js +148 -0
  25. package/dist/llm-definitions.js.map +1 -0
  26. package/dist/offline-queue.d.ts +45 -0
  27. package/dist/offline-queue.d.ts.map +1 -0
  28. package/dist/offline-queue.js +109 -0
  29. package/dist/offline-queue.js.map +1 -0
  30. package/dist/paths.d.ts +10 -0
  31. package/dist/paths.d.ts.map +1 -0
  32. package/dist/paths.js +29 -0
  33. package/dist/paths.js.map +1 -0
  34. package/dist/runtime.d.ts +17 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +24 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/tools.d.ts +35 -0
  39. package/dist/tools.d.ts.map +1 -0
  40. package/dist/tools.js +527 -0
  41. package/dist/tools.js.map +1 -0
  42. package/dist/types.d.ts +389 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +2 -0
  45. package/dist/types.js.map +1 -0
  46. package/dist/workspace.d.ts +18 -0
  47. package/dist/workspace.d.ts.map +1 -0
  48. package/dist/workspace.js +83 -0
  49. package/dist/workspace.js.map +1 -0
  50. package/openclaw.plugin.json +8 -0
  51. package/package.json +63 -0
  52. package/skills/deskfree/SKILL.md +271 -0
@@ -0,0 +1,687 @@
1
+ // Use ESM imports for Node.js built-in modules
2
+ import { DeskFreeClient } from './client';
3
+ import { deliverMessageToAgent } from './deliver';
4
+ import { resolvePluginStorePath } from './paths';
5
+ import { getDeskFreeRuntime } from './runtime';
6
+ import { resolveWorkspacePath } from './workspace';
7
+ import { spawn } from 'node:child_process';
8
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import WebSocket from 'ws';
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ // ── Active Task Tracking ──────────────────────────────────────────────
14
+ // Tracks the currently active task so outbound messages can be
15
+ // automatically threaded into it without explicit taskId parameters.
16
+ let activeTaskId = null;
17
+ export function setActiveTaskId(taskId) {
18
+ activeTaskId = taskId;
19
+ }
20
+ export function getActiveTaskId() {
21
+ return activeTaskId;
22
+ }
23
+ // ── Workspace S3 Sync Helpers ─────────────────────────────────────────
24
+ /**
25
+ * Helper function to get AWS credentials from DeskFree and run an S3 command.
26
+ * Replaces the previous API-based workspace sync approach with direct AWS CLI usage.
27
+ */
28
+ async function runS3CommandWithCredentials(client, command, workspacePath, log) {
29
+ try {
30
+ // Get short-lived credentials from DeskFree
31
+ const creds = await client.workspaceCredentials();
32
+ // Set up environment with temporary credentials
33
+ const env = {
34
+ ...process.env,
35
+ AWS_ACCESS_KEY_ID: creds.accessKeyId,
36
+ AWS_SECRET_ACCESS_KEY: creds.secretAccessKey,
37
+ AWS_SESSION_TOKEN: creds.sessionToken,
38
+ };
39
+ return new Promise((resolve, reject) => {
40
+ const child = spawn('aws', command, {
41
+ env,
42
+ cwd: workspacePath,
43
+ stdio: 'pipe',
44
+ });
45
+ let stdout = '';
46
+ let stderr = '';
47
+ child.stdout?.on('data', (data) => {
48
+ stdout += data.toString();
49
+ });
50
+ child.stderr?.on('data', (data) => {
51
+ stderr += data.toString();
52
+ });
53
+ child.on('close', (code) => {
54
+ if (code === 0) {
55
+ log.info(`S3 command succeeded: ${command.join(' ')}`);
56
+ if (stdout.trim()) {
57
+ log.debug(`S3 stdout: ${stdout.trim()}`);
58
+ }
59
+ resolve(true);
60
+ }
61
+ else {
62
+ log.warn(`S3 command failed (exit ${code}): ${command.join(' ')}`);
63
+ if (stderr.trim()) {
64
+ log.warn(`S3 stderr: ${stderr.trim()}`);
65
+ }
66
+ reject(new Error(`AWS CLI command failed with exit code ${code}: ${stderr}`));
67
+ }
68
+ });
69
+ child.on('error', (err) => {
70
+ log.warn(`S3 command spawn error: ${err.message}`);
71
+ reject(err);
72
+ });
73
+ });
74
+ }
75
+ catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ log.warn(`Failed to get S3 credentials or run command: ${message}`);
78
+ return false;
79
+ }
80
+ }
81
+ const PLUGIN_VERSION = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')).version;
82
+ const PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (API GW idle timeout = 10 min)
83
+ const POLL_FALLBACK_INTERVAL_MS = 30 * 1000; // 30s fallback when WS is down
84
+ const WS_CONNECTION_TIMEOUT_MS = 30 * 1000; // 30s timeout for initial connection
85
+ const WS_PONG_TIMEOUT_MS = 10 * 1000; // 10s timeout waiting for pong response
86
+ const BACKOFF_INITIAL_MS = 2000;
87
+ const BACKOFF_MAX_MS = 30000;
88
+ const BACKOFF_FACTOR = 1.8;
89
+ const HEALTH_LOG_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
90
+ const MAX_CONSECUTIVE_POLL_FAILURES = 5; // Switch to longer backoff after this many
91
+ // Dedup set to prevent re-delivering messages due to cursor precision issues
92
+ // (Postgres microsecond timestamps vs JS millisecond Date precision)
93
+ const deliveredMessageIds = new Set();
94
+ // Module-level health state persists across reconnects
95
+ const healthState = new Map();
96
+ function initializeHealth(accountId) {
97
+ if (!healthState.has(accountId)) {
98
+ healthState.set(accountId, {
99
+ connectionStartTime: Date.now(),
100
+ totalReconnects: 0,
101
+ lastReconnectAt: null,
102
+ avgReconnectInterval: 0,
103
+ totalMessagesDelivered: 0,
104
+ lastMessageAt: null,
105
+ currentMode: 'websocket',
106
+ });
107
+ }
108
+ }
109
+ function updateHealthMode(accountId, mode) {
110
+ const health = healthState.get(accountId);
111
+ if (health) {
112
+ health.currentMode = mode;
113
+ }
114
+ }
115
+ function recordReconnect(accountId) {
116
+ const health = healthState.get(accountId);
117
+ if (health) {
118
+ const now = Date.now();
119
+ if (health.lastReconnectAt) {
120
+ const interval = now - health.lastReconnectAt;
121
+ health.avgReconnectInterval =
122
+ (health.avgReconnectInterval * health.totalReconnects + interval) /
123
+ (health.totalReconnects + 1);
124
+ }
125
+ health.totalReconnects++;
126
+ health.lastReconnectAt = now;
127
+ }
128
+ }
129
+ function recordMessageDelivery(accountId, count) {
130
+ const health = healthState.get(accountId);
131
+ if (health) {
132
+ health.totalMessagesDelivered += count;
133
+ health.lastMessageAt = Date.now();
134
+ }
135
+ }
136
+ function formatDuration(ms) {
137
+ const seconds = Math.floor(ms / 1000);
138
+ const minutes = Math.floor(seconds / 60);
139
+ const hours = Math.floor(minutes / 60);
140
+ if (hours > 0) {
141
+ return `${hours}h${minutes % 60}m`;
142
+ }
143
+ else if (minutes > 0) {
144
+ return `${minutes}m${seconds % 60}s`;
145
+ }
146
+ else {
147
+ return `${seconds}s`;
148
+ }
149
+ }
150
+ function logHealthSummary(accountId, log) {
151
+ const health = healthState.get(accountId);
152
+ if (!health)
153
+ return;
154
+ const uptime = Date.now() - health.connectionStartTime;
155
+ log.info(`DeskFree health: uptime=${formatDuration(uptime)}, ` +
156
+ `reconnects=${health.totalReconnects}, ` +
157
+ `messages=${health.totalMessagesDelivered}, ` +
158
+ `mode=${health.currentMode}`);
159
+ }
160
+ // Serialize poll operations per account to prevent duplicate message delivery.
161
+ // Each account gets its own promise-chain mutex so polls don't block across accounts.
162
+ const pollChains = new Map();
163
+ function enqueuePoll(client, ctx, getCursor, setCursor, log) {
164
+ const accountId = ctx.accountId;
165
+ const prev = pollChains.get(accountId) ?? Promise.resolve();
166
+ const next = prev
167
+ .then(async () => {
168
+ const newCursor = await pollAndDeliver(client, ctx, getCursor(), log);
169
+ if (newCursor)
170
+ setCursor(newCursor);
171
+ })
172
+ .catch((err) => {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ log.error(`Poll error: ${message}`);
175
+ });
176
+ pollChains.set(accountId, next);
177
+ }
178
+ function nextBackoff(state) {
179
+ const delay = Math.min(BACKOFF_INITIAL_MS * Math.pow(BACKOFF_FACTOR, state.attempt), BACKOFF_MAX_MS);
180
+ const jitter = delay * 0.25 * Math.random();
181
+ state.attempt++;
182
+ return delay + jitter;
183
+ }
184
+ function resetBackoff(state) {
185
+ state.attempt = 0;
186
+ }
187
+ // Clean up abort listener to prevent memory leak
188
+ function sleepWithAbort(ms, signal) {
189
+ return new Promise((resolve) => {
190
+ const onAbort = () => {
191
+ clearTimeout(timer);
192
+ resolve();
193
+ };
194
+ const timer = setTimeout(() => {
195
+ signal.removeEventListener('abort', onAbort);
196
+ resolve();
197
+ }, ms);
198
+ signal.addEventListener('abort', onAbort, { once: true });
199
+ });
200
+ }
201
+ /**
202
+ * Persistent cursor management.
203
+ * Stores cursor on disk so we can resume from the right position after restart.
204
+ */
205
+ function loadCursor(ctx) {
206
+ try {
207
+ const cursorPath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
208
+ return readFileSync(cursorPath, 'utf-8').trim() || null;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ function saveCursor(ctx, cursor, log) {
215
+ try {
216
+ const filePath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
217
+ const dir = dirname(filePath);
218
+ mkdirSync(dir, { recursive: true });
219
+ writeFileSync(filePath, cursor, 'utf-8');
220
+ }
221
+ catch (err) {
222
+ // Non-fatal — we'll just re-process some messages on restart
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ log?.warn(`Failed to persist cursor: ${message}`);
225
+ }
226
+ }
227
+ /**
228
+ * Main entry point for the DeskFree channel gateway.
229
+ * Called by OpenClaw's channel manager via gateway.startAccount().
230
+ *
231
+ * Maintains a WebSocket connection for real-time notifications
232
+ * and polls the messages endpoint for actual data.
233
+ * Falls back to interval-based polling if WebSocket is unavailable.
234
+ */
235
+ export async function startDeskFreeConnection(ctx) {
236
+ const account = ctx.account;
237
+ const client = new DeskFreeClient(account.botToken, account.apiUrl);
238
+ const log = ctx.log ?? getDeskFreeRuntime().logging.createLogger('deskfree');
239
+ let cursor = loadCursor(ctx);
240
+ const backoff = { attempt: 0 };
241
+ let totalReconnects = 0;
242
+ // Initialize health tracking for this account
243
+ initializeHealth(ctx.accountId);
244
+ log.info(`Starting DeskFree connection for account ${ctx.accountId}` +
245
+ (cursor ? ` (resuming from cursor ${cursor})` : ' (fresh start)'));
246
+ // Start health logging timer
247
+ const healthInterval = setInterval(() => {
248
+ if (!ctx.abortSignal.aborted) {
249
+ logHealthSummary(ctx.accountId, log);
250
+ }
251
+ }, HEALTH_LOG_INTERVAL_MS);
252
+ // Clean up health interval on shutdown
253
+ ctx.abortSignal.addEventListener('abort', () => {
254
+ clearInterval(healthInterval);
255
+ }, { once: true });
256
+ // Outer reconnection loop — runs until OpenClaw shuts down
257
+ while (!ctx.abortSignal.aborted) {
258
+ try {
259
+ const { ticket, wsUrl } = await client.getWsTicket();
260
+ resetBackoff(backoff);
261
+ if (totalReconnects > 0) {
262
+ log.info(`Got WS ticket, reconnecting to ${wsUrl}... (reconnect #${totalReconnects})`);
263
+ recordReconnect(ctx.accountId);
264
+ }
265
+ else {
266
+ log.info(`Got WS ticket, connecting to ${wsUrl}...`);
267
+ }
268
+ updateHealthMode(ctx.accountId, 'websocket');
269
+ cursor = await runWebSocketConnection({
270
+ ticket,
271
+ wsUrl,
272
+ client,
273
+ ctx,
274
+ cursor,
275
+ log,
276
+ });
277
+ // Connection closed cleanly — will reconnect
278
+ totalReconnects++;
279
+ }
280
+ catch (err) {
281
+ totalReconnects++;
282
+ const message = err instanceof Error ? err.message : String(err);
283
+ // If ticket fetch fails, fall back to polling
284
+ if (message.includes('API error') ||
285
+ message.includes('authentication failed') ||
286
+ message.includes('server error')) {
287
+ log.warn(`Ticket fetch failed (attempt #${totalReconnects}): ${message}. Falling back to polling.`);
288
+ recordReconnect(ctx.accountId);
289
+ updateHealthMode(ctx.accountId, 'polling');
290
+ cursor = await runPollingFallback({ client, ctx, cursor, log });
291
+ }
292
+ else {
293
+ log.warn(`Connection error (attempt #${totalReconnects}): ${message}`);
294
+ recordReconnect(ctx.accountId);
295
+ }
296
+ if (ctx.abortSignal.aborted)
297
+ break;
298
+ const delay = nextBackoff(backoff);
299
+ log.info(`Reconnecting in ${Math.round(delay)}ms (attempt #${totalReconnects + 1})...`);
300
+ await sleepWithAbort(delay, ctx.abortSignal);
301
+ }
302
+ }
303
+ log.info(`DeskFree connection loop exited after ${totalReconnects} reconnect(s).`);
304
+ }
305
+ /**
306
+ * Runs a single WebSocket connection session.
307
+ * Returns the latest cursor when the connection closes.
308
+ */
309
+ async function runWebSocketConnection(opts) {
310
+ const { ticket, wsUrl, client, ctx, log } = opts;
311
+ let cursor = opts.cursor;
312
+ return new Promise((resolve, reject) => {
313
+ const ws = new WebSocket(`${wsUrl}?ticket=${ticket}`);
314
+ let pingInterval;
315
+ let connectionTimer;
316
+ let pongTimer;
317
+ let isConnected = false;
318
+ // Centralized cleanup to prevent timer leaks
319
+ const cleanup = () => {
320
+ if (pingInterval !== undefined) {
321
+ clearInterval(pingInterval);
322
+ pingInterval = undefined;
323
+ }
324
+ if (connectionTimer !== undefined) {
325
+ clearTimeout(connectionTimer);
326
+ connectionTimer = undefined;
327
+ }
328
+ if (pongTimer !== undefined) {
329
+ clearTimeout(pongTimer);
330
+ pongTimer = undefined;
331
+ }
332
+ };
333
+ // Connection timeout - if no 'open' event within timeout, fail
334
+ connectionTimer = setTimeout(() => {
335
+ if (!isConnected) {
336
+ cleanup();
337
+ try {
338
+ ws.close();
339
+ }
340
+ catch {
341
+ // Ignore close errors during timeout cleanup
342
+ }
343
+ reject(new Error(`WebSocket connection timeout after ${WS_CONNECTION_TIMEOUT_MS}ms`));
344
+ }
345
+ }, WS_CONNECTION_TIMEOUT_MS);
346
+ ws.on('open', async () => {
347
+ isConnected = true;
348
+ if (connectionTimer !== undefined) {
349
+ clearTimeout(connectionTimer);
350
+ connectionTimer = undefined;
351
+ }
352
+ ctx.setStatus({ running: true, lastStartAt: Date.now() });
353
+ log.info('WebSocket connected.');
354
+ // Keepalive ping every 5 minutes with pong timeout handling
355
+ pingInterval = setInterval(() => {
356
+ if (ws.readyState === WebSocket.OPEN) {
357
+ try {
358
+ ws.send(JSON.stringify({ action: 'ping' }));
359
+ // Start pong timeout
360
+ pongTimer = setTimeout(() => {
361
+ log.warn('Pong timeout - closing WebSocket connection');
362
+ try {
363
+ ws.close(1002, 'pong timeout');
364
+ }
365
+ catch (closeErr) {
366
+ const closeMsg = closeErr instanceof Error
367
+ ? closeErr.message
368
+ : String(closeErr);
369
+ log.warn(`Error closing WebSocket on pong timeout: ${closeMsg}`);
370
+ }
371
+ }, WS_PONG_TIMEOUT_MS);
372
+ }
373
+ catch (err) {
374
+ const message = err instanceof Error ? err.message : String(err);
375
+ log.warn(`Failed to send ping: ${message}`);
376
+ }
377
+ }
378
+ }, PING_INTERVAL_MS);
379
+ // Report plugin + OpenClaw versions on connect
380
+ const openclawVersion = getDeskFreeRuntime().version;
381
+ client
382
+ .statusUpdate({
383
+ status: 'idle',
384
+ activeSubAgents: [],
385
+ pluginVersion: PLUGIN_VERSION,
386
+ openclawVersion,
387
+ })
388
+ .catch((err) => {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ log.warn(`Failed to send statusUpdate: ${msg}`);
391
+ });
392
+ // Download workspace files from S3 on startup
393
+ try {
394
+ const cfg = getDeskFreeRuntime().config.loadConfig();
395
+ const workspacePath = resolveWorkspacePath(cfg);
396
+ if (workspacePath) {
397
+ // Use aws s3 sync to download all workspace files
398
+ // The S3 path and bucket will be determined by the scoped credentials
399
+ // TODO: Get actual bucket name and bot ID from DeskFree API
400
+ const s3Command = [
401
+ 's3',
402
+ 'sync',
403
+ 's3://deskfree-uploads/workspace/',
404
+ '.',
405
+ '--only-show-errors',
406
+ ];
407
+ const success = await runS3CommandWithCredentials(client, s3Command, workspacePath, log);
408
+ if (success) {
409
+ log.info(`Workspace sync: downloaded files to ${workspacePath}`);
410
+ }
411
+ else {
412
+ log.warn('Workspace sync: failed to download files from S3');
413
+ }
414
+ }
415
+ else {
416
+ log.debug('Workspace sync: no workspace path configured (agents.defaults.workspace)');
417
+ }
418
+ }
419
+ catch (err) {
420
+ const msg = err instanceof Error ? err.message : String(err);
421
+ log.warn(`Workspace sync failed: ${msg}`);
422
+ }
423
+ // Enqueue initial catch-up poll through mutex
424
+ enqueuePoll(client, ctx, () => cursor, (c) => {
425
+ cursor = c ?? cursor;
426
+ }, log);
427
+ });
428
+ ws.on('message', async (data) => {
429
+ try {
430
+ const raw = data.toString();
431
+ if (!raw || raw.length > 65536) {
432
+ log.warn(`Ignoring oversized or empty WS message (${raw?.length ?? 0} bytes)`);
433
+ return;
434
+ }
435
+ const msg = JSON.parse(raw);
436
+ if (!msg || typeof msg.action !== 'string') {
437
+ log.warn('Ignoring WS message with missing or invalid action field');
438
+ return;
439
+ }
440
+ if (msg.action === 'notify') {
441
+ const notifyMsg = msg;
442
+ // Handle workspace file changes from human edits
443
+ if (notifyMsg.hint === 'workspace.fileChanged') {
444
+ const paths = notifyMsg.paths ?? [];
445
+ log.info(`Workspace file(s) changed by human: ${paths.join(', ') || '(all)'}`);
446
+ // Download changed files using aws s3 cp
447
+ try {
448
+ const cfg = getDeskFreeRuntime().config.loadConfig();
449
+ const workspacePath = resolveWorkspacePath(cfg);
450
+ if (workspacePath && paths.length > 0) {
451
+ for (const filePath of paths) {
452
+ try {
453
+ // Use aws s3 cp to download the specific changed file
454
+ const s3Command = [
455
+ 's3',
456
+ 'cp',
457
+ `s3://deskfree-uploads/workspace/${filePath}`,
458
+ filePath,
459
+ '--only-show-errors',
460
+ ];
461
+ const success = await runS3CommandWithCredentials(client, s3Command, workspacePath, log);
462
+ if (success) {
463
+ log.info(`Updated local file: ${filePath}`);
464
+ }
465
+ else {
466
+ log.warn(`Failed to download workspace file: ${filePath}`);
467
+ }
468
+ }
469
+ catch (err) {
470
+ const errMsg = err instanceof Error ? err.message : String(err);
471
+ log.warn(`Failed to download workspace file ${filePath}: ${errMsg}`);
472
+ }
473
+ }
474
+ }
475
+ else if (!workspacePath) {
476
+ log.warn('Cannot sync workspace files: workspace path not configured');
477
+ }
478
+ }
479
+ catch (err) {
480
+ const errMsg = err instanceof Error ? err.message : String(err);
481
+ log.warn(`Workspace file change handling failed: ${errMsg}`);
482
+ }
483
+ }
484
+ // Enqueue notification-triggered poll through mutex
485
+ enqueuePoll(client, ctx, () => cursor, (c) => {
486
+ cursor = c ?? cursor;
487
+ }, log);
488
+ }
489
+ else if (msg.action === 'pong') {
490
+ // Clear pong timeout - connection is healthy
491
+ if (pongTimer !== undefined) {
492
+ clearTimeout(pongTimer);
493
+ pongTimer = undefined;
494
+ }
495
+ log.debug('Received pong - connection healthy');
496
+ }
497
+ // Ignore other message types
498
+ }
499
+ catch (err) {
500
+ const message = err instanceof Error ? err.message : String(err);
501
+ log.warn(`Error processing WS message: ${message}`);
502
+ }
503
+ });
504
+ ws.on('close', (code, reason) => {
505
+ cleanup();
506
+ isConnected = false;
507
+ ctx.setStatus({ running: false, lastStopAt: Date.now() });
508
+ // Classify close codes for better logging and error handling
509
+ if (code === 1000) {
510
+ log.info(`WebSocket closed normally: ${code} ${reason.toString()}`);
511
+ }
512
+ else if (code === 1001) {
513
+ log.info(`WebSocket closed - endpoint going away: ${code} ${reason.toString()}`);
514
+ }
515
+ else if (code >= 1002 && code <= 1011) {
516
+ log.warn(`WebSocket closed with error: ${code} ${reason.toString()}`);
517
+ ctx.setStatus({
518
+ running: false,
519
+ lastStopAt: Date.now(),
520
+ lastError: `WebSocket closed with code ${code}: ${reason.toString()}`,
521
+ });
522
+ }
523
+ else if (code >= 4000) {
524
+ log.warn(`WebSocket closed with application error: ${code} ${reason.toString()}`);
525
+ ctx.setStatus({
526
+ running: false,
527
+ lastStopAt: Date.now(),
528
+ lastError: `Application error ${code}: ${reason.toString()}`,
529
+ });
530
+ }
531
+ else {
532
+ log.info(`WebSocket closed: ${code} ${reason.toString()}`);
533
+ }
534
+ resolve(cursor);
535
+ });
536
+ ws.on('error', (err) => {
537
+ cleanup();
538
+ isConnected = false;
539
+ const errorMessage = err instanceof Error ? err.message : String(err);
540
+ log.error(`WebSocket error: ${errorMessage}`);
541
+ ctx.setStatus({
542
+ running: false,
543
+ lastError: errorMessage,
544
+ lastStopAt: Date.now(),
545
+ });
546
+ reject(err);
547
+ });
548
+ // Clean shutdown - register abort handler before potential errors
549
+ ctx.abortSignal.addEventListener('abort', () => {
550
+ log.info('Shutdown requested - closing WebSocket');
551
+ cleanup();
552
+ try {
553
+ if (ws.readyState === WebSocket.OPEN ||
554
+ ws.readyState === WebSocket.CONNECTING) {
555
+ ws.close(1000, 'shutdown');
556
+ }
557
+ }
558
+ catch (err) {
559
+ const message = err instanceof Error ? err.message : String(err);
560
+ log.warn(`Error closing WebSocket during shutdown: ${message}`);
561
+ }
562
+ }, { once: true });
563
+ });
564
+ }
565
+ /**
566
+ * Fallback polling loop when WebSocket is unavailable.
567
+ * Polls every 30s until aborted or until we should retry WebSocket.
568
+ */
569
+ async function runPollingFallback(opts) {
570
+ const { client, ctx, log } = opts;
571
+ let cursor = opts.cursor;
572
+ let iterations = 0;
573
+ const maxIterations = 10; // After 10 polls (~5 min), try WebSocket again
574
+ ctx.setStatus({ running: true, lastStartAt: Date.now() });
575
+ log.info('Running in polling fallback mode.');
576
+ let consecutiveFailures = 0;
577
+ while (!ctx.abortSignal.aborted && iterations < maxIterations) {
578
+ const newCursor = await pollAndDeliver(client, ctx, cursor, log);
579
+ if (newCursor) {
580
+ cursor = newCursor;
581
+ consecutiveFailures = 0;
582
+ }
583
+ else if (newCursor === null && cursor) {
584
+ // null return with existing cursor means poll succeeded but no new messages - fine
585
+ consecutiveFailures = 0;
586
+ }
587
+ else {
588
+ consecutiveFailures++;
589
+ if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
590
+ log.warn(`${consecutiveFailures} consecutive poll failures, breaking out to retry WebSocket`);
591
+ break;
592
+ }
593
+ }
594
+ iterations++;
595
+ // Add jitter to poll interval to avoid thundering herd
596
+ const jitter = Math.random() * POLL_FALLBACK_INTERVAL_MS * 0.2;
597
+ await sleepWithAbort(POLL_FALLBACK_INTERVAL_MS + jitter, ctx.abortSignal);
598
+ }
599
+ ctx.setStatus({ running: false, lastStopAt: Date.now() });
600
+ return cursor;
601
+ }
602
+ /**
603
+ * Poll the messages endpoint and deliver new messages to the OpenClaw agent.
604
+ * Returns the new cursor, or null if no messages.
605
+ *
606
+ * On first run (cursor is null), we skip delivering old messages and just
607
+ * seed the cursor so we only receive messages from this point forward.
608
+ */
609
+ async function pollAndDeliver(client, ctx, cursor, log) {
610
+ try {
611
+ // SEED is a sentinel value indicating we've completed the first-run
612
+ // flow but had no real cursor (empty inbox). Don't send it to the API
613
+ // since it's not a valid datetime cursor.
614
+ const SEED = 'SEED';
615
+ const isFirstRun = !cursor || cursor === SEED;
616
+ const apiCursor = cursor && cursor !== SEED ? cursor : undefined;
617
+ const response = await client.listMessages({
618
+ ...(apiCursor ? { cursor: apiCursor } : {}),
619
+ });
620
+ // On first run, don't deliver old messages — just seed the cursor
621
+ // and send a welcome message so the user knows the connection is live.
622
+ if (isFirstRun) {
623
+ if (response.cursor) {
624
+ log.info(`First run: skipping ${response.items.length} existing message(s), seeding cursor.`);
625
+ saveCursor(ctx, response.cursor, log);
626
+ }
627
+ else {
628
+ log.info('First run: no messages yet (empty inbox).');
629
+ }
630
+ log.info('Connected to DeskFree. Ready to receive messages and tasks.');
631
+ // Return the API cursor, or SEED as sentinel when API returns null
632
+ // (empty inbox) so subsequent polls don't re-trigger first-run flow.
633
+ return response.cursor ?? SEED;
634
+ }
635
+ if (response.items.length === 0)
636
+ return null;
637
+ // Filter out already-delivered messages (handles cursor precision issues
638
+ // where Postgres microsecond timestamps round-trip through JS millisecond Date)
639
+ const newItems = response.items.filter((m) => !deliveredMessageIds.has(m.messageId));
640
+ if (newItems.length === 0) {
641
+ log.debug(`Poll returned ${response.items.length} item(s), all already delivered.`);
642
+ // Still advance cursor even if all items were duplicates
643
+ if (response.cursor) {
644
+ saveCursor(ctx, response.cursor, log);
645
+ }
646
+ return response.cursor;
647
+ }
648
+ log.info(`Received ${newItems.length} new message(s) (${response.items.length - newItems.length} duplicate(s) skipped).`);
649
+ // Save cursor after each successful delivery so a mid-batch
650
+ // failure doesn't cause the entire batch to be re-delivered on retry.
651
+ let deliveredCount = 0;
652
+ for (const message of newItems) {
653
+ // Skip bot's own messages to prevent echo loops
654
+ if (message.authorType === 'bot') {
655
+ log.debug(`Skipping bot message ${message.messageId}`);
656
+ deliveredMessageIds.add(message.messageId);
657
+ continue;
658
+ }
659
+ await deliverMessageToAgent(ctx, message, client);
660
+ deliveredMessageIds.add(message.messageId);
661
+ deliveredCount++;
662
+ }
663
+ // Cap the dedup set to prevent unbounded memory growth
664
+ if (deliveredMessageIds.size > 1000) {
665
+ const entries = Array.from(deliveredMessageIds);
666
+ deliveredMessageIds.clear();
667
+ for (const id of entries.slice(-500)) {
668
+ deliveredMessageIds.add(id);
669
+ }
670
+ }
671
+ // Record message delivery in health stats
672
+ if (deliveredCount > 0) {
673
+ recordMessageDelivery(ctx.accountId, deliveredCount);
674
+ }
675
+ // Persist cursor
676
+ if (response.cursor) {
677
+ saveCursor(ctx, response.cursor, log);
678
+ }
679
+ return response.cursor;
680
+ }
681
+ catch (err) {
682
+ const message = err instanceof Error ? err.message : String(err);
683
+ log.warn(`Poll failed: ${message}`);
684
+ return null;
685
+ }
686
+ }
687
+ //# sourceMappingURL=gateway.js.map