agent-relay 3.1.10 → 3.1.12
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/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +2 -2
- package/dist/src/cli/bootstrap.d.ts.map +1 -1
- package/dist/src/cli/bootstrap.js +2 -0
- package/dist/src/cli/bootstrap.js.map +1 -1
- package/dist/src/cli/commands/connect.d.ts +3 -0
- package/dist/src/cli/commands/connect.d.ts.map +1 -0
- package/dist/src/cli/commands/connect.js +18 -0
- package/dist/src/cli/commands/connect.js.map +1 -0
- package/dist/src/cli/lib/auth-ssh.d.ts.map +1 -1
- package/dist/src/cli/lib/auth-ssh.js +22 -270
- package/dist/src/cli/lib/auth-ssh.js.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.js +33 -0
- package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/src/cli/lib/connect-daytona.d.ts +15 -0
- package/dist/src/cli/lib/connect-daytona.d.ts.map +1 -0
- package/dist/src/cli/lib/connect-daytona.js +217 -0
- package/dist/src/cli/lib/connect-daytona.js.map +1 -0
- package/dist/src/cli/lib/ssh-interactive.d.ts +41 -0
- package/dist/src/cli/lib/ssh-interactive.d.ts.map +1 -0
- package/dist/src/cli/lib/ssh-interactive.js +320 -0
- package/dist/src/cli/lib/ssh-interactive.js.map +1 -0
- package/install.sh +2 -1
- package/package.json +13 -10
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/dist/cli-auth-config.d.ts +2 -0
- package/packages/config/dist/cli-auth-config.d.ts.map +1 -1
- package/packages/config/dist/cli-auth-config.js +1 -0
- package/packages/config/dist/cli-auth-config.js.map +1 -1
- package/packages/config/package.json +1 -1
- package/packages/config/src/cli-auth-config.ts +3 -0
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/dist/__tests__/gateway-control.test.js +13 -13
- package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js +369 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js +57 -16
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/ws-client.test.js +2 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
- package/packages/openclaw/dist/config.d.ts +2 -0
- package/packages/openclaw/dist/config.d.ts.map +1 -1
- package/packages/openclaw/dist/config.js +99 -12
- package/packages/openclaw/dist/config.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +56 -2
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +819 -127
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts +2 -0
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.js +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -1
- package/packages/openclaw/dist/runtime/setup.d.ts +0 -2
- package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -1
- package/packages/openclaw/dist/runtime/setup.js +53 -8
- package/packages/openclaw/dist/runtime/setup.js.map +1 -1
- package/packages/openclaw/dist/types.d.ts +28 -0
- package/packages/openclaw/dist/types.d.ts.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +150 -44
- package/packages/openclaw/src/__tests__/gateway-control.test.ts +14 -14
- package/packages/openclaw/src/__tests__/gateway-poll-fallback.test.ts +467 -0
- package/packages/openclaw/src/__tests__/gateway-threads.test.ts +73 -23
- package/packages/openclaw/src/__tests__/ws-client.test.ts +71 -51
- package/packages/openclaw/src/config.ts +121 -12
- package/packages/openclaw/src/gateway.ts +1155 -252
- package/packages/openclaw/src/runtime/openclaw-config.ts +3 -1
- package/packages/openclaw/src/runtime/setup.ts +57 -16
- package/packages/openclaw/src/types.ts +31 -0
- package/packages/openclaw/test/vitest.setup.ts +1 -0
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/__tests__/unit.test.js +131 -129
- package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
- package/packages/sdk/dist/relay.js +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +5 -3
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/unit.test.ts +142 -157
- package/packages/sdk/src/relay.ts +1 -1
- package/packages/sdk/src/workflows/runner.ts +12 -9
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -1,12 +1,88 @@
|
|
|
1
|
-
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
|
|
2
2
|
import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
-
import { createServer } from 'node:http';
|
|
3
|
+
import { createServer, } from 'node:http';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { RelayCast } from '@relaycast/sdk';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { openclawHome } from './config.js';
|
|
8
|
-
import { DEFAULT_OPENCLAW_GATEWAY_PORT } from './types.js';
|
|
8
|
+
import { DEFAULT_OPENCLAW_GATEWAY_PORT, } from './types.js';
|
|
9
9
|
import { SpawnManager } from './spawn/manager.js';
|
|
10
|
+
const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll';
|
|
11
|
+
const DEFAULT_POLL_INITIAL_CURSOR = '0';
|
|
12
|
+
const DEFAULT_WS_FAILURE_THRESHOLD = 3;
|
|
13
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
|
|
14
|
+
const MAX_POLL_TIMEOUT_SECONDS = 30;
|
|
15
|
+
const DEFAULT_POLL_LIMIT = 100;
|
|
16
|
+
const MAX_POLL_LIMIT = 500;
|
|
17
|
+
const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000;
|
|
18
|
+
const DEFAULT_WS_STABLE_GRACE_MS = 10_000;
|
|
19
|
+
const POLL_CURSOR_RECENT_EVENT_LIMIT = 256;
|
|
20
|
+
const MAX_POLL_CURSOR_LENGTH = 4_096;
|
|
21
|
+
const MAX_EVENT_ID_LENGTH = 512;
|
|
22
|
+
const BACKOFF_BASE_MS = 500;
|
|
23
|
+
const BACKOFF_CAP_MS = 30_000;
|
|
24
|
+
function pollCursorStatePath() {
|
|
25
|
+
return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json');
|
|
26
|
+
}
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
function applyJitter(ms) {
|
|
31
|
+
const factor = 1.1 + Math.random() * 0.1;
|
|
32
|
+
return Math.max(0, Math.floor(ms * factor));
|
|
33
|
+
}
|
|
34
|
+
function hasControlCharacters(value) {
|
|
35
|
+
for (const char of value) {
|
|
36
|
+
const code = char.charCodeAt(0);
|
|
37
|
+
if ((code >= 0 && code <= 31) || code === 127) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function stripControlChars(value) {
|
|
44
|
+
let out = '';
|
|
45
|
+
for (const char of value) {
|
|
46
|
+
const code = char.charCodeAt(0);
|
|
47
|
+
out += code <= 31 || code === 127 ? ' ' : char;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function sanitizeOpaqueStateValue(value, maxLength) {
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
return null;
|
|
54
|
+
if (value.trim().length === 0 || value.length > maxLength)
|
|
55
|
+
return null;
|
|
56
|
+
if (hasControlCharacters(value))
|
|
57
|
+
return null;
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function computeBackoffMs(attempt) {
|
|
61
|
+
const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS);
|
|
62
|
+
return applyJitter(base);
|
|
63
|
+
}
|
|
64
|
+
function sanitizePollTimeoutSeconds(value) {
|
|
65
|
+
if (!Number.isFinite(value))
|
|
66
|
+
return DEFAULT_POLL_TIMEOUT_SECONDS;
|
|
67
|
+
return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value)));
|
|
68
|
+
}
|
|
69
|
+
function sanitizePollLimit(value) {
|
|
70
|
+
if (!Number.isFinite(value))
|
|
71
|
+
return DEFAULT_POLL_LIMIT;
|
|
72
|
+
return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value)));
|
|
73
|
+
}
|
|
74
|
+
function parseRetryAfterMs(retryAfter) {
|
|
75
|
+
if (!retryAfter)
|
|
76
|
+
return null;
|
|
77
|
+
const seconds = Number(retryAfter);
|
|
78
|
+
if (Number.isFinite(seconds)) {
|
|
79
|
+
return Math.max(0, Math.floor(seconds * 1000));
|
|
80
|
+
}
|
|
81
|
+
const asDate = Date.parse(retryAfter);
|
|
82
|
+
if (Number.isNaN(asDate))
|
|
83
|
+
return null;
|
|
84
|
+
return Math.max(0, asDate - Date.now());
|
|
85
|
+
}
|
|
10
86
|
function normalizeChannelName(channel) {
|
|
11
87
|
return channel.startsWith('#') ? channel.slice(1) : channel;
|
|
12
88
|
}
|
|
@@ -44,7 +120,10 @@ function resolveAuthProfile() {
|
|
|
44
120
|
// which checks valid parseable config files, not just directory existence.
|
|
45
121
|
// Strict suffix check avoids false positives from substring matching.
|
|
46
122
|
const home = openclawHome();
|
|
47
|
-
const homeSuffix = home
|
|
123
|
+
const homeSuffix = home
|
|
124
|
+
.replace(/[/\\]+$/, '')
|
|
125
|
+
.split(/[/\\]/)
|
|
126
|
+
.pop() ?? '';
|
|
48
127
|
if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') {
|
|
49
128
|
return AUTH_PROFILES['clawdbot-v1'];
|
|
50
129
|
}
|
|
@@ -167,22 +246,69 @@ function buildCanonicalVariants(device, params) {
|
|
|
167
246
|
// V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily)
|
|
168
247
|
{
|
|
169
248
|
name: 'v3-default-ms',
|
|
170
|
-
payload: [
|
|
249
|
+
payload: [
|
|
250
|
+
'v3',
|
|
251
|
+
device.deviceId,
|
|
252
|
+
params.clientId,
|
|
253
|
+
params.clientMode,
|
|
254
|
+
params.role,
|
|
255
|
+
scopesCsv,
|
|
256
|
+
signedAtMs,
|
|
257
|
+
params.token || '',
|
|
258
|
+
params.nonce,
|
|
259
|
+
params.platform,
|
|
260
|
+
params.deviceFamily,
|
|
261
|
+
].join('|'),
|
|
171
262
|
},
|
|
172
263
|
// V1: signedAt in seconds instead of milliseconds
|
|
173
264
|
{
|
|
174
265
|
name: 'v3-default-sec',
|
|
175
|
-
payload: [
|
|
266
|
+
payload: [
|
|
267
|
+
'v3',
|
|
268
|
+
device.deviceId,
|
|
269
|
+
params.clientId,
|
|
270
|
+
params.clientMode,
|
|
271
|
+
params.role,
|
|
272
|
+
scopesCsv,
|
|
273
|
+
signedAtSec,
|
|
274
|
+
params.token || '',
|
|
275
|
+
params.nonce,
|
|
276
|
+
params.platform,
|
|
277
|
+
params.deviceFamily,
|
|
278
|
+
].join('|'),
|
|
176
279
|
},
|
|
177
280
|
// V2: no token in payload (token omitted entirely)
|
|
178
281
|
{
|
|
179
282
|
name: 'v3-no-token-ms',
|
|
180
|
-
payload: [
|
|
283
|
+
payload: [
|
|
284
|
+
'v3',
|
|
285
|
+
device.deviceId,
|
|
286
|
+
params.clientId,
|
|
287
|
+
params.clientMode,
|
|
288
|
+
params.role,
|
|
289
|
+
scopesCsv,
|
|
290
|
+
signedAtMs,
|
|
291
|
+
params.nonce,
|
|
292
|
+
params.platform,
|
|
293
|
+
params.deviceFamily,
|
|
294
|
+
].join('|'),
|
|
181
295
|
},
|
|
182
296
|
// V3: nonce before token (swapped positions)
|
|
183
297
|
{
|
|
184
298
|
name: 'v3-nonce-first-ms',
|
|
185
|
-
payload: [
|
|
299
|
+
payload: [
|
|
300
|
+
'v3',
|
|
301
|
+
device.deviceId,
|
|
302
|
+
params.clientId,
|
|
303
|
+
params.clientMode,
|
|
304
|
+
params.role,
|
|
305
|
+
scopesCsv,
|
|
306
|
+
signedAtMs,
|
|
307
|
+
params.nonce,
|
|
308
|
+
params.token || '',
|
|
309
|
+
params.platform,
|
|
310
|
+
params.deviceFamily,
|
|
311
|
+
].join('|'),
|
|
186
312
|
},
|
|
187
313
|
// V4: fewer fields — just core identity + nonce + signedAt (minimal)
|
|
188
314
|
{
|
|
@@ -192,22 +318,62 @@ function buildCanonicalVariants(device, params) {
|
|
|
192
318
|
// V5: signedAt seconds + no token
|
|
193
319
|
{
|
|
194
320
|
name: 'v3-no-token-sec',
|
|
195
|
-
payload: [
|
|
321
|
+
payload: [
|
|
322
|
+
'v3',
|
|
323
|
+
device.deviceId,
|
|
324
|
+
params.clientId,
|
|
325
|
+
params.clientMode,
|
|
326
|
+
params.role,
|
|
327
|
+
scopesCsv,
|
|
328
|
+
signedAtSec,
|
|
329
|
+
params.nonce,
|
|
330
|
+
params.platform,
|
|
331
|
+
params.deviceFamily,
|
|
332
|
+
].join('|'),
|
|
196
333
|
},
|
|
197
334
|
// V6: v2 format (no platform/deviceFamily) — used by older gateway versions
|
|
198
335
|
{
|
|
199
336
|
name: 'v2-default-ms',
|
|
200
|
-
payload: [
|
|
337
|
+
payload: [
|
|
338
|
+
'v2',
|
|
339
|
+
device.deviceId,
|
|
340
|
+
params.clientId,
|
|
341
|
+
params.clientMode,
|
|
342
|
+
params.role,
|
|
343
|
+
scopesCsv,
|
|
344
|
+
signedAtMs,
|
|
345
|
+
params.token || '',
|
|
346
|
+
params.nonce,
|
|
347
|
+
].join('|'),
|
|
201
348
|
},
|
|
202
349
|
// V7: v2 with signedAt in seconds
|
|
203
350
|
{
|
|
204
351
|
name: 'v2-default-sec',
|
|
205
|
-
payload: [
|
|
352
|
+
payload: [
|
|
353
|
+
'v2',
|
|
354
|
+
device.deviceId,
|
|
355
|
+
params.clientId,
|
|
356
|
+
params.clientMode,
|
|
357
|
+
params.role,
|
|
358
|
+
scopesCsv,
|
|
359
|
+
signedAtSec,
|
|
360
|
+
params.token || '',
|
|
361
|
+
params.nonce,
|
|
362
|
+
].join('|'),
|
|
206
363
|
},
|
|
207
364
|
// V8: v2 without token
|
|
208
365
|
{
|
|
209
366
|
name: 'v2-no-token-ms',
|
|
210
|
-
payload: [
|
|
367
|
+
payload: [
|
|
368
|
+
'v2',
|
|
369
|
+
device.deviceId,
|
|
370
|
+
params.clientId,
|
|
371
|
+
params.clientMode,
|
|
372
|
+
params.role,
|
|
373
|
+
scopesCsv,
|
|
374
|
+
signedAtMs,
|
|
375
|
+
params.nonce,
|
|
376
|
+
].join('|'),
|
|
211
377
|
},
|
|
212
378
|
];
|
|
213
379
|
}
|
|
@@ -229,7 +395,7 @@ function signConnectPayload(device, params, versionOverride) {
|
|
|
229
395
|
else {
|
|
230
396
|
primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms';
|
|
231
397
|
}
|
|
232
|
-
const primary = variants.find(v => v.name === primaryName) ?? variants[0];
|
|
398
|
+
const primary = variants.find((v) => v.name === primaryName) ?? variants[0];
|
|
233
399
|
const payloadBytes = Buffer.from(primary.payload, 'utf-8');
|
|
234
400
|
const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1';
|
|
235
401
|
// Concise production log — one line with essential info
|
|
@@ -371,6 +537,9 @@ export class OpenClawGatewayClient {
|
|
|
371
537
|
console.log('[openclaw-ws] Connected to OpenClaw gateway');
|
|
372
538
|
});
|
|
373
539
|
ws.on('message', (data) => {
|
|
540
|
+
// Guard: ignore messages from superseded WebSocket instances.
|
|
541
|
+
if (this.ws !== ws)
|
|
542
|
+
return;
|
|
374
543
|
this.handleMessage(data.toString());
|
|
375
544
|
});
|
|
376
545
|
ws.on('close', (code, reason) => {
|
|
@@ -378,7 +547,8 @@ export class OpenClawGatewayClient {
|
|
|
378
547
|
// During v3↔v2 fallback, the old WS is replaced before its close fires.
|
|
379
548
|
if (this.ws !== ws)
|
|
380
549
|
return;
|
|
381
|
-
|
|
550
|
+
// Sanitize reason to prevent log injection (newlines, control chars)
|
|
551
|
+
const reasonStr = stripControlChars(reason.toString()).slice(0, 200);
|
|
382
552
|
console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`);
|
|
383
553
|
const wasAuthenticated = this.authenticated;
|
|
384
554
|
this.authenticated = false;
|
|
@@ -421,6 +591,7 @@ export class OpenClawGatewayClient {
|
|
|
421
591
|
}
|
|
422
592
|
});
|
|
423
593
|
}
|
|
594
|
+
// eslint-disable-next-line complexity
|
|
424
595
|
handleMessage(raw) {
|
|
425
596
|
let msg;
|
|
426
597
|
try {
|
|
@@ -501,8 +672,7 @@ export class OpenClawGatewayClient {
|
|
|
501
672
|
if (msg.type === 'res' && msg.id === 'connect-1') {
|
|
502
673
|
if (msg.ok) {
|
|
503
674
|
this.clearConnectTimeout();
|
|
504
|
-
const versionUsed = this.payloadVersionOverride
|
|
505
|
-
?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
675
|
+
const versionUsed = this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
506
676
|
console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`);
|
|
507
677
|
this.authenticated = true;
|
|
508
678
|
this.consecutiveFailures = 0;
|
|
@@ -523,9 +693,7 @@ export class OpenClawGatewayClient {
|
|
|
523
693
|
console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
|
|
524
694
|
}
|
|
525
695
|
console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
|
|
526
|
-
const configHint = getWsAuthCompat() === 'clawdbot'
|
|
527
|
-
? '~/.clawdbot/clawdbot.json'
|
|
528
|
-
: '~/.openclaw/openclaw.json';
|
|
696
|
+
const configHint = getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json';
|
|
529
697
|
console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`);
|
|
530
698
|
this.pairingRejected = true;
|
|
531
699
|
}
|
|
@@ -535,8 +703,7 @@ export class OpenClawGatewayClient {
|
|
|
535
703
|
this.authRejectCount++;
|
|
536
704
|
this.authFallbackCount++;
|
|
537
705
|
const profile = resolveAuthProfile();
|
|
538
|
-
const currentVersion = this.payloadVersionOverride
|
|
539
|
-
?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
706
|
+
const currentVersion = this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
540
707
|
const fallbackVersion = currentVersion === 'v2' ? 'v3' : 'v2';
|
|
541
708
|
console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`);
|
|
542
709
|
this.payloadVersionOverride = fallbackVersion;
|
|
@@ -547,7 +714,9 @@ export class OpenClawGatewayClient {
|
|
|
547
714
|
try {
|
|
548
715
|
this.ws?.close();
|
|
549
716
|
}
|
|
550
|
-
catch {
|
|
717
|
+
catch {
|
|
718
|
+
// Best effort
|
|
719
|
+
}
|
|
551
720
|
this.ws = null;
|
|
552
721
|
setTimeout(() => this.doConnect(), 0);
|
|
553
722
|
return; // Don't reject the connect promise yet — fallback attempt in progress
|
|
@@ -570,12 +739,11 @@ export class OpenClawGatewayClient {
|
|
|
570
739
|
clearTimeout(pending.timer);
|
|
571
740
|
this.pendingRpcs.delete(id);
|
|
572
741
|
if (msg.ok === false || msg.error) {
|
|
573
|
-
console.warn(
|
|
742
|
+
console.warn('[openclaw-ws] RPC error response received');
|
|
574
743
|
pending.resolve(false);
|
|
575
744
|
}
|
|
576
745
|
else {
|
|
577
|
-
|
|
578
|
-
console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
|
|
746
|
+
console.log('[openclaw-ws] RPC succeeded');
|
|
579
747
|
pending.resolve(true);
|
|
580
748
|
}
|
|
581
749
|
return;
|
|
@@ -641,6 +809,9 @@ export class OpenClawGatewayClient {
|
|
|
641
809
|
this.reconnectTimer = setTimeout(() => {
|
|
642
810
|
this.reconnectTimer = null;
|
|
643
811
|
this.pairingRejected = false; // Clear flag so connect attempt proceeds
|
|
812
|
+
// Reset fallback state so reconnect tries primary payload version first
|
|
813
|
+
this.payloadVersionOverride = null;
|
|
814
|
+
this.fallbackAttempted = false;
|
|
644
815
|
this.doConnect();
|
|
645
816
|
}, OpenClawGatewayClient.PAIRING_RETRY_MS);
|
|
646
817
|
return;
|
|
@@ -650,6 +821,9 @@ export class OpenClawGatewayClient {
|
|
|
650
821
|
console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`);
|
|
651
822
|
this.reconnectTimer = setTimeout(() => {
|
|
652
823
|
this.reconnectTimer = null;
|
|
824
|
+
// Reset fallback state so reconnect tries primary payload version first
|
|
825
|
+
this.payloadVersionOverride = null;
|
|
826
|
+
this.fallbackAttempted = false;
|
|
653
827
|
this.doConnect();
|
|
654
828
|
}, delay);
|
|
655
829
|
}
|
|
@@ -669,7 +843,9 @@ export class OpenClawGatewayClient {
|
|
|
669
843
|
try {
|
|
670
844
|
this.ws.close();
|
|
671
845
|
}
|
|
672
|
-
catch {
|
|
846
|
+
catch {
|
|
847
|
+
// Best effort
|
|
848
|
+
}
|
|
673
849
|
this.ws = null;
|
|
674
850
|
}
|
|
675
851
|
this.authenticated = false;
|
|
@@ -684,6 +860,7 @@ export class OpenClawGatewayClient {
|
|
|
684
860
|
export class InboundGateway {
|
|
685
861
|
relaySender;
|
|
686
862
|
relayAgentClient = null;
|
|
863
|
+
relayAgentToken = null;
|
|
687
864
|
relaycast;
|
|
688
865
|
config;
|
|
689
866
|
dedupeTtlMs;
|
|
@@ -699,6 +876,25 @@ export class InboundGateway {
|
|
|
699
876
|
controlServer = null;
|
|
700
877
|
/** Port the control server listens on. */
|
|
701
878
|
controlPort = 0;
|
|
879
|
+
transportState = 'WS_DEGRADED';
|
|
880
|
+
activeTransportMode = 'ws';
|
|
881
|
+
wsFailureCount = 0;
|
|
882
|
+
pollLoopPromise = null;
|
|
883
|
+
pollAbortController = null;
|
|
884
|
+
pollLoopStopRequested = false;
|
|
885
|
+
pollCursorLoaded = false;
|
|
886
|
+
pollCursor = DEFAULT_POLL_INITIAL_CURSOR;
|
|
887
|
+
pollLastSequence = 0;
|
|
888
|
+
pollRecentEventIds = [];
|
|
889
|
+
pollFailureCount = 0;
|
|
890
|
+
probeWsTimer = null;
|
|
891
|
+
wsRecoveryTimer = null;
|
|
892
|
+
fallbackCount = 0;
|
|
893
|
+
lastFallbackReason = null;
|
|
894
|
+
fallbackStartedAt = null;
|
|
895
|
+
totalFallbackMs = 0;
|
|
896
|
+
duplicateDropCount = 0;
|
|
897
|
+
cursorResetCount = 0;
|
|
702
898
|
/** Default control port for the gateway's spawn API. */
|
|
703
899
|
static DEFAULT_CONTROL_PORT = 18790;
|
|
704
900
|
constructor(options) {
|
|
@@ -712,92 +908,578 @@ export class InboundGateway {
|
|
|
712
908
|
baseUrl: this.config.baseUrl,
|
|
713
909
|
});
|
|
714
910
|
const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
|
|
715
|
-
this.dedupeTtlMs =
|
|
716
|
-
? Math.floor(dedupeTtlMs)
|
|
717
|
-
: 15 * 60 * 1000;
|
|
911
|
+
this.dedupeTtlMs =
|
|
912
|
+
Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000;
|
|
718
913
|
const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
|
|
719
914
|
this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
|
|
720
915
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
916
|
+
isPollFallbackEnabled() {
|
|
917
|
+
return this.config.transport?.pollFallback?.enabled ?? false;
|
|
918
|
+
}
|
|
919
|
+
wsFailureThreshold() {
|
|
920
|
+
const configured = this.config.transport?.pollFallback?.wsFailureThreshold;
|
|
921
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
922
|
+
return DEFAULT_WS_FAILURE_THRESHOLD;
|
|
923
|
+
}
|
|
924
|
+
return Math.max(1, Math.floor(configured));
|
|
925
|
+
}
|
|
926
|
+
pollTimeoutSeconds() {
|
|
927
|
+
return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds);
|
|
928
|
+
}
|
|
929
|
+
pollLimit() {
|
|
930
|
+
return sanitizePollLimit(this.config.transport?.pollFallback?.limit);
|
|
931
|
+
}
|
|
932
|
+
pollInitialCursor() {
|
|
933
|
+
return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR;
|
|
934
|
+
}
|
|
935
|
+
isWsProbeEnabled() {
|
|
936
|
+
return this.config.transport?.pollFallback?.probeWs?.enabled ?? true;
|
|
937
|
+
}
|
|
938
|
+
wsProbeIntervalMs() {
|
|
939
|
+
const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs;
|
|
940
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
941
|
+
return DEFAULT_WS_PROBE_INTERVAL_MS;
|
|
942
|
+
}
|
|
943
|
+
return Math.max(1_000, Math.floor(configured));
|
|
944
|
+
}
|
|
945
|
+
wsStableGraceMs() {
|
|
946
|
+
const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs;
|
|
947
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
948
|
+
return DEFAULT_WS_STABLE_GRACE_MS;
|
|
949
|
+
}
|
|
950
|
+
return Math.max(1_000, Math.floor(configured));
|
|
951
|
+
}
|
|
952
|
+
transportHealthSnapshot() {
|
|
953
|
+
const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt;
|
|
954
|
+
return {
|
|
955
|
+
mode: this.activeTransportMode,
|
|
956
|
+
state: this.transportState,
|
|
957
|
+
wsFailureCount: this.wsFailureCount,
|
|
958
|
+
fallbackCount: this.fallbackCount,
|
|
959
|
+
lastFallbackReason: this.lastFallbackReason,
|
|
960
|
+
timeInFallbackMs: this.totalFallbackMs + activeFallbackMs,
|
|
961
|
+
duplicateDrops: this.duplicateDropCount,
|
|
962
|
+
cursorResets: this.cursorResetCount,
|
|
963
|
+
lastSequence: this.pollLastSequence,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
completeFallbackWindow() {
|
|
967
|
+
if (this.fallbackStartedAt !== null) {
|
|
968
|
+
this.totalFallbackMs += Date.now() - this.fallbackStartedAt;
|
|
969
|
+
this.fallbackStartedAt = null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
cleanupRelaySubscriptions() {
|
|
973
|
+
for (const unsubscribe of this.unsubscribeHandlers) {
|
|
731
974
|
try {
|
|
732
|
-
|
|
733
|
-
console.log('[gateway] OpenClaw gateway WebSocket client ready');
|
|
975
|
+
unsubscribe();
|
|
734
976
|
}
|
|
735
|
-
catch
|
|
736
|
-
|
|
977
|
+
catch {
|
|
978
|
+
// Best effort
|
|
737
979
|
}
|
|
738
980
|
}
|
|
739
|
-
|
|
740
|
-
|
|
981
|
+
this.unsubscribeHandlers = [];
|
|
982
|
+
}
|
|
983
|
+
subscribeRelayChannels() {
|
|
984
|
+
if (!this.relayAgentClient)
|
|
985
|
+
return;
|
|
986
|
+
try {
|
|
987
|
+
this.relayAgentClient.subscribe(this.config.channels);
|
|
741
988
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this.relayAgentClient
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
989
|
+
catch {
|
|
990
|
+
// Will subscribe on the next connected event.
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async connectRelayAgentClient() {
|
|
994
|
+
if (!this.relayAgentClient)
|
|
995
|
+
return;
|
|
996
|
+
try {
|
|
997
|
+
await Promise.resolve(this.relayAgentClient.connect());
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
console.warn(`[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1001
|
+
await this.handleWsFailure('connect_failed');
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
bindRelayAgentHandlers() {
|
|
1005
|
+
if (!this.relayAgentClient)
|
|
1006
|
+
return;
|
|
1007
|
+
this.cleanupRelaySubscriptions();
|
|
751
1008
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.connected(() => {
|
|
752
1009
|
console.log(`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`);
|
|
753
|
-
this.
|
|
1010
|
+
this.wsFailureCount = 0;
|
|
1011
|
+
this.subscribeRelayChannels();
|
|
1012
|
+
if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') {
|
|
1013
|
+
this.beginWsRecovery();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
this.completeFallbackWindow();
|
|
1017
|
+
this.transportState = 'WS_ACTIVE';
|
|
1018
|
+
this.activeTransportMode = 'ws';
|
|
754
1019
|
}));
|
|
755
1020
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.messageCreated((event) => {
|
|
1021
|
+
if (!this.shouldProcessWsInbound())
|
|
1022
|
+
return;
|
|
756
1023
|
console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
|
|
757
1024
|
void this.handleRealtimeMessage(event);
|
|
758
1025
|
}));
|
|
759
1026
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.threadReply((event) => {
|
|
1027
|
+
if (!this.shouldProcessWsInbound())
|
|
1028
|
+
return;
|
|
760
1029
|
console.log(`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`);
|
|
761
1030
|
void this.handleRealtimeThreadReply(event);
|
|
762
1031
|
}));
|
|
763
1032
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.dmReceived((event) => {
|
|
1033
|
+
if (!this.shouldProcessWsInbound())
|
|
1034
|
+
return;
|
|
764
1035
|
console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
|
|
765
1036
|
void this.handleRealtimeDm(event);
|
|
766
1037
|
}));
|
|
767
1038
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.groupDmReceived((event) => {
|
|
1039
|
+
if (!this.shouldProcessWsInbound())
|
|
1040
|
+
return;
|
|
768
1041
|
console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
|
|
769
1042
|
void this.handleRealtimeGroupDm(event);
|
|
770
1043
|
}));
|
|
771
1044
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.commandInvoked((event) => {
|
|
1045
|
+
if (!this.shouldProcessWsInbound())
|
|
1046
|
+
return;
|
|
772
1047
|
console.log(`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`);
|
|
773
1048
|
void this.handleRealtimeCommand(event);
|
|
774
1049
|
}));
|
|
775
1050
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.reactionAdded((event) => {
|
|
1051
|
+
if (!this.shouldProcessWsInbound())
|
|
1052
|
+
return;
|
|
776
1053
|
console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
|
|
777
1054
|
void this.handleRealtimeReaction(event, 'added');
|
|
778
1055
|
}));
|
|
779
1056
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.reactionRemoved((event) => {
|
|
1057
|
+
if (!this.shouldProcessWsInbound())
|
|
1058
|
+
return;
|
|
780
1059
|
console.log(`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`);
|
|
781
1060
|
void this.handleRealtimeReaction(event, 'removed');
|
|
782
1061
|
}));
|
|
783
1062
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.reconnecting((attempt) => {
|
|
784
1063
|
console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
|
|
1064
|
+
void this.handleWsFailure(`reconnecting:${attempt}`);
|
|
785
1065
|
}));
|
|
786
1066
|
this.unsubscribeHandlers.push(this.relayAgentClient.on.disconnected(() => {
|
|
787
|
-
console.warn(
|
|
1067
|
+
console.warn('[gateway] Relaycast disconnected');
|
|
1068
|
+
void this.handleWsFailure('disconnected');
|
|
788
1069
|
}));
|
|
789
|
-
this.unsubscribeHandlers.push(this.relayAgentClient.on.error(() => {
|
|
790
|
-
|
|
1070
|
+
this.unsubscribeHandlers.push(this.relayAgentClient.on.error((error) => {
|
|
1071
|
+
const message = error instanceof Error ? error.message : 'socket error';
|
|
1072
|
+
console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`);
|
|
1073
|
+
void this.handleWsFailure(message || 'socket_error');
|
|
791
1074
|
}));
|
|
1075
|
+
}
|
|
1076
|
+
async replaceRelayAgentClient(agentToken) {
|
|
1077
|
+
this.cleanupRelaySubscriptions();
|
|
1078
|
+
if (this.relayAgentClient) {
|
|
1079
|
+
try {
|
|
1080
|
+
await this.relayAgentClient.disconnect();
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
// Best effort
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
this.relayAgentToken = agentToken;
|
|
1087
|
+
this.relayAgentClient = this.relaycast.as(agentToken);
|
|
1088
|
+
this.bindRelayAgentHandlers();
|
|
1089
|
+
await this.connectRelayAgentClient();
|
|
1090
|
+
}
|
|
1091
|
+
async refreshRelayAgentRegistration() {
|
|
1092
|
+
const registered = await this.relaycast.agents.registerOrRotate({
|
|
1093
|
+
name: this.config.clawName,
|
|
1094
|
+
type: 'agent',
|
|
1095
|
+
persona: 'Relaycast inbound gateway for OpenClaw',
|
|
1096
|
+
});
|
|
1097
|
+
await this.replaceRelayAgentClient(registered.token);
|
|
792
1098
|
await this.ensureChannelMembership();
|
|
793
|
-
|
|
794
|
-
|
|
1099
|
+
this.subscribeRelayChannels();
|
|
1100
|
+
}
|
|
1101
|
+
shouldProcessWsInbound() {
|
|
1102
|
+
return (!this.isPollFallbackEnabled() ||
|
|
1103
|
+
this.transportState === 'WS_ACTIVE' ||
|
|
1104
|
+
this.transportState === 'RECOVERING_WS');
|
|
1105
|
+
}
|
|
1106
|
+
async handleWsFailure(reason) {
|
|
1107
|
+
if (!this.running)
|
|
1108
|
+
return;
|
|
1109
|
+
if (this.wsRecoveryTimer) {
|
|
1110
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1111
|
+
this.wsRecoveryTimer = null;
|
|
1112
|
+
}
|
|
1113
|
+
if (this.transportState === 'RECOVERING_WS') {
|
|
1114
|
+
console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`);
|
|
1115
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1116
|
+
this.activeTransportMode = 'poll';
|
|
1117
|
+
await this.startPollLoop();
|
|
1118
|
+
this.startWsProbeLoop();
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (this.transportState === 'POLL_ACTIVE') {
|
|
1122
|
+
this.lastFallbackReason = reason;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
this.transportState = 'WS_DEGRADED';
|
|
1126
|
+
this.wsFailureCount += 1;
|
|
1127
|
+
if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) {
|
|
1128
|
+
await this.activatePollFallback(reason);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
async activatePollFallback(reason) {
|
|
1132
|
+
if (!this.running || !this.isPollFallbackEnabled())
|
|
1133
|
+
return;
|
|
1134
|
+
if (this.transportState === 'POLL_ACTIVE')
|
|
1135
|
+
return;
|
|
1136
|
+
await this.ensurePollCursorLoaded();
|
|
1137
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1138
|
+
this.activeTransportMode = 'poll';
|
|
1139
|
+
this.fallbackCount += 1;
|
|
1140
|
+
this.lastFallbackReason = reason;
|
|
1141
|
+
if (this.fallbackStartedAt === null) {
|
|
1142
|
+
this.fallbackStartedAt = Date.now();
|
|
1143
|
+
}
|
|
1144
|
+
console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`);
|
|
1145
|
+
await this.startPollLoop();
|
|
1146
|
+
this.startWsProbeLoop();
|
|
1147
|
+
}
|
|
1148
|
+
startWsProbeLoop() {
|
|
1149
|
+
if (!this.isWsProbeEnabled() || this.probeWsTimer)
|
|
1150
|
+
return;
|
|
1151
|
+
this.probeWsTimer = setInterval(() => {
|
|
1152
|
+
if (!this.running || this.transportState !== 'POLL_ACTIVE')
|
|
1153
|
+
return;
|
|
1154
|
+
void this.connectRelayAgentClient();
|
|
1155
|
+
}, this.wsProbeIntervalMs());
|
|
1156
|
+
}
|
|
1157
|
+
stopWsProbeLoop() {
|
|
1158
|
+
if (!this.probeWsTimer)
|
|
1159
|
+
return;
|
|
1160
|
+
clearInterval(this.probeWsTimer);
|
|
1161
|
+
this.probeWsTimer = null;
|
|
1162
|
+
}
|
|
1163
|
+
beginWsRecovery() {
|
|
1164
|
+
if (!this.running)
|
|
1165
|
+
return;
|
|
1166
|
+
this.transportState = 'RECOVERING_WS';
|
|
1167
|
+
this.stopWsProbeLoop();
|
|
1168
|
+
if (this.wsRecoveryTimer) {
|
|
1169
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1170
|
+
}
|
|
1171
|
+
console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`);
|
|
1172
|
+
this.wsRecoveryTimer = setTimeout(() => {
|
|
1173
|
+
this.wsRecoveryTimer = null;
|
|
1174
|
+
void this.promoteWsTransport();
|
|
1175
|
+
}, this.wsStableGraceMs());
|
|
1176
|
+
}
|
|
1177
|
+
async promoteWsTransport() {
|
|
1178
|
+
if (!this.running || this.transportState !== 'RECOVERING_WS')
|
|
1179
|
+
return;
|
|
1180
|
+
await this.stopPollLoop();
|
|
1181
|
+
const catchupDelayMs = await this.pollOnce(0);
|
|
1182
|
+
if (catchupDelayMs > 0) {
|
|
1183
|
+
console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll');
|
|
1184
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1185
|
+
this.activeTransportMode = 'poll';
|
|
1186
|
+
await this.startPollLoop();
|
|
1187
|
+
this.startWsProbeLoop();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
this.completeFallbackWindow();
|
|
1191
|
+
this.transportState = 'WS_ACTIVE';
|
|
1192
|
+
this.activeTransportMode = 'ws';
|
|
1193
|
+
this.wsFailureCount = 0;
|
|
1194
|
+
console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport');
|
|
1195
|
+
}
|
|
1196
|
+
async ensurePollCursorLoaded() {
|
|
1197
|
+
if (this.pollCursorLoaded)
|
|
1198
|
+
return;
|
|
1199
|
+
this.pollCursorLoaded = true;
|
|
1200
|
+
this.pollCursor = this.pollInitialCursor();
|
|
795
1201
|
try {
|
|
796
|
-
|
|
1202
|
+
const raw = await readFile(pollCursorStatePath(), 'utf-8');
|
|
1203
|
+
const parsed = JSON.parse(raw);
|
|
1204
|
+
const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH);
|
|
1205
|
+
if (persistedCursor) {
|
|
1206
|
+
this.pollCursor = persistedCursor;
|
|
1207
|
+
}
|
|
1208
|
+
if (Number.isFinite(parsed.lastSequence)) {
|
|
1209
|
+
this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0));
|
|
1210
|
+
}
|
|
1211
|
+
if (Array.isArray(parsed.recentEventIds)) {
|
|
1212
|
+
this.pollRecentEventIds = parsed.recentEventIds
|
|
1213
|
+
.map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH))
|
|
1214
|
+
.filter((value) => value !== null)
|
|
1215
|
+
.slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1216
|
+
const now = Date.now();
|
|
1217
|
+
for (const eventId of this.pollRecentEventIds) {
|
|
1218
|
+
this.seenMessageIds.set(eventId, now);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
797
1221
|
}
|
|
798
|
-
catch {
|
|
799
|
-
|
|
1222
|
+
catch (error) {
|
|
1223
|
+
if (error.code !== 'ENOENT') {
|
|
1224
|
+
console.warn(`[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}`);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
async persistPollCursorState() {
|
|
1229
|
+
const cursor = sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor();
|
|
1230
|
+
const recentEventIds = this.pollRecentEventIds
|
|
1231
|
+
.map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH))
|
|
1232
|
+
.filter((eventId) => eventId !== null)
|
|
1233
|
+
.slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1234
|
+
this.pollCursor = cursor;
|
|
1235
|
+
this.pollRecentEventIds = recentEventIds;
|
|
1236
|
+
const state = {
|
|
1237
|
+
cursor,
|
|
1238
|
+
lastSequence: this.pollLastSequence,
|
|
1239
|
+
recentEventIds,
|
|
1240
|
+
updatedAt: new Date().toISOString(),
|
|
1241
|
+
};
|
|
1242
|
+
const filePath = pollCursorStatePath();
|
|
1243
|
+
const tmpPath = `${filePath}.tmp`;
|
|
1244
|
+
await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true });
|
|
1245
|
+
await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
1246
|
+
await rename(tmpPath, filePath);
|
|
1247
|
+
}
|
|
1248
|
+
rememberPollEventId(eventId) {
|
|
1249
|
+
const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH);
|
|
1250
|
+
if (!sanitizedEventId)
|
|
1251
|
+
return;
|
|
1252
|
+
this.pollRecentEventIds = [
|
|
1253
|
+
...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId),
|
|
1254
|
+
sanitizedEventId,
|
|
1255
|
+
].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1256
|
+
}
|
|
1257
|
+
hasRecentPollEventId(eventId) {
|
|
1258
|
+
return this.pollRecentEventIds.includes(eventId);
|
|
1259
|
+
}
|
|
1260
|
+
async commitPollCursorState(nextCursor, lastSequence) {
|
|
1261
|
+
const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH);
|
|
1262
|
+
if (sanitizedCursor) {
|
|
1263
|
+
this.pollCursor = sanitizedCursor;
|
|
1264
|
+
}
|
|
1265
|
+
this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence);
|
|
1266
|
+
await this.persistPollCursorState();
|
|
1267
|
+
}
|
|
1268
|
+
async resetPollCursorState(reason) {
|
|
1269
|
+
this.cursorResetCount += 1;
|
|
1270
|
+
this.lastFallbackReason = reason;
|
|
1271
|
+
this.pollCursor = this.pollInitialCursor();
|
|
1272
|
+
this.pollLastSequence = 0;
|
|
1273
|
+
await this.persistPollCursorState();
|
|
1274
|
+
}
|
|
1275
|
+
async startPollLoop() {
|
|
1276
|
+
if (this.pollLoopPromise)
|
|
1277
|
+
return;
|
|
1278
|
+
this.pollLoopStopRequested = false;
|
|
1279
|
+
this.pollLoopPromise = (async () => {
|
|
1280
|
+
while (this.running &&
|
|
1281
|
+
!this.pollLoopStopRequested &&
|
|
1282
|
+
(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')) {
|
|
1283
|
+
const delayMs = await this.pollOnce(this.pollTimeoutSeconds());
|
|
1284
|
+
if (!this.running ||
|
|
1285
|
+
this.pollLoopStopRequested ||
|
|
1286
|
+
!(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')) {
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
if (delayMs > 0) {
|
|
1290
|
+
await sleep(delayMs);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
})().finally(() => {
|
|
1294
|
+
this.pollLoopPromise = null;
|
|
1295
|
+
this.pollAbortController = null;
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
async stopPollLoop() {
|
|
1299
|
+
this.pollLoopStopRequested = true;
|
|
1300
|
+
if (this.pollAbortController) {
|
|
1301
|
+
this.pollAbortController.abort();
|
|
1302
|
+
this.pollAbortController = null;
|
|
1303
|
+
}
|
|
1304
|
+
if (this.pollLoopPromise) {
|
|
1305
|
+
await this.pollLoopPromise.catch(() => undefined);
|
|
1306
|
+
this.pollLoopPromise = null;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// eslint-disable-next-line complexity
|
|
1310
|
+
async pollOnce(timeoutSeconds) {
|
|
1311
|
+
await this.ensurePollCursorLoaded();
|
|
1312
|
+
const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl);
|
|
1313
|
+
baseUrl.searchParams.set('cursor', this.pollCursor);
|
|
1314
|
+
baseUrl.searchParams.set('timeout', String(timeoutSeconds));
|
|
1315
|
+
baseUrl.searchParams.set('limit', String(this.pollLimit()));
|
|
1316
|
+
const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000);
|
|
1317
|
+
const abortController = new AbortController();
|
|
1318
|
+
this.pollAbortController = abortController;
|
|
1319
|
+
const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs);
|
|
1320
|
+
try {
|
|
1321
|
+
const response = await fetch(baseUrl, {
|
|
1322
|
+
method: 'GET',
|
|
1323
|
+
headers: {
|
|
1324
|
+
Accept: 'application/json',
|
|
1325
|
+
Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '',
|
|
1326
|
+
'x-api-key': this.config.apiKey,
|
|
1327
|
+
},
|
|
1328
|
+
signal: abortController.signal,
|
|
1329
|
+
});
|
|
1330
|
+
if (response.status === 401 || response.status === 403) {
|
|
1331
|
+
console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`);
|
|
1332
|
+
try {
|
|
1333
|
+
await this.refreshRelayAgentRegistration();
|
|
1334
|
+
this.pollFailureCount = 0;
|
|
1335
|
+
return 0;
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
console.warn(`[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1339
|
+
this.pollFailureCount += 1;
|
|
1340
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (response.status === 409) {
|
|
1344
|
+
console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state');
|
|
1345
|
+
this.pollFailureCount = 0;
|
|
1346
|
+
await this.resetPollCursorState('cursor_reset');
|
|
1347
|
+
return 0;
|
|
1348
|
+
}
|
|
1349
|
+
if (response.status === 429) {
|
|
1350
|
+
this.pollFailureCount += 1;
|
|
1351
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
|
|
1352
|
+
return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount);
|
|
1353
|
+
}
|
|
1354
|
+
if (response.status >= 500) {
|
|
1355
|
+
this.pollFailureCount += 1;
|
|
1356
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1357
|
+
}
|
|
1358
|
+
if (!response.ok) {
|
|
1359
|
+
this.pollFailureCount += 1;
|
|
1360
|
+
console.warn(`[gateway] Poll request failed: HTTP ${response.status}`);
|
|
1361
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1362
|
+
}
|
|
1363
|
+
const body = (await response.json());
|
|
1364
|
+
this.pollFailureCount = 0;
|
|
1365
|
+
const processed = await this.processPollResponse(body);
|
|
1366
|
+
return processed ? 0 : computeBackoffMs(1);
|
|
1367
|
+
}
|
|
1368
|
+
catch (error) {
|
|
1369
|
+
if (abortController.signal.aborted) {
|
|
1370
|
+
return 0;
|
|
1371
|
+
}
|
|
1372
|
+
this.pollFailureCount += 1;
|
|
1373
|
+
console.warn(`[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1374
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
clearTimeout(timeoutHandle);
|
|
1378
|
+
if (this.pollAbortController === abortController) {
|
|
1379
|
+
this.pollAbortController = null;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async processPollResponse(body) {
|
|
1384
|
+
const events = Array.isArray(body.events) ? [...body.events] : [];
|
|
1385
|
+
events.sort((left, right) => left.sequence - right.sequence);
|
|
1386
|
+
let lastSequence = this.pollLastSequence;
|
|
1387
|
+
for (const event of events) {
|
|
1388
|
+
if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) {
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
lastSequence = Math.max(lastSequence, event.sequence);
|
|
1392
|
+
if (event.sequence <= this.pollLastSequence ||
|
|
1393
|
+
this.hasRecentPollEventId(event.id) ||
|
|
1394
|
+
this.isSeen(event.id)) {
|
|
1395
|
+
this.duplicateDropCount += 1;
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const committed = await this.handlePolledEvent(event);
|
|
1399
|
+
if (!committed) {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
this.rememberPollEventId(event.id);
|
|
1403
|
+
}
|
|
1404
|
+
const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor;
|
|
1405
|
+
await this.commitPollCursorState(nextCursor, lastSequence);
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
// eslint-disable-next-line complexity
|
|
1409
|
+
async handlePolledEvent(event) {
|
|
1410
|
+
const type = typeof event.payload.type === 'string' ? event.payload.type : '';
|
|
1411
|
+
const baseOptions = {
|
|
1412
|
+
timestamp: event.timestamp,
|
|
1413
|
+
};
|
|
1414
|
+
switch (type) {
|
|
1415
|
+
case 'message.created':
|
|
1416
|
+
case 'message.received':
|
|
1417
|
+
case 'message.new':
|
|
1418
|
+
case 'message.sent':
|
|
1419
|
+
return (await this.handleRealtimeMessage(event.payload, baseOptions)).committed;
|
|
1420
|
+
case 'thread.reply':
|
|
1421
|
+
case 'thread.message.created':
|
|
1422
|
+
case 'thread.message.sent':
|
|
1423
|
+
return (await this.handleRealtimeThreadReply(event.payload, baseOptions)).committed;
|
|
1424
|
+
case 'dm.received':
|
|
1425
|
+
case 'dm.message.created':
|
|
1426
|
+
case 'direct_message.created':
|
|
1427
|
+
return (await this.handleRealtimeDm(event.payload, baseOptions))
|
|
1428
|
+
.committed;
|
|
1429
|
+
case 'group_dm.received':
|
|
1430
|
+
case 'group_dm.message.created':
|
|
1431
|
+
return (await this.handleRealtimeGroupDm(event.payload, baseOptions)).committed;
|
|
1432
|
+
case 'command.invoked':
|
|
1433
|
+
return (await this.handleRealtimeCommand(event.payload, {
|
|
1434
|
+
...baseOptions,
|
|
1435
|
+
eventId: event.id,
|
|
1436
|
+
})).committed;
|
|
1437
|
+
case 'reaction.added':
|
|
1438
|
+
return (await this.handleRealtimeReaction(event.payload, 'added', {
|
|
1439
|
+
...baseOptions,
|
|
1440
|
+
eventId: event.id,
|
|
1441
|
+
})).committed;
|
|
1442
|
+
case 'reaction.removed':
|
|
1443
|
+
return (await this.handleRealtimeReaction(event.payload, 'removed', {
|
|
1444
|
+
...baseOptions,
|
|
1445
|
+
eventId: event.id,
|
|
1446
|
+
})).committed;
|
|
1447
|
+
default:
|
|
1448
|
+
console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`);
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/** Start the gateway — register agent and subscribe for realtime events. */
|
|
1453
|
+
async start() {
|
|
1454
|
+
if (this.running)
|
|
1455
|
+
return;
|
|
1456
|
+
this.running = true;
|
|
1457
|
+
// Connect to the local OpenClaw gateway WebSocket (persistent connection)
|
|
1458
|
+
const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
1459
|
+
const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
|
|
1460
|
+
if (token) {
|
|
1461
|
+
this.openclawClient = await OpenClawGatewayClient.create(token, port);
|
|
1462
|
+
try {
|
|
1463
|
+
await this.openclawClient.connect();
|
|
1464
|
+
console.log('[gateway] OpenClaw gateway WebSocket client ready');
|
|
1465
|
+
}
|
|
1466
|
+
catch (err) {
|
|
1467
|
+
console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
|
|
800
1472
|
}
|
|
1473
|
+
const registered = await this.relaycast.agents.registerOrGet({
|
|
1474
|
+
name: this.config.clawName,
|
|
1475
|
+
type: 'agent',
|
|
1476
|
+
persona: 'Relaycast inbound gateway for OpenClaw',
|
|
1477
|
+
});
|
|
1478
|
+
await this.replaceRelayAgentClient(registered.token);
|
|
1479
|
+
await this.ensureChannelMembership();
|
|
1480
|
+
// Also subscribe explicitly in case the `connected` event fired before
|
|
1481
|
+
// the handler ran, or the SDK defers connection readiness.
|
|
1482
|
+
this.subscribeRelayChannels();
|
|
801
1483
|
console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`);
|
|
802
1484
|
// Start spawn control HTTP server
|
|
803
1485
|
await this.startControlServer();
|
|
@@ -805,15 +1487,14 @@ export class InboundGateway {
|
|
|
805
1487
|
/** Stop the gateway — clean up websocket and relay clients. */
|
|
806
1488
|
async stop() {
|
|
807
1489
|
this.running = false;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
catch {
|
|
813
|
-
// Best effort
|
|
814
|
-
}
|
|
1490
|
+
this.stopWsProbeLoop();
|
|
1491
|
+
if (this.wsRecoveryTimer) {
|
|
1492
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1493
|
+
this.wsRecoveryTimer = null;
|
|
815
1494
|
}
|
|
816
|
-
this.
|
|
1495
|
+
this.completeFallbackWindow();
|
|
1496
|
+
await this.stopPollLoop();
|
|
1497
|
+
this.cleanupRelaySubscriptions();
|
|
817
1498
|
if (this.relayAgentClient) {
|
|
818
1499
|
try {
|
|
819
1500
|
await this.relayAgentClient.disconnect();
|
|
@@ -871,96 +1552,97 @@ export class InboundGateway {
|
|
|
871
1552
|
}
|
|
872
1553
|
}
|
|
873
1554
|
}
|
|
874
|
-
async handleRealtimeMessage(event) {
|
|
1555
|
+
async handleRealtimeMessage(event, options = {}) {
|
|
875
1556
|
const channel = normalizeChannelName(event.channel);
|
|
876
1557
|
if (!this.config.channels.includes(channel))
|
|
877
|
-
return;
|
|
878
|
-
const messageId = event.message?.id;
|
|
1558
|
+
return { committed: true };
|
|
1559
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
879
1560
|
if (!messageId)
|
|
880
|
-
return;
|
|
1561
|
+
return { committed: true };
|
|
881
1562
|
const inbound = {
|
|
882
1563
|
id: messageId,
|
|
883
1564
|
channel,
|
|
884
|
-
from: event.message
|
|
885
|
-
text: event.message
|
|
886
|
-
timestamp: new Date().toISOString(),
|
|
1565
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1566
|
+
text: event.message?.text ?? '',
|
|
1567
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
887
1568
|
};
|
|
888
|
-
|
|
1569
|
+
return this.processInbound(inbound);
|
|
889
1570
|
}
|
|
890
|
-
async handleRealtimeThreadReply(event) {
|
|
1571
|
+
async handleRealtimeThreadReply(event, options = {}) {
|
|
891
1572
|
const channel = normalizeChannelName(event.channel);
|
|
892
1573
|
if (!this.config.channels.includes(channel))
|
|
893
|
-
return;
|
|
894
|
-
const messageId = event.message?.id;
|
|
1574
|
+
return { committed: true };
|
|
1575
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
895
1576
|
if (!messageId)
|
|
896
|
-
return;
|
|
1577
|
+
return { committed: true };
|
|
897
1578
|
const inbound = {
|
|
898
1579
|
id: messageId,
|
|
899
1580
|
channel,
|
|
900
|
-
from: event.message
|
|
901
|
-
text: event.message
|
|
902
|
-
timestamp: new Date().toISOString(),
|
|
1581
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1582
|
+
text: event.message?.text ?? '',
|
|
1583
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
903
1584
|
threadParentId: event.parentId,
|
|
904
1585
|
};
|
|
905
|
-
|
|
1586
|
+
return this.processInbound(inbound);
|
|
906
1587
|
}
|
|
907
|
-
async handleRealtimeDm(event) {
|
|
908
|
-
const messageId = event.message?.id;
|
|
1588
|
+
async handleRealtimeDm(event, options = {}) {
|
|
1589
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
909
1590
|
if (!messageId)
|
|
910
|
-
return;
|
|
1591
|
+
return { committed: true };
|
|
911
1592
|
const inbound = {
|
|
912
1593
|
id: messageId,
|
|
913
1594
|
channel: 'dm',
|
|
914
|
-
from: event.message
|
|
915
|
-
text: event.message
|
|
916
|
-
timestamp: new Date().toISOString(),
|
|
1595
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1596
|
+
text: event.message?.text ?? '',
|
|
1597
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
917
1598
|
conversationId: event.conversationId,
|
|
918
1599
|
kind: 'dm',
|
|
919
1600
|
};
|
|
920
|
-
|
|
1601
|
+
return this.processInbound(inbound);
|
|
921
1602
|
}
|
|
922
|
-
async handleRealtimeGroupDm(event) {
|
|
923
|
-
const messageId = event.message?.id;
|
|
1603
|
+
async handleRealtimeGroupDm(event, options = {}) {
|
|
1604
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
924
1605
|
if (!messageId)
|
|
925
|
-
return;
|
|
1606
|
+
return { committed: true };
|
|
926
1607
|
const inbound = {
|
|
927
1608
|
id: messageId,
|
|
928
1609
|
channel: `groupdm:${event.conversationId}`,
|
|
929
|
-
from: event.message
|
|
930
|
-
text: event.message
|
|
931
|
-
timestamp: new Date().toISOString(),
|
|
1610
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1611
|
+
text: event.message?.text ?? '',
|
|
1612
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
932
1613
|
conversationId: event.conversationId,
|
|
933
1614
|
kind: 'groupdm',
|
|
934
1615
|
};
|
|
935
|
-
|
|
1616
|
+
return this.processInbound(inbound);
|
|
936
1617
|
}
|
|
937
|
-
async handleRealtimeCommand(event) {
|
|
1618
|
+
async handleRealtimeCommand(event, options = {}) {
|
|
938
1619
|
const channel = normalizeChannelName(event.channel);
|
|
939
1620
|
if (!this.config.channels.includes(channel))
|
|
940
|
-
return;
|
|
1621
|
+
return { committed: true };
|
|
941
1622
|
// Commands lack a server-assigned event ID, so we synthesize one.
|
|
942
1623
|
// We include args + timestamp to avoid silently dropping legitimate
|
|
943
1624
|
// repeat invocations (e.g. /deploy twice in 15 min). This means SDK
|
|
944
1625
|
// reconnection replays may deliver a duplicate, but that's less
|
|
945
1626
|
// harmful than silently swallowing a real command.
|
|
946
1627
|
const argsSlug = event.args ? `_${event.args}` : '';
|
|
947
|
-
const syntheticId = `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
|
|
1628
|
+
const syntheticId = options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
|
|
948
1629
|
const argsText = event.args ? ` ${event.args}` : '';
|
|
949
1630
|
const inbound = {
|
|
950
1631
|
id: syntheticId,
|
|
951
1632
|
channel,
|
|
952
1633
|
from: event.invokedBy,
|
|
953
1634
|
text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
|
|
954
|
-
timestamp: new Date().toISOString(),
|
|
1635
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
955
1636
|
kind: 'command',
|
|
956
1637
|
};
|
|
957
|
-
|
|
1638
|
+
return this.processInbound(inbound);
|
|
958
1639
|
}
|
|
959
|
-
async handleRealtimeReaction(event, action) {
|
|
1640
|
+
async handleRealtimeReaction(event, action, options = {}) {
|
|
960
1641
|
// Include timestamp so add→remove→re-add of the same emoji isn't
|
|
961
1642
|
// silently dropped within the 15-min dedup window. Reactions are soft
|
|
962
1643
|
// notifications, so a rare duplicate on SDK reconnect is acceptable.
|
|
963
|
-
const syntheticId =
|
|
1644
|
+
const syntheticId = options.eventId ??
|
|
1645
|
+
`reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
|
|
964
1646
|
const text = action === 'added'
|
|
965
1647
|
? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
|
|
966
1648
|
: `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
|
|
@@ -969,49 +1651,57 @@ export class InboundGateway {
|
|
|
969
1651
|
channel: 'reaction',
|
|
970
1652
|
from: event.agentName,
|
|
971
1653
|
text,
|
|
972
|
-
timestamp: new Date().toISOString(),
|
|
1654
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
973
1655
|
kind: 'reaction',
|
|
974
1656
|
};
|
|
975
|
-
|
|
1657
|
+
return this.processInbound(inbound);
|
|
976
1658
|
}
|
|
977
|
-
async
|
|
1659
|
+
async processInbound(message) {
|
|
978
1660
|
if (!this.running)
|
|
979
|
-
return;
|
|
980
|
-
if (this.processingMessageIds.has(message.id) || this.isSeen(message.id))
|
|
981
|
-
|
|
1661
|
+
return { committed: false };
|
|
1662
|
+
if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) {
|
|
1663
|
+
this.duplicateDropCount += 1;
|
|
1664
|
+
return { committed: true, reason: 'duplicate' };
|
|
1665
|
+
}
|
|
982
1666
|
// Avoid echo loops — skip messages from this claw.
|
|
983
1667
|
if (message.from === this.config.clawName) {
|
|
984
|
-
// Only update cursor for real channels with real (non-synthetic) message IDs.
|
|
985
1668
|
this.markSeen(message.id);
|
|
986
|
-
return;
|
|
1669
|
+
return { committed: true, reason: 'echo' };
|
|
987
1670
|
}
|
|
988
|
-
// Mark as seen immediately to prevent duplicate delivery from concurrent
|
|
989
|
-
// realtime events processing the same message.
|
|
990
|
-
this.markSeen(message.id);
|
|
991
1671
|
this.processingMessageIds.add(message.id);
|
|
992
1672
|
console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
|
|
993
1673
|
try {
|
|
994
1674
|
const result = await this.onMessage(message);
|
|
995
1675
|
console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
|
|
1676
|
+
if (!result.ok) {
|
|
1677
|
+
return { committed: false, result };
|
|
1678
|
+
}
|
|
1679
|
+
this.markSeen(message.id);
|
|
1680
|
+
return { committed: true, result };
|
|
996
1681
|
}
|
|
997
1682
|
finally {
|
|
998
1683
|
this.processingMessageIds.delete(message.id);
|
|
999
1684
|
}
|
|
1000
1685
|
}
|
|
1001
|
-
/** Format delivery text with channel, sender, and
|
|
1686
|
+
/** Format delivery text with channel, sender, and response hint. */
|
|
1002
1687
|
formatDeliveryText(message) {
|
|
1003
|
-
// Pre-formatted kinds (
|
|
1004
|
-
if (message.kind === '
|
|
1688
|
+
// Pre-formatted kinds (reaction) already have the full text with hints.
|
|
1689
|
+
if (message.kind === 'reaction') {
|
|
1005
1690
|
return message.text;
|
|
1006
1691
|
}
|
|
1692
|
+
if (message.kind === 'command') {
|
|
1693
|
+
return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`;
|
|
1694
|
+
}
|
|
1007
1695
|
if (message.kind === 'dm') {
|
|
1008
|
-
return `[relaycast:dm] @${message.from}: ${message.text}`;
|
|
1696
|
+
return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
|
|
1009
1697
|
}
|
|
1010
1698
|
if (message.kind === 'groupdm') {
|
|
1011
|
-
return `[relaycast:groupdm] @${message.from}: ${message.text}`;
|
|
1699
|
+
return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
|
|
1700
|
+
}
|
|
1701
|
+
if (message.threadParentId) {
|
|
1702
|
+
return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`;
|
|
1012
1703
|
}
|
|
1013
|
-
|
|
1014
|
-
return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
|
|
1704
|
+
return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`;
|
|
1015
1705
|
}
|
|
1016
1706
|
/** Handle an inbound Relaycast message. */
|
|
1017
1707
|
async onMessage(message) {
|
|
@@ -1076,6 +1766,7 @@ export class InboundGateway {
|
|
|
1076
1766
|
});
|
|
1077
1767
|
});
|
|
1078
1768
|
}
|
|
1769
|
+
// eslint-disable-next-line complexity
|
|
1079
1770
|
async handleControlRequest(req, res) {
|
|
1080
1771
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
1081
1772
|
const path = url.pathname;
|
|
@@ -1088,6 +1779,7 @@ export class InboundGateway {
|
|
|
1088
1779
|
status: 'running',
|
|
1089
1780
|
active: this.spawnManager.size,
|
|
1090
1781
|
uptime: process.uptime(),
|
|
1782
|
+
transport: this.transportHealthSnapshot(),
|
|
1091
1783
|
}));
|
|
1092
1784
|
return;
|
|
1093
1785
|
}
|
|
@@ -1135,7 +1827,7 @@ export class InboundGateway {
|
|
|
1135
1827
|
res.end(JSON.stringify({
|
|
1136
1828
|
ok: true,
|
|
1137
1829
|
active: handles.length,
|
|
1138
|
-
claws: handles.map(h => ({
|
|
1830
|
+
claws: handles.map((h) => ({
|
|
1139
1831
|
name: h.displayName,
|
|
1140
1832
|
agentName: h.agentName,
|
|
1141
1833
|
id: h.id,
|