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,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createPrivateKey,
|
|
4
|
+
createPublicKey,
|
|
5
|
+
generateKeyPairSync,
|
|
6
|
+
sign,
|
|
7
|
+
verify,
|
|
8
|
+
type KeyObject,
|
|
9
|
+
} from 'node:crypto';
|
|
2
10
|
import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createServer,
|
|
13
|
+
type Server as HttpServer,
|
|
14
|
+
type IncomingMessage,
|
|
15
|
+
type ServerResponse,
|
|
16
|
+
} from 'node:http';
|
|
4
17
|
import { join } from 'node:path';
|
|
5
18
|
|
|
6
19
|
import type { SendMessageInput } from '@agent-relay/sdk';
|
|
@@ -16,8 +29,13 @@ import type {
|
|
|
16
29
|
} from '@relaycast/sdk';
|
|
17
30
|
import WebSocket from 'ws';
|
|
18
31
|
|
|
19
|
-
import { openclawHome
|
|
20
|
-
import {
|
|
32
|
+
import { openclawHome } from './config.js';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_OPENCLAW_GATEWAY_PORT,
|
|
35
|
+
type GatewayConfig,
|
|
36
|
+
type InboundMessage,
|
|
37
|
+
type DeliveryResult,
|
|
38
|
+
} from './types.js';
|
|
21
39
|
import { SpawnManager } from './spawn/manager.js';
|
|
22
40
|
import type { SpawnOptions } from './spawn/types.js';
|
|
23
41
|
|
|
@@ -41,6 +59,120 @@ export interface GatewayOptions {
|
|
|
41
59
|
relaySender?: RelaySender;
|
|
42
60
|
}
|
|
43
61
|
|
|
62
|
+
type InboundTransportState = 'WS_ACTIVE' | 'WS_DEGRADED' | 'POLL_ACTIVE' | 'RECOVERING_WS';
|
|
63
|
+
type InboundTransportMode = 'ws' | 'poll';
|
|
64
|
+
|
|
65
|
+
interface PollEventEnvelope {
|
|
66
|
+
id: string;
|
|
67
|
+
sequence: number;
|
|
68
|
+
timestamp: string;
|
|
69
|
+
payload: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface PollResponseBody {
|
|
73
|
+
events?: PollEventEnvelope[];
|
|
74
|
+
nextCursor?: string;
|
|
75
|
+
hasMore?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PersistedPollCursorState {
|
|
79
|
+
cursor: string;
|
|
80
|
+
lastSequence: number;
|
|
81
|
+
recentEventIds: string[];
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface InboundProcessingResult {
|
|
86
|
+
committed: boolean;
|
|
87
|
+
reason?: 'duplicate' | 'echo';
|
|
88
|
+
result?: DeliveryResult;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface RealtimeHandlingOptions {
|
|
92
|
+
eventId?: string;
|
|
93
|
+
timestamp?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll';
|
|
97
|
+
const DEFAULT_POLL_INITIAL_CURSOR = '0';
|
|
98
|
+
const DEFAULT_WS_FAILURE_THRESHOLD = 3;
|
|
99
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
|
|
100
|
+
const MAX_POLL_TIMEOUT_SECONDS = 30;
|
|
101
|
+
const DEFAULT_POLL_LIMIT = 100;
|
|
102
|
+
const MAX_POLL_LIMIT = 500;
|
|
103
|
+
const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000;
|
|
104
|
+
const DEFAULT_WS_STABLE_GRACE_MS = 10_000;
|
|
105
|
+
const POLL_CURSOR_RECENT_EVENT_LIMIT = 256;
|
|
106
|
+
const MAX_POLL_CURSOR_LENGTH = 4_096;
|
|
107
|
+
const MAX_EVENT_ID_LENGTH = 512;
|
|
108
|
+
const BACKOFF_BASE_MS = 500;
|
|
109
|
+
const BACKOFF_CAP_MS = 30_000;
|
|
110
|
+
|
|
111
|
+
function pollCursorStatePath(): string {
|
|
112
|
+
return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sleep(ms: number): Promise<void> {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyJitter(ms: number): number {
|
|
120
|
+
const factor = 1.1 + Math.random() * 0.1;
|
|
121
|
+
return Math.max(0, Math.floor(ms * factor));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasControlCharacters(value: string): boolean {
|
|
125
|
+
for (const char of value) {
|
|
126
|
+
const code = char.charCodeAt(0);
|
|
127
|
+
if ((code >= 0 && code <= 31) || code === 127) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stripControlChars(value: string): string {
|
|
135
|
+
let out = '';
|
|
136
|
+
for (const char of value) {
|
|
137
|
+
const code = char.charCodeAt(0);
|
|
138
|
+
out += code <= 31 || code === 127 ? ' ' : char;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sanitizeOpaqueStateValue(value: unknown, maxLength: number): string | null {
|
|
144
|
+
if (typeof value !== 'string') return null;
|
|
145
|
+
if (value.trim().length === 0 || value.length > maxLength) return null;
|
|
146
|
+
if (hasControlCharacters(value)) return null;
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function computeBackoffMs(attempt: number): number {
|
|
151
|
+
const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS);
|
|
152
|
+
return applyJitter(base);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sanitizePollTimeoutSeconds(value: number | undefined): number {
|
|
156
|
+
if (!Number.isFinite(value)) return DEFAULT_POLL_TIMEOUT_SECONDS;
|
|
157
|
+
return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value!)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sanitizePollLimit(value: number | undefined): number {
|
|
161
|
+
if (!Number.isFinite(value)) return DEFAULT_POLL_LIMIT;
|
|
162
|
+
return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value!)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseRetryAfterMs(retryAfter: string | null): number | null {
|
|
166
|
+
if (!retryAfter) return null;
|
|
167
|
+
const seconds = Number(retryAfter);
|
|
168
|
+
if (Number.isFinite(seconds)) {
|
|
169
|
+
return Math.max(0, Math.floor(seconds * 1000));
|
|
170
|
+
}
|
|
171
|
+
const asDate = Date.parse(retryAfter);
|
|
172
|
+
if (Number.isNaN(asDate)) return null;
|
|
173
|
+
return Math.max(0, asDate - Date.now());
|
|
174
|
+
}
|
|
175
|
+
|
|
44
176
|
function normalizeChannelName(channel: string): string {
|
|
45
177
|
return channel.startsWith('#') ? channel.slice(1) : channel;
|
|
46
178
|
}
|
|
@@ -100,7 +232,11 @@ function resolveAuthProfile(): AuthProfile {
|
|
|
100
232
|
// which checks valid parseable config files, not just directory existence.
|
|
101
233
|
// Strict suffix check avoids false positives from substring matching.
|
|
102
234
|
const home = openclawHome();
|
|
103
|
-
const homeSuffix =
|
|
235
|
+
const homeSuffix =
|
|
236
|
+
home
|
|
237
|
+
.replace(/[/\\]+$/, '')
|
|
238
|
+
.split(/[/\\]/)
|
|
239
|
+
.pop() ?? '';
|
|
104
240
|
if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') {
|
|
105
241
|
return AUTH_PROFILES['clawdbot-v1'];
|
|
106
242
|
}
|
|
@@ -117,10 +253,10 @@ function getWsAuthCompat(): WsAuthCompat {
|
|
|
117
253
|
}
|
|
118
254
|
|
|
119
255
|
interface DeviceIdentity {
|
|
120
|
-
publicKeyB64: string;
|
|
121
|
-
publicKeyPem?: string;
|
|
256
|
+
publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode)
|
|
257
|
+
publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode)
|
|
122
258
|
privateKeyObj: KeyObject; // Node.js KeyObject for signing
|
|
123
|
-
deviceId: string;
|
|
259
|
+
deviceId: string; // SHA-256 hex of the raw public key
|
|
124
260
|
}
|
|
125
261
|
|
|
126
262
|
function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity {
|
|
@@ -180,7 +316,9 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
180
316
|
});
|
|
181
317
|
// Ensure permissions are tight even if file was created with looser perms
|
|
182
318
|
await chmod(filePath, 0o600).catch(() => {});
|
|
183
|
-
console.log(
|
|
319
|
+
console.log(
|
|
320
|
+
`[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)`
|
|
321
|
+
);
|
|
184
322
|
|
|
185
323
|
const identity: DeviceIdentity = {
|
|
186
324
|
publicKeyB64: persisted.publicKeyB64,
|
|
@@ -209,7 +347,9 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
209
347
|
} catch (err) {
|
|
210
348
|
// ENOENT is expected on first run; other errors mean corruption
|
|
211
349
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
212
|
-
console.warn(
|
|
350
|
+
console.warn(
|
|
351
|
+
`[openclaw-ws] Failed to load device identity, generating new: ${err instanceof Error ? err.message : String(err)}`
|
|
352
|
+
);
|
|
213
353
|
}
|
|
214
354
|
}
|
|
215
355
|
|
|
@@ -233,9 +373,13 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
233
373
|
const tmpPath = filePath + '.tmp';
|
|
234
374
|
await writeFile(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
|
|
235
375
|
await rename(tmpPath, filePath);
|
|
236
|
-
console.log(
|
|
376
|
+
console.log(
|
|
377
|
+
`[openclaw-ws] Persisted new device identity (deviceId=${identity.deviceId.slice(0, 12)}...)`
|
|
378
|
+
);
|
|
237
379
|
} catch (err) {
|
|
238
|
-
console.warn(
|
|
380
|
+
console.warn(
|
|
381
|
+
`[openclaw-ws] Could not persist device identity: ${err instanceof Error ? err.message : String(err)}`
|
|
382
|
+
);
|
|
239
383
|
}
|
|
240
384
|
|
|
241
385
|
return identity;
|
|
@@ -263,7 +407,7 @@ function buildCanonicalVariants(
|
|
|
263
407
|
signedAt: number;
|
|
264
408
|
token: string;
|
|
265
409
|
nonce: string;
|
|
266
|
-
}
|
|
410
|
+
}
|
|
267
411
|
): Array<{ name: string; payload: string }> {
|
|
268
412
|
const signedAtMs = String(params.signedAt);
|
|
269
413
|
const signedAtSec = String(Math.floor(params.signedAt / 1000));
|
|
@@ -273,22 +417,69 @@ function buildCanonicalVariants(
|
|
|
273
417
|
// V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily)
|
|
274
418
|
{
|
|
275
419
|
name: 'v3-default-ms',
|
|
276
|
-
payload: [
|
|
420
|
+
payload: [
|
|
421
|
+
'v3',
|
|
422
|
+
device.deviceId,
|
|
423
|
+
params.clientId,
|
|
424
|
+
params.clientMode,
|
|
425
|
+
params.role,
|
|
426
|
+
scopesCsv,
|
|
427
|
+
signedAtMs,
|
|
428
|
+
params.token || '',
|
|
429
|
+
params.nonce,
|
|
430
|
+
params.platform,
|
|
431
|
+
params.deviceFamily,
|
|
432
|
+
].join('|'),
|
|
277
433
|
},
|
|
278
434
|
// V1: signedAt in seconds instead of milliseconds
|
|
279
435
|
{
|
|
280
436
|
name: 'v3-default-sec',
|
|
281
|
-
payload: [
|
|
437
|
+
payload: [
|
|
438
|
+
'v3',
|
|
439
|
+
device.deviceId,
|
|
440
|
+
params.clientId,
|
|
441
|
+
params.clientMode,
|
|
442
|
+
params.role,
|
|
443
|
+
scopesCsv,
|
|
444
|
+
signedAtSec,
|
|
445
|
+
params.token || '',
|
|
446
|
+
params.nonce,
|
|
447
|
+
params.platform,
|
|
448
|
+
params.deviceFamily,
|
|
449
|
+
].join('|'),
|
|
282
450
|
},
|
|
283
451
|
// V2: no token in payload (token omitted entirely)
|
|
284
452
|
{
|
|
285
453
|
name: 'v3-no-token-ms',
|
|
286
|
-
payload: [
|
|
454
|
+
payload: [
|
|
455
|
+
'v3',
|
|
456
|
+
device.deviceId,
|
|
457
|
+
params.clientId,
|
|
458
|
+
params.clientMode,
|
|
459
|
+
params.role,
|
|
460
|
+
scopesCsv,
|
|
461
|
+
signedAtMs,
|
|
462
|
+
params.nonce,
|
|
463
|
+
params.platform,
|
|
464
|
+
params.deviceFamily,
|
|
465
|
+
].join('|'),
|
|
287
466
|
},
|
|
288
467
|
// V3: nonce before token (swapped positions)
|
|
289
468
|
{
|
|
290
469
|
name: 'v3-nonce-first-ms',
|
|
291
|
-
payload: [
|
|
470
|
+
payload: [
|
|
471
|
+
'v3',
|
|
472
|
+
device.deviceId,
|
|
473
|
+
params.clientId,
|
|
474
|
+
params.clientMode,
|
|
475
|
+
params.role,
|
|
476
|
+
scopesCsv,
|
|
477
|
+
signedAtMs,
|
|
478
|
+
params.nonce,
|
|
479
|
+
params.token || '',
|
|
480
|
+
params.platform,
|
|
481
|
+
params.deviceFamily,
|
|
482
|
+
].join('|'),
|
|
292
483
|
},
|
|
293
484
|
// V4: fewer fields — just core identity + nonce + signedAt (minimal)
|
|
294
485
|
{
|
|
@@ -298,22 +489,62 @@ function buildCanonicalVariants(
|
|
|
298
489
|
// V5: signedAt seconds + no token
|
|
299
490
|
{
|
|
300
491
|
name: 'v3-no-token-sec',
|
|
301
|
-
payload: [
|
|
492
|
+
payload: [
|
|
493
|
+
'v3',
|
|
494
|
+
device.deviceId,
|
|
495
|
+
params.clientId,
|
|
496
|
+
params.clientMode,
|
|
497
|
+
params.role,
|
|
498
|
+
scopesCsv,
|
|
499
|
+
signedAtSec,
|
|
500
|
+
params.nonce,
|
|
501
|
+
params.platform,
|
|
502
|
+
params.deviceFamily,
|
|
503
|
+
].join('|'),
|
|
302
504
|
},
|
|
303
505
|
// V6: v2 format (no platform/deviceFamily) — used by older gateway versions
|
|
304
506
|
{
|
|
305
507
|
name: 'v2-default-ms',
|
|
306
|
-
payload: [
|
|
508
|
+
payload: [
|
|
509
|
+
'v2',
|
|
510
|
+
device.deviceId,
|
|
511
|
+
params.clientId,
|
|
512
|
+
params.clientMode,
|
|
513
|
+
params.role,
|
|
514
|
+
scopesCsv,
|
|
515
|
+
signedAtMs,
|
|
516
|
+
params.token || '',
|
|
517
|
+
params.nonce,
|
|
518
|
+
].join('|'),
|
|
307
519
|
},
|
|
308
520
|
// V7: v2 with signedAt in seconds
|
|
309
521
|
{
|
|
310
522
|
name: 'v2-default-sec',
|
|
311
|
-
payload: [
|
|
523
|
+
payload: [
|
|
524
|
+
'v2',
|
|
525
|
+
device.deviceId,
|
|
526
|
+
params.clientId,
|
|
527
|
+
params.clientMode,
|
|
528
|
+
params.role,
|
|
529
|
+
scopesCsv,
|
|
530
|
+
signedAtSec,
|
|
531
|
+
params.token || '',
|
|
532
|
+
params.nonce,
|
|
533
|
+
].join('|'),
|
|
312
534
|
},
|
|
313
535
|
// V8: v2 without token
|
|
314
536
|
{
|
|
315
537
|
name: 'v2-no-token-ms',
|
|
316
|
-
payload: [
|
|
538
|
+
payload: [
|
|
539
|
+
'v2',
|
|
540
|
+
device.deviceId,
|
|
541
|
+
params.clientId,
|
|
542
|
+
params.clientMode,
|
|
543
|
+
params.role,
|
|
544
|
+
scopesCsv,
|
|
545
|
+
signedAtMs,
|
|
546
|
+
params.nonce,
|
|
547
|
+
].join('|'),
|
|
317
548
|
},
|
|
318
549
|
];
|
|
319
550
|
}
|
|
@@ -334,7 +565,7 @@ function signConnectPayload(
|
|
|
334
565
|
token: string;
|
|
335
566
|
nonce: string;
|
|
336
567
|
},
|
|
337
|
-
versionOverride?: PayloadVersionOverride
|
|
568
|
+
versionOverride?: PayloadVersionOverride
|
|
338
569
|
): string {
|
|
339
570
|
const profile = resolveAuthProfile();
|
|
340
571
|
|
|
@@ -353,19 +584,25 @@ function signConnectPayload(
|
|
|
353
584
|
} else {
|
|
354
585
|
primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms';
|
|
355
586
|
}
|
|
356
|
-
const primary = variants.find(v => v.name === primaryName) ?? variants[0];
|
|
587
|
+
const primary = variants.find((v) => v.name === primaryName) ?? variants[0];
|
|
357
588
|
|
|
358
589
|
const payloadBytes = Buffer.from(primary.payload, 'utf-8');
|
|
359
590
|
|
|
360
591
|
const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1';
|
|
361
592
|
|
|
362
593
|
// Concise production log — one line with essential info
|
|
363
|
-
console.log(
|
|
594
|
+
console.log(
|
|
595
|
+
`[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}`
|
|
596
|
+
);
|
|
364
597
|
|
|
365
598
|
// Verbose debug logging — field hashes and canonicalization matrix
|
|
366
599
|
if (isDebug) {
|
|
367
|
-
console.log(
|
|
368
|
-
|
|
600
|
+
console.log(
|
|
601
|
+
`[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`
|
|
602
|
+
);
|
|
603
|
+
console.log(
|
|
604
|
+
`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`
|
|
605
|
+
);
|
|
369
606
|
console.log('[ws-auth-debug] canonicalization matrix:');
|
|
370
607
|
for (const v of variants) {
|
|
371
608
|
console.log(` ${v.name}: hash=${shortHash(v.payload)}`);
|
|
@@ -385,7 +622,10 @@ function signConnectPayload(
|
|
|
385
622
|
const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature);
|
|
386
623
|
|
|
387
624
|
// Also verify the round-trip: decode our encoded signature like the server would
|
|
388
|
-
const decodedSig = Buffer.from(
|
|
625
|
+
const decodedSig = Buffer.from(
|
|
626
|
+
encoded,
|
|
627
|
+
profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64'
|
|
628
|
+
);
|
|
389
629
|
const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig);
|
|
390
630
|
|
|
391
631
|
// Verify deviceId matches public key
|
|
@@ -393,9 +633,13 @@ function signConnectPayload(
|
|
|
393
633
|
const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex');
|
|
394
634
|
const deviceIdMatch = derivedDeviceId === device.deviceId;
|
|
395
635
|
|
|
396
|
-
console.log(
|
|
636
|
+
console.log(
|
|
637
|
+
`[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...`
|
|
638
|
+
);
|
|
397
639
|
if (!deviceIdMatch) {
|
|
398
|
-
console.error(
|
|
640
|
+
console.error(
|
|
641
|
+
`[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}`
|
|
642
|
+
);
|
|
399
643
|
}
|
|
400
644
|
} catch (err) {
|
|
401
645
|
console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -405,7 +649,6 @@ function signConnectPayload(
|
|
|
405
649
|
return encoded;
|
|
406
650
|
}
|
|
407
651
|
|
|
408
|
-
|
|
409
652
|
// ---------------------------------------------------------------------------
|
|
410
653
|
// Persistent OpenClaw Gateway WebSocket client
|
|
411
654
|
// ---------------------------------------------------------------------------
|
|
@@ -489,7 +732,9 @@ export class OpenClawGatewayClient {
|
|
|
489
732
|
this.connectTimeout = setTimeout(() => {
|
|
490
733
|
this.connectTimeout = null;
|
|
491
734
|
if (!this.authenticated) {
|
|
492
|
-
const err = new Error(
|
|
735
|
+
const err = new Error(
|
|
736
|
+
`Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms`
|
|
737
|
+
);
|
|
493
738
|
this.connectReject?.(err);
|
|
494
739
|
this.connectReject = null;
|
|
495
740
|
this.connectResolve = null;
|
|
@@ -526,6 +771,8 @@ export class OpenClawGatewayClient {
|
|
|
526
771
|
});
|
|
527
772
|
|
|
528
773
|
ws.on('message', (data) => {
|
|
774
|
+
// Guard: ignore messages from superseded WebSocket instances.
|
|
775
|
+
if (this.ws !== ws) return;
|
|
529
776
|
this.handleMessage(data.toString());
|
|
530
777
|
});
|
|
531
778
|
|
|
@@ -534,7 +781,8 @@ export class OpenClawGatewayClient {
|
|
|
534
781
|
// During v3↔v2 fallback, the old WS is replaced before its close fires.
|
|
535
782
|
if (this.ws !== ws) return;
|
|
536
783
|
|
|
537
|
-
|
|
784
|
+
// Sanitize reason to prevent log injection (newlines, control chars)
|
|
785
|
+
const reasonStr = stripControlChars(reason.toString()).slice(0, 200);
|
|
538
786
|
console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`);
|
|
539
787
|
const wasAuthenticated = this.authenticated;
|
|
540
788
|
this.authenticated = false;
|
|
@@ -543,7 +791,9 @@ export class OpenClawGatewayClient {
|
|
|
543
791
|
if (code === 1008 && /pairing|not.paired/i.test(reasonStr)) {
|
|
544
792
|
console.error('[openclaw-ws] Connection closed due to pairing policy. Device is not paired.');
|
|
545
793
|
console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
|
|
546
|
-
console.error(
|
|
794
|
+
console.error(
|
|
795
|
+
'[openclaw-ws] Run: openclaw devices approve <requestId> (check gateway logs for requestId)'
|
|
796
|
+
);
|
|
547
797
|
this.pairingRejected = true;
|
|
548
798
|
}
|
|
549
799
|
|
|
@@ -581,6 +831,7 @@ export class OpenClawGatewayClient {
|
|
|
581
831
|
});
|
|
582
832
|
}
|
|
583
833
|
|
|
834
|
+
// eslint-disable-next-line complexity
|
|
584
835
|
private handleMessage(raw: string): void {
|
|
585
836
|
let msg: Record<string, unknown>;
|
|
586
837
|
try {
|
|
@@ -606,59 +857,66 @@ export class OpenClawGatewayClient {
|
|
|
606
857
|
const role = 'operator';
|
|
607
858
|
const scopes = ['operator.read', 'operator.write'];
|
|
608
859
|
|
|
609
|
-
const signature = signConnectPayload(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
860
|
+
const signature = signConnectPayload(
|
|
861
|
+
this.device,
|
|
862
|
+
{
|
|
863
|
+
clientId,
|
|
864
|
+
clientMode,
|
|
865
|
+
platform,
|
|
866
|
+
deviceFamily,
|
|
867
|
+
role,
|
|
868
|
+
scopes,
|
|
869
|
+
signedAt,
|
|
870
|
+
token: this.token,
|
|
871
|
+
nonce: payload.nonce,
|
|
872
|
+
},
|
|
873
|
+
this.payloadVersionOverride
|
|
874
|
+
);
|
|
620
875
|
|
|
621
876
|
// Select public key format based on resolved auth profile.
|
|
622
877
|
const profile = resolveAuthProfile();
|
|
623
|
-
const publicKeyField =
|
|
624
|
-
|
|
625
|
-
|
|
878
|
+
const publicKeyField =
|
|
879
|
+
profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem
|
|
880
|
+
? this.device.publicKeyPem
|
|
881
|
+
: this.device.publicKeyB64;
|
|
626
882
|
|
|
627
883
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
628
884
|
console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
|
|
629
885
|
return;
|
|
630
886
|
}
|
|
631
|
-
this.ws.send(
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
887
|
+
this.ws.send(
|
|
888
|
+
JSON.stringify({
|
|
889
|
+
type: 'req',
|
|
890
|
+
id: 'connect-1',
|
|
891
|
+
method: 'connect',
|
|
892
|
+
params: {
|
|
893
|
+
minProtocol: 3,
|
|
894
|
+
maxProtocol: 3,
|
|
895
|
+
client: {
|
|
896
|
+
id: clientId,
|
|
897
|
+
version: '1.0.0',
|
|
898
|
+
platform,
|
|
899
|
+
mode: clientMode,
|
|
900
|
+
deviceFamily,
|
|
901
|
+
},
|
|
902
|
+
role,
|
|
903
|
+
scopes,
|
|
904
|
+
caps: [],
|
|
905
|
+
commands: [],
|
|
906
|
+
permissions: {},
|
|
907
|
+
auth: { token: this.token },
|
|
908
|
+
locale: 'en-US',
|
|
909
|
+
userAgent: 'relaycast-gateway/1.0.0',
|
|
910
|
+
device: {
|
|
911
|
+
id: this.device.deviceId,
|
|
912
|
+
publicKey: publicKeyField,
|
|
913
|
+
signature,
|
|
914
|
+
signedAt,
|
|
915
|
+
nonce: payload.nonce,
|
|
916
|
+
},
|
|
659
917
|
},
|
|
660
|
-
}
|
|
661
|
-
|
|
918
|
+
})
|
|
919
|
+
);
|
|
662
920
|
return;
|
|
663
921
|
}
|
|
664
922
|
|
|
@@ -666,9 +924,11 @@ export class OpenClawGatewayClient {
|
|
|
666
924
|
if (msg.type === 'res' && msg.id === 'connect-1') {
|
|
667
925
|
if (msg.ok) {
|
|
668
926
|
this.clearConnectTimeout();
|
|
669
|
-
const versionUsed =
|
|
670
|
-
?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
671
|
-
console.log(
|
|
927
|
+
const versionUsed =
|
|
928
|
+
this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
929
|
+
console.log(
|
|
930
|
+
`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`
|
|
931
|
+
);
|
|
672
932
|
this.authenticated = true;
|
|
673
933
|
this.consecutiveFailures = 0;
|
|
674
934
|
this.connectResolve?.();
|
|
@@ -688,10 +948,11 @@ export class OpenClawGatewayClient {
|
|
|
688
948
|
console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
|
|
689
949
|
}
|
|
690
950
|
console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
|
|
691
|
-
const configHint =
|
|
692
|
-
? '~/.clawdbot/clawdbot.json'
|
|
693
|
-
|
|
694
|
-
|
|
951
|
+
const configHint =
|
|
952
|
+
getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json';
|
|
953
|
+
console.error(
|
|
954
|
+
`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`
|
|
955
|
+
);
|
|
695
956
|
this.pairingRejected = true;
|
|
696
957
|
} else if (isSignatureInvalid && !this.fallbackAttempted) {
|
|
697
958
|
// Signature rejected — try the alternate payload version once.
|
|
@@ -699,18 +960,24 @@ export class OpenClawGatewayClient {
|
|
|
699
960
|
this.authRejectCount++;
|
|
700
961
|
this.authFallbackCount++;
|
|
701
962
|
const profile = resolveAuthProfile();
|
|
702
|
-
const currentVersion =
|
|
703
|
-
?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
963
|
+
const currentVersion =
|
|
964
|
+
this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
|
|
704
965
|
const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2';
|
|
705
966
|
|
|
706
|
-
console.warn(
|
|
967
|
+
console.warn(
|
|
968
|
+
`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`
|
|
969
|
+
);
|
|
707
970
|
this.payloadVersionOverride = fallbackVersion;
|
|
708
971
|
this.fallbackAttempted = true;
|
|
709
972
|
|
|
710
973
|
// Close current WS and reconnect with the alternate payload.
|
|
711
974
|
// Setting this.ws = null ensures the old WS's close/error handlers
|
|
712
975
|
// no-op via the `this.ws !== ws` guard in doConnect().
|
|
713
|
-
try {
|
|
976
|
+
try {
|
|
977
|
+
this.ws?.close();
|
|
978
|
+
} catch {
|
|
979
|
+
// Best effort
|
|
980
|
+
}
|
|
714
981
|
this.ws = null;
|
|
715
982
|
setTimeout(() => this.doConnect(), 0);
|
|
716
983
|
return; // Don't reject the connect promise yet — fallback attempt in progress
|
|
@@ -735,11 +1002,10 @@ export class OpenClawGatewayClient {
|
|
|
735
1002
|
this.pendingRpcs.delete(id);
|
|
736
1003
|
|
|
737
1004
|
if (msg.ok === false || msg.error) {
|
|
738
|
-
console.warn(
|
|
1005
|
+
console.warn('[openclaw-ws] RPC error response received');
|
|
739
1006
|
pending.resolve(false);
|
|
740
1007
|
} else {
|
|
741
|
-
|
|
742
|
-
console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
|
|
1008
|
+
console.log('[openclaw-ws] RPC succeeded');
|
|
743
1009
|
pending.resolve(true);
|
|
744
1010
|
}
|
|
745
1011
|
return;
|
|
@@ -781,16 +1047,18 @@ export class OpenClawGatewayClient {
|
|
|
781
1047
|
resolve(false);
|
|
782
1048
|
return;
|
|
783
1049
|
}
|
|
784
|
-
this.ws.send(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1050
|
+
this.ws.send(
|
|
1051
|
+
JSON.stringify({
|
|
1052
|
+
type: 'req',
|
|
1053
|
+
id,
|
|
1054
|
+
method: 'chat.send',
|
|
1055
|
+
params: {
|
|
1056
|
+
sessionKey: 'agent:main:main',
|
|
1057
|
+
message: text,
|
|
1058
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
1059
|
+
},
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
794
1062
|
});
|
|
795
1063
|
}
|
|
796
1064
|
|
|
@@ -801,14 +1069,21 @@ export class OpenClawGatewayClient {
|
|
|
801
1069
|
// so the gateway can self-heal once pairing is approved externally.
|
|
802
1070
|
if (this.pairingRejected || this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
|
|
803
1071
|
if (this.consecutiveFailures === OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
|
|
804
|
-
console.warn(
|
|
805
|
-
|
|
1072
|
+
console.warn(
|
|
1073
|
+
`[openclaw-ws] ${this.consecutiveFailures} consecutive failures — switching to slow retry (every 60s).`
|
|
1074
|
+
);
|
|
1075
|
+
console.warn(
|
|
1076
|
+
'[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.'
|
|
1077
|
+
);
|
|
806
1078
|
}
|
|
807
1079
|
this.consecutiveFailures++;
|
|
808
1080
|
console.log(`[openclaw-ws] Slow retry in ${OpenClawGatewayClient.PAIRING_RETRY_MS / 1000}s...`);
|
|
809
1081
|
this.reconnectTimer = setTimeout(() => {
|
|
810
1082
|
this.reconnectTimer = null;
|
|
811
1083
|
this.pairingRejected = false; // Clear flag so connect attempt proceeds
|
|
1084
|
+
// Reset fallback state so reconnect tries primary payload version first
|
|
1085
|
+
this.payloadVersionOverride = null;
|
|
1086
|
+
this.fallbackAttempted = false;
|
|
812
1087
|
this.doConnect();
|
|
813
1088
|
}, OpenClawGatewayClient.PAIRING_RETRY_MS);
|
|
814
1089
|
return;
|
|
@@ -818,11 +1093,14 @@ export class OpenClawGatewayClient {
|
|
|
818
1093
|
|
|
819
1094
|
const delay = Math.min(
|
|
820
1095
|
OpenClawGatewayClient.BASE_RECONNECT_MS * Math.pow(2, this.consecutiveFailures - 1),
|
|
821
|
-
OpenClawGatewayClient.MAX_RECONNECT_MS
|
|
1096
|
+
OpenClawGatewayClient.MAX_RECONNECT_MS
|
|
822
1097
|
);
|
|
823
1098
|
console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`);
|
|
824
1099
|
this.reconnectTimer = setTimeout(() => {
|
|
825
1100
|
this.reconnectTimer = null;
|
|
1101
|
+
// Reset fallback state so reconnect tries primary payload version first
|
|
1102
|
+
this.payloadVersionOverride = null;
|
|
1103
|
+
this.fallbackAttempted = false;
|
|
826
1104
|
this.doConnect();
|
|
827
1105
|
}, delay);
|
|
828
1106
|
}
|
|
@@ -840,7 +1118,11 @@ export class OpenClawGatewayClient {
|
|
|
840
1118
|
this.pendingRpcs.delete(id);
|
|
841
1119
|
}
|
|
842
1120
|
if (this.ws) {
|
|
843
|
-
try {
|
|
1121
|
+
try {
|
|
1122
|
+
this.ws.close();
|
|
1123
|
+
} catch {
|
|
1124
|
+
// Best effort
|
|
1125
|
+
}
|
|
844
1126
|
this.ws = null;
|
|
845
1127
|
}
|
|
846
1128
|
this.authenticated = false;
|
|
@@ -857,6 +1139,7 @@ export class OpenClawGatewayClient {
|
|
|
857
1139
|
export class InboundGateway {
|
|
858
1140
|
private readonly relaySender: RelaySender | null;
|
|
859
1141
|
private relayAgentClient: AgentClient | null = null;
|
|
1142
|
+
private relayAgentToken: string | null = null;
|
|
860
1143
|
private readonly relaycast: RelayCast;
|
|
861
1144
|
private readonly config: GatewayConfig;
|
|
862
1145
|
private readonly dedupeTtlMs: number;
|
|
@@ -876,6 +1159,26 @@ export class InboundGateway {
|
|
|
876
1159
|
/** Port the control server listens on. */
|
|
877
1160
|
controlPort = 0;
|
|
878
1161
|
|
|
1162
|
+
private transportState: InboundTransportState = 'WS_DEGRADED';
|
|
1163
|
+
private activeTransportMode: InboundTransportMode = 'ws';
|
|
1164
|
+
private wsFailureCount = 0;
|
|
1165
|
+
private pollLoopPromise: Promise<void> | null = null;
|
|
1166
|
+
private pollAbortController: AbortController | null = null;
|
|
1167
|
+
private pollLoopStopRequested = false;
|
|
1168
|
+
private pollCursorLoaded = false;
|
|
1169
|
+
private pollCursor = DEFAULT_POLL_INITIAL_CURSOR;
|
|
1170
|
+
private pollLastSequence = 0;
|
|
1171
|
+
private pollRecentEventIds: string[] = [];
|
|
1172
|
+
private pollFailureCount = 0;
|
|
1173
|
+
private probeWsTimer: ReturnType<typeof setInterval> | null = null;
|
|
1174
|
+
private wsRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1175
|
+
private fallbackCount = 0;
|
|
1176
|
+
private lastFallbackReason: string | null = null;
|
|
1177
|
+
private fallbackStartedAt: number | null = null;
|
|
1178
|
+
private totalFallbackMs = 0;
|
|
1179
|
+
private duplicateDropCount = 0;
|
|
1180
|
+
private cursorResetCount = 0;
|
|
1181
|
+
|
|
879
1182
|
/** Default control port for the gateway's spawn API. */
|
|
880
1183
|
static readonly DEFAULT_CONTROL_PORT = 18790;
|
|
881
1184
|
|
|
@@ -891,124 +1194,688 @@ export class InboundGateway {
|
|
|
891
1194
|
});
|
|
892
1195
|
|
|
893
1196
|
const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
|
|
894
|
-
this.dedupeTtlMs =
|
|
895
|
-
? Math.floor(dedupeTtlMs)
|
|
896
|
-
: 15 * 60 * 1000;
|
|
1197
|
+
this.dedupeTtlMs =
|
|
1198
|
+
Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000;
|
|
897
1199
|
|
|
898
1200
|
const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
|
|
899
1201
|
this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
|
|
900
1202
|
}
|
|
901
1203
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
this.running = true;
|
|
1204
|
+
private isPollFallbackEnabled(): boolean {
|
|
1205
|
+
return this.config.transport?.pollFallback?.enabled ?? false;
|
|
1206
|
+
}
|
|
906
1207
|
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
|
|
1208
|
+
private wsFailureThreshold(): number {
|
|
1209
|
+
const configured = this.config.transport?.pollFallback?.wsFailureThreshold;
|
|
1210
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
1211
|
+
return DEFAULT_WS_FAILURE_THRESHOLD;
|
|
1212
|
+
}
|
|
1213
|
+
return Math.max(1, Math.floor(configured));
|
|
1214
|
+
}
|
|
910
1215
|
|
|
911
|
-
|
|
912
|
-
|
|
1216
|
+
private pollTimeoutSeconds(): number {
|
|
1217
|
+
return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private pollLimit(): number {
|
|
1221
|
+
return sanitizePollLimit(this.config.transport?.pollFallback?.limit);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
private pollInitialCursor(): string {
|
|
1225
|
+
return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private isWsProbeEnabled(): boolean {
|
|
1229
|
+
return this.config.transport?.pollFallback?.probeWs?.enabled ?? true;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private wsProbeIntervalMs(): number {
|
|
1233
|
+
const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs;
|
|
1234
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
1235
|
+
return DEFAULT_WS_PROBE_INTERVAL_MS;
|
|
1236
|
+
}
|
|
1237
|
+
return Math.max(1_000, Math.floor(configured));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private wsStableGraceMs(): number {
|
|
1241
|
+
const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs;
|
|
1242
|
+
if (!Number.isFinite(configured) || configured === undefined) {
|
|
1243
|
+
return DEFAULT_WS_STABLE_GRACE_MS;
|
|
1244
|
+
}
|
|
1245
|
+
return Math.max(1_000, Math.floor(configured));
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private transportHealthSnapshot(): Record<string, unknown> {
|
|
1249
|
+
const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt;
|
|
1250
|
+
return {
|
|
1251
|
+
mode: this.activeTransportMode,
|
|
1252
|
+
state: this.transportState,
|
|
1253
|
+
wsFailureCount: this.wsFailureCount,
|
|
1254
|
+
fallbackCount: this.fallbackCount,
|
|
1255
|
+
lastFallbackReason: this.lastFallbackReason,
|
|
1256
|
+
timeInFallbackMs: this.totalFallbackMs + activeFallbackMs,
|
|
1257
|
+
duplicateDrops: this.duplicateDropCount,
|
|
1258
|
+
cursorResets: this.cursorResetCount,
|
|
1259
|
+
lastSequence: this.pollLastSequence,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
private completeFallbackWindow(): void {
|
|
1264
|
+
if (this.fallbackStartedAt !== null) {
|
|
1265
|
+
this.totalFallbackMs += Date.now() - this.fallbackStartedAt;
|
|
1266
|
+
this.fallbackStartedAt = null;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private cleanupRelaySubscriptions(): void {
|
|
1271
|
+
for (const unsubscribe of this.unsubscribeHandlers) {
|
|
913
1272
|
try {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
|
|
1273
|
+
unsubscribe();
|
|
1274
|
+
} catch {
|
|
1275
|
+
// Best effort
|
|
918
1276
|
}
|
|
919
|
-
} else {
|
|
920
|
-
console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
|
|
921
1277
|
}
|
|
1278
|
+
this.unsubscribeHandlers = [];
|
|
1279
|
+
}
|
|
922
1280
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
1281
|
+
private subscribeRelayChannels(): void {
|
|
1282
|
+
if (!this.relayAgentClient) return;
|
|
1283
|
+
try {
|
|
1284
|
+
this.relayAgentClient.subscribe(this.config.channels);
|
|
1285
|
+
} catch {
|
|
1286
|
+
// Will subscribe on the next connected event.
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private async connectRelayAgentClient(): Promise<void> {
|
|
1291
|
+
if (!this.relayAgentClient) return;
|
|
1292
|
+
try {
|
|
1293
|
+
await Promise.resolve(this.relayAgentClient.connect());
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
console.warn(
|
|
1296
|
+
`[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1297
|
+
);
|
|
1298
|
+
await this.handleWsFailure('connect_failed');
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
928
1301
|
|
|
929
|
-
|
|
1302
|
+
private bindRelayAgentHandlers(): void {
|
|
1303
|
+
if (!this.relayAgentClient) return;
|
|
930
1304
|
|
|
931
|
-
|
|
932
|
-
// before subscribe() can be called.
|
|
933
|
-
this.relayAgentClient.connect();
|
|
1305
|
+
this.cleanupRelaySubscriptions();
|
|
934
1306
|
|
|
935
1307
|
this.unsubscribeHandlers.push(
|
|
936
1308
|
this.relayAgentClient.on.connected(() => {
|
|
937
|
-
console.log(
|
|
938
|
-
|
|
939
|
-
|
|
1309
|
+
console.log(
|
|
1310
|
+
`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`
|
|
1311
|
+
);
|
|
1312
|
+
this.wsFailureCount = 0;
|
|
1313
|
+
this.subscribeRelayChannels();
|
|
1314
|
+
if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') {
|
|
1315
|
+
this.beginWsRecovery();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
this.completeFallbackWindow();
|
|
1319
|
+
this.transportState = 'WS_ACTIVE';
|
|
1320
|
+
this.activeTransportMode = 'ws';
|
|
1321
|
+
})
|
|
940
1322
|
);
|
|
941
1323
|
this.unsubscribeHandlers.push(
|
|
942
1324
|
this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => {
|
|
1325
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
943
1326
|
console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
|
|
944
1327
|
void this.handleRealtimeMessage(event);
|
|
945
|
-
})
|
|
1328
|
+
})
|
|
946
1329
|
);
|
|
947
1330
|
this.unsubscribeHandlers.push(
|
|
948
1331
|
this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => {
|
|
949
|
-
|
|
1332
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
1333
|
+
console.log(
|
|
1334
|
+
`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`
|
|
1335
|
+
);
|
|
950
1336
|
void this.handleRealtimeThreadReply(event);
|
|
951
|
-
})
|
|
1337
|
+
})
|
|
952
1338
|
);
|
|
953
1339
|
this.unsubscribeHandlers.push(
|
|
954
1340
|
this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => {
|
|
1341
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
955
1342
|
console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
|
|
956
1343
|
void this.handleRealtimeDm(event);
|
|
957
|
-
})
|
|
1344
|
+
})
|
|
958
1345
|
);
|
|
959
1346
|
this.unsubscribeHandlers.push(
|
|
960
1347
|
this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => {
|
|
1348
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
961
1349
|
console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
|
|
962
1350
|
void this.handleRealtimeGroupDm(event);
|
|
963
|
-
})
|
|
1351
|
+
})
|
|
964
1352
|
);
|
|
965
1353
|
this.unsubscribeHandlers.push(
|
|
966
1354
|
this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => {
|
|
967
|
-
|
|
1355
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
1356
|
+
console.log(
|
|
1357
|
+
`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`
|
|
1358
|
+
);
|
|
968
1359
|
void this.handleRealtimeCommand(event);
|
|
969
|
-
})
|
|
1360
|
+
})
|
|
970
1361
|
);
|
|
971
1362
|
this.unsubscribeHandlers.push(
|
|
972
1363
|
this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => {
|
|
1364
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
973
1365
|
console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
|
|
974
1366
|
void this.handleRealtimeReaction(event, 'added');
|
|
975
|
-
})
|
|
1367
|
+
})
|
|
976
1368
|
);
|
|
977
1369
|
this.unsubscribeHandlers.push(
|
|
978
1370
|
this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => {
|
|
979
|
-
|
|
1371
|
+
if (!this.shouldProcessWsInbound()) return;
|
|
1372
|
+
console.log(
|
|
1373
|
+
`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`
|
|
1374
|
+
);
|
|
980
1375
|
void this.handleRealtimeReaction(event, 'removed');
|
|
981
|
-
})
|
|
1376
|
+
})
|
|
982
1377
|
);
|
|
983
1378
|
this.unsubscribeHandlers.push(
|
|
984
1379
|
this.relayAgentClient.on.reconnecting((attempt: number) => {
|
|
985
1380
|
console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
|
|
986
|
-
|
|
1381
|
+
void this.handleWsFailure(`reconnecting:${attempt}`);
|
|
1382
|
+
})
|
|
987
1383
|
);
|
|
988
1384
|
this.unsubscribeHandlers.push(
|
|
989
1385
|
this.relayAgentClient.on.disconnected(() => {
|
|
990
|
-
console.warn(
|
|
991
|
-
|
|
1386
|
+
console.warn('[gateway] Relaycast disconnected');
|
|
1387
|
+
void this.handleWsFailure('disconnected');
|
|
1388
|
+
})
|
|
992
1389
|
);
|
|
993
1390
|
this.unsubscribeHandlers.push(
|
|
994
|
-
this.relayAgentClient.on.error(() => {
|
|
995
|
-
|
|
996
|
-
|
|
1391
|
+
this.relayAgentClient.on.error((error?: unknown) => {
|
|
1392
|
+
const message = error instanceof Error ? error.message : 'socket error';
|
|
1393
|
+
console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`);
|
|
1394
|
+
void this.handleWsFailure(message || 'socket_error');
|
|
1395
|
+
})
|
|
997
1396
|
);
|
|
1397
|
+
}
|
|
998
1398
|
|
|
1399
|
+
private async replaceRelayAgentClient(agentToken: string): Promise<void> {
|
|
1400
|
+
this.cleanupRelaySubscriptions();
|
|
1401
|
+
if (this.relayAgentClient) {
|
|
1402
|
+
try {
|
|
1403
|
+
await this.relayAgentClient.disconnect();
|
|
1404
|
+
} catch {
|
|
1405
|
+
// Best effort
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
this.relayAgentToken = agentToken;
|
|
1409
|
+
this.relayAgentClient = this.relaycast.as(agentToken);
|
|
1410
|
+
this.bindRelayAgentHandlers();
|
|
1411
|
+
await this.connectRelayAgentClient();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
private async refreshRelayAgentRegistration(): Promise<void> {
|
|
1415
|
+
const registered = await this.relaycast.agents.registerOrRotate({
|
|
1416
|
+
name: this.config.clawName,
|
|
1417
|
+
type: 'agent',
|
|
1418
|
+
persona: 'Relaycast inbound gateway for OpenClaw',
|
|
1419
|
+
});
|
|
1420
|
+
await this.replaceRelayAgentClient(registered.token);
|
|
999
1421
|
await this.ensureChannelMembership();
|
|
1422
|
+
this.subscribeRelayChannels();
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
private shouldProcessWsInbound(): boolean {
|
|
1426
|
+
return (
|
|
1427
|
+
!this.isPollFallbackEnabled() ||
|
|
1428
|
+
this.transportState === 'WS_ACTIVE' ||
|
|
1429
|
+
this.transportState === 'RECOVERING_WS'
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private async handleWsFailure(reason: string): Promise<void> {
|
|
1434
|
+
if (!this.running) return;
|
|
1435
|
+
|
|
1436
|
+
if (this.wsRecoveryTimer) {
|
|
1437
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1438
|
+
this.wsRecoveryTimer = null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (this.transportState === 'RECOVERING_WS') {
|
|
1442
|
+
console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`);
|
|
1443
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1444
|
+
this.activeTransportMode = 'poll';
|
|
1445
|
+
await this.startPollLoop();
|
|
1446
|
+
this.startWsProbeLoop();
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (this.transportState === 'POLL_ACTIVE') {
|
|
1451
|
+
this.lastFallbackReason = reason;
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
this.transportState = 'WS_DEGRADED';
|
|
1456
|
+
this.wsFailureCount += 1;
|
|
1457
|
+
|
|
1458
|
+
if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) {
|
|
1459
|
+
await this.activatePollFallback(reason);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private async activatePollFallback(reason: string): Promise<void> {
|
|
1464
|
+
if (!this.running || !this.isPollFallbackEnabled()) return;
|
|
1465
|
+
if (this.transportState === 'POLL_ACTIVE') return;
|
|
1466
|
+
|
|
1467
|
+
await this.ensurePollCursorLoaded();
|
|
1468
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1469
|
+
this.activeTransportMode = 'poll';
|
|
1470
|
+
this.fallbackCount += 1;
|
|
1471
|
+
this.lastFallbackReason = reason;
|
|
1472
|
+
if (this.fallbackStartedAt === null) {
|
|
1473
|
+
this.fallbackStartedAt = Date.now();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`);
|
|
1477
|
+
await this.startPollLoop();
|
|
1478
|
+
this.startWsProbeLoop();
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private startWsProbeLoop(): void {
|
|
1482
|
+
if (!this.isWsProbeEnabled() || this.probeWsTimer) return;
|
|
1483
|
+
|
|
1484
|
+
this.probeWsTimer = setInterval(() => {
|
|
1485
|
+
if (!this.running || this.transportState !== 'POLL_ACTIVE') return;
|
|
1486
|
+
void this.connectRelayAgentClient();
|
|
1487
|
+
}, this.wsProbeIntervalMs());
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
private stopWsProbeLoop(): void {
|
|
1491
|
+
if (!this.probeWsTimer) return;
|
|
1492
|
+
clearInterval(this.probeWsTimer);
|
|
1493
|
+
this.probeWsTimer = null;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
private beginWsRecovery(): void {
|
|
1497
|
+
if (!this.running) return;
|
|
1498
|
+
|
|
1499
|
+
this.transportState = 'RECOVERING_WS';
|
|
1500
|
+
this.stopWsProbeLoop();
|
|
1501
|
+
|
|
1502
|
+
if (this.wsRecoveryTimer) {
|
|
1503
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`);
|
|
1507
|
+
this.wsRecoveryTimer = setTimeout(() => {
|
|
1508
|
+
this.wsRecoveryTimer = null;
|
|
1509
|
+
void this.promoteWsTransport();
|
|
1510
|
+
}, this.wsStableGraceMs());
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private async promoteWsTransport(): Promise<void> {
|
|
1514
|
+
if (!this.running || this.transportState !== 'RECOVERING_WS') return;
|
|
1515
|
+
|
|
1516
|
+
await this.stopPollLoop();
|
|
1517
|
+
const catchupDelayMs = await this.pollOnce(0);
|
|
1518
|
+
if (catchupDelayMs > 0) {
|
|
1519
|
+
console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll');
|
|
1520
|
+
this.transportState = 'POLL_ACTIVE';
|
|
1521
|
+
this.activeTransportMode = 'poll';
|
|
1522
|
+
await this.startPollLoop();
|
|
1523
|
+
this.startWsProbeLoop();
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
this.completeFallbackWindow();
|
|
1528
|
+
this.transportState = 'WS_ACTIVE';
|
|
1529
|
+
this.activeTransportMode = 'ws';
|
|
1530
|
+
this.wsFailureCount = 0;
|
|
1531
|
+
console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport');
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
private async ensurePollCursorLoaded(): Promise<void> {
|
|
1535
|
+
if (this.pollCursorLoaded) return;
|
|
1536
|
+
|
|
1537
|
+
this.pollCursorLoaded = true;
|
|
1538
|
+
this.pollCursor = this.pollInitialCursor();
|
|
1000
1539
|
|
|
1001
|
-
// Also subscribe explicitly in case the `connected` event already fired
|
|
1002
|
-
// before we registered the handler above.
|
|
1003
1540
|
try {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1541
|
+
const raw = await readFile(pollCursorStatePath(), 'utf-8');
|
|
1542
|
+
const parsed = JSON.parse(raw) as Partial<PersistedPollCursorState>;
|
|
1543
|
+
const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH);
|
|
1544
|
+
if (persistedCursor) {
|
|
1545
|
+
this.pollCursor = persistedCursor;
|
|
1546
|
+
}
|
|
1547
|
+
if (Number.isFinite(parsed.lastSequence)) {
|
|
1548
|
+
this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0));
|
|
1549
|
+
}
|
|
1550
|
+
if (Array.isArray(parsed.recentEventIds)) {
|
|
1551
|
+
this.pollRecentEventIds = parsed.recentEventIds
|
|
1552
|
+
.map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH))
|
|
1553
|
+
.filter((value): value is string => value !== null)
|
|
1554
|
+
.slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1555
|
+
const now = Date.now();
|
|
1556
|
+
for (const eventId of this.pollRecentEventIds) {
|
|
1557
|
+
this.seenMessageIds.set(eventId, now);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1562
|
+
console.warn(
|
|
1563
|
+
`[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}`
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1007
1566
|
}
|
|
1567
|
+
}
|
|
1008
1568
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1569
|
+
private async persistPollCursorState(): Promise<void> {
|
|
1570
|
+
const cursor =
|
|
1571
|
+
sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor();
|
|
1572
|
+
const recentEventIds = this.pollRecentEventIds
|
|
1573
|
+
.map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH))
|
|
1574
|
+
.filter((eventId): eventId is string => eventId !== null)
|
|
1575
|
+
.slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1576
|
+
this.pollCursor = cursor;
|
|
1577
|
+
this.pollRecentEventIds = recentEventIds;
|
|
1578
|
+
|
|
1579
|
+
const state: PersistedPollCursorState = {
|
|
1580
|
+
cursor,
|
|
1581
|
+
lastSequence: this.pollLastSequence,
|
|
1582
|
+
recentEventIds,
|
|
1583
|
+
updatedAt: new Date().toISOString(),
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
const filePath = pollCursorStatePath();
|
|
1587
|
+
const tmpPath = `${filePath}.tmp`;
|
|
1588
|
+
await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true });
|
|
1589
|
+
await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
1590
|
+
await rename(tmpPath, filePath);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
private rememberPollEventId(eventId: string): void {
|
|
1594
|
+
const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH);
|
|
1595
|
+
if (!sanitizedEventId) return;
|
|
1596
|
+
this.pollRecentEventIds = [
|
|
1597
|
+
...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId),
|
|
1598
|
+
sanitizedEventId,
|
|
1599
|
+
].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
private hasRecentPollEventId(eventId: string): boolean {
|
|
1603
|
+
return this.pollRecentEventIds.includes(eventId);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
private async commitPollCursorState(nextCursor: string, lastSequence: number): Promise<void> {
|
|
1607
|
+
const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH);
|
|
1608
|
+
if (sanitizedCursor) {
|
|
1609
|
+
this.pollCursor = sanitizedCursor;
|
|
1610
|
+
}
|
|
1611
|
+
this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence);
|
|
1612
|
+
await this.persistPollCursorState();
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
private async resetPollCursorState(reason: string): Promise<void> {
|
|
1616
|
+
this.cursorResetCount += 1;
|
|
1617
|
+
this.lastFallbackReason = reason;
|
|
1618
|
+
this.pollCursor = this.pollInitialCursor();
|
|
1619
|
+
this.pollLastSequence = 0;
|
|
1620
|
+
await this.persistPollCursorState();
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
private async startPollLoop(): Promise<void> {
|
|
1624
|
+
if (this.pollLoopPromise) return;
|
|
1625
|
+
|
|
1626
|
+
this.pollLoopStopRequested = false;
|
|
1627
|
+
this.pollLoopPromise = (async () => {
|
|
1628
|
+
while (
|
|
1629
|
+
this.running &&
|
|
1630
|
+
!this.pollLoopStopRequested &&
|
|
1631
|
+
(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')
|
|
1632
|
+
) {
|
|
1633
|
+
const delayMs = await this.pollOnce(this.pollTimeoutSeconds());
|
|
1634
|
+
if (
|
|
1635
|
+
!this.running ||
|
|
1636
|
+
this.pollLoopStopRequested ||
|
|
1637
|
+
!(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS')
|
|
1638
|
+
) {
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
if (delayMs > 0) {
|
|
1642
|
+
await sleep(delayMs);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
})().finally(() => {
|
|
1646
|
+
this.pollLoopPromise = null;
|
|
1647
|
+
this.pollAbortController = null;
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
private async stopPollLoop(): Promise<void> {
|
|
1652
|
+
this.pollLoopStopRequested = true;
|
|
1653
|
+
if (this.pollAbortController) {
|
|
1654
|
+
this.pollAbortController.abort();
|
|
1655
|
+
this.pollAbortController = null;
|
|
1656
|
+
}
|
|
1657
|
+
if (this.pollLoopPromise) {
|
|
1658
|
+
await this.pollLoopPromise.catch(() => undefined);
|
|
1659
|
+
this.pollLoopPromise = null;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// eslint-disable-next-line complexity
|
|
1664
|
+
private async pollOnce(timeoutSeconds: number): Promise<number> {
|
|
1665
|
+
await this.ensurePollCursorLoaded();
|
|
1666
|
+
|
|
1667
|
+
const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl);
|
|
1668
|
+
baseUrl.searchParams.set('cursor', this.pollCursor);
|
|
1669
|
+
baseUrl.searchParams.set('timeout', String(timeoutSeconds));
|
|
1670
|
+
baseUrl.searchParams.set('limit', String(this.pollLimit()));
|
|
1671
|
+
|
|
1672
|
+
const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000);
|
|
1673
|
+
const abortController = new AbortController();
|
|
1674
|
+
this.pollAbortController = abortController;
|
|
1675
|
+
const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs);
|
|
1676
|
+
|
|
1677
|
+
try {
|
|
1678
|
+
const response = await fetch(baseUrl, {
|
|
1679
|
+
method: 'GET',
|
|
1680
|
+
headers: {
|
|
1681
|
+
Accept: 'application/json',
|
|
1682
|
+
Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '',
|
|
1683
|
+
'x-api-key': this.config.apiKey,
|
|
1684
|
+
},
|
|
1685
|
+
signal: abortController.signal,
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
if (response.status === 401 || response.status === 403) {
|
|
1689
|
+
console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`);
|
|
1690
|
+
try {
|
|
1691
|
+
await this.refreshRelayAgentRegistration();
|
|
1692
|
+
this.pollFailureCount = 0;
|
|
1693
|
+
return 0;
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
console.warn(
|
|
1696
|
+
`[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1697
|
+
);
|
|
1698
|
+
this.pollFailureCount += 1;
|
|
1699
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (response.status === 409) {
|
|
1704
|
+
console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state');
|
|
1705
|
+
this.pollFailureCount = 0;
|
|
1706
|
+
await this.resetPollCursorState('cursor_reset');
|
|
1707
|
+
return 0;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (response.status === 429) {
|
|
1711
|
+
this.pollFailureCount += 1;
|
|
1712
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
|
|
1713
|
+
return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (response.status >= 500) {
|
|
1717
|
+
this.pollFailureCount += 1;
|
|
1718
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (!response.ok) {
|
|
1722
|
+
this.pollFailureCount += 1;
|
|
1723
|
+
console.warn(`[gateway] Poll request failed: HTTP ${response.status}`);
|
|
1724
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const body = (await response.json()) as PollResponseBody;
|
|
1728
|
+
this.pollFailureCount = 0;
|
|
1729
|
+
const processed = await this.processPollResponse(body);
|
|
1730
|
+
return processed ? 0 : computeBackoffMs(1);
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
if (abortController.signal.aborted) {
|
|
1733
|
+
return 0;
|
|
1734
|
+
}
|
|
1735
|
+
this.pollFailureCount += 1;
|
|
1736
|
+
console.warn(
|
|
1737
|
+
`[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1738
|
+
);
|
|
1739
|
+
return computeBackoffMs(this.pollFailureCount);
|
|
1740
|
+
} finally {
|
|
1741
|
+
clearTimeout(timeoutHandle);
|
|
1742
|
+
if (this.pollAbortController === abortController) {
|
|
1743
|
+
this.pollAbortController = null;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private async processPollResponse(body: PollResponseBody): Promise<boolean> {
|
|
1749
|
+
const events = Array.isArray(body.events) ? [...body.events] : [];
|
|
1750
|
+
events.sort((left, right) => left.sequence - right.sequence);
|
|
1751
|
+
|
|
1752
|
+
let lastSequence = this.pollLastSequence;
|
|
1753
|
+
|
|
1754
|
+
for (const event of events) {
|
|
1755
|
+
if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) {
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
lastSequence = Math.max(lastSequence, event.sequence);
|
|
1760
|
+
|
|
1761
|
+
if (
|
|
1762
|
+
event.sequence <= this.pollLastSequence ||
|
|
1763
|
+
this.hasRecentPollEventId(event.id) ||
|
|
1764
|
+
this.isSeen(event.id)
|
|
1765
|
+
) {
|
|
1766
|
+
this.duplicateDropCount += 1;
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const committed = await this.handlePolledEvent(event);
|
|
1771
|
+
if (!committed) {
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
this.rememberPollEventId(event.id);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor;
|
|
1779
|
+
await this.commitPollCursorState(nextCursor, lastSequence);
|
|
1780
|
+
return true;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// eslint-disable-next-line complexity
|
|
1784
|
+
private async handlePolledEvent(event: PollEventEnvelope): Promise<boolean> {
|
|
1785
|
+
const type = typeof event.payload.type === 'string' ? event.payload.type : '';
|
|
1786
|
+
const baseOptions: RealtimeHandlingOptions = {
|
|
1787
|
+
timestamp: event.timestamp,
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
switch (type) {
|
|
1791
|
+
case 'message.created':
|
|
1792
|
+
case 'message.received':
|
|
1793
|
+
case 'message.new':
|
|
1794
|
+
case 'message.sent':
|
|
1795
|
+
return (
|
|
1796
|
+
await this.handleRealtimeMessage(event.payload as unknown as MessageCreatedEvent, baseOptions)
|
|
1797
|
+
).committed;
|
|
1798
|
+
case 'thread.reply':
|
|
1799
|
+
case 'thread.message.created':
|
|
1800
|
+
case 'thread.message.sent':
|
|
1801
|
+
return (
|
|
1802
|
+
await this.handleRealtimeThreadReply(event.payload as unknown as ThreadReplyEvent, baseOptions)
|
|
1803
|
+
).committed;
|
|
1804
|
+
case 'dm.received':
|
|
1805
|
+
case 'dm.message.created':
|
|
1806
|
+
case 'direct_message.created':
|
|
1807
|
+
return (await this.handleRealtimeDm(event.payload as unknown as DmReceivedEvent, baseOptions))
|
|
1808
|
+
.committed;
|
|
1809
|
+
case 'group_dm.received':
|
|
1810
|
+
case 'group_dm.message.created':
|
|
1811
|
+
return (
|
|
1812
|
+
await this.handleRealtimeGroupDm(event.payload as unknown as GroupDmReceivedEvent, baseOptions)
|
|
1813
|
+
).committed;
|
|
1814
|
+
case 'command.invoked':
|
|
1815
|
+
return (
|
|
1816
|
+
await this.handleRealtimeCommand(event.payload as unknown as CommandInvokedEvent, {
|
|
1817
|
+
...baseOptions,
|
|
1818
|
+
eventId: event.id,
|
|
1819
|
+
})
|
|
1820
|
+
).committed;
|
|
1821
|
+
case 'reaction.added':
|
|
1822
|
+
return (
|
|
1823
|
+
await this.handleRealtimeReaction(event.payload as unknown as ReactionAddedEvent, 'added', {
|
|
1824
|
+
...baseOptions,
|
|
1825
|
+
eventId: event.id,
|
|
1826
|
+
})
|
|
1827
|
+
).committed;
|
|
1828
|
+
case 'reaction.removed':
|
|
1829
|
+
return (
|
|
1830
|
+
await this.handleRealtimeReaction(event.payload as unknown as ReactionRemovedEvent, 'removed', {
|
|
1831
|
+
...baseOptions,
|
|
1832
|
+
eventId: event.id,
|
|
1833
|
+
})
|
|
1834
|
+
).committed;
|
|
1835
|
+
default:
|
|
1836
|
+
console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`);
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Start the gateway — register agent and subscribe for realtime events. */
|
|
1842
|
+
async start(): Promise<void> {
|
|
1843
|
+
if (this.running) return;
|
|
1844
|
+
this.running = true;
|
|
1845
|
+
|
|
1846
|
+
// Connect to the local OpenClaw gateway WebSocket (persistent connection)
|
|
1847
|
+
const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
1848
|
+
const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT;
|
|
1849
|
+
|
|
1850
|
+
if (token) {
|
|
1851
|
+
this.openclawClient = await OpenClawGatewayClient.create(token, port);
|
|
1852
|
+
try {
|
|
1853
|
+
await this.openclawClient.connect();
|
|
1854
|
+
console.log('[gateway] OpenClaw gateway WebSocket client ready');
|
|
1855
|
+
} catch (err) {
|
|
1856
|
+
console.warn(
|
|
1857
|
+
`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
} else {
|
|
1861
|
+
console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const registered = await this.relaycast.agents.registerOrGet({
|
|
1865
|
+
name: this.config.clawName,
|
|
1866
|
+
type: 'agent',
|
|
1867
|
+
persona: 'Relaycast inbound gateway for OpenClaw',
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
await this.replaceRelayAgentClient(registered.token);
|
|
1871
|
+
|
|
1872
|
+
await this.ensureChannelMembership();
|
|
1873
|
+
|
|
1874
|
+
// Also subscribe explicitly in case the `connected` event fired before
|
|
1875
|
+
// the handler ran, or the SDK defers connection readiness.
|
|
1876
|
+
this.subscribeRelayChannels();
|
|
1877
|
+
|
|
1878
|
+
console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`);
|
|
1012
1879
|
|
|
1013
1880
|
// Start spawn control HTTP server
|
|
1014
1881
|
await this.startControlServer();
|
|
@@ -1017,15 +1884,14 @@ export class InboundGateway {
|
|
|
1017
1884
|
/** Stop the gateway — clean up websocket and relay clients. */
|
|
1018
1885
|
async stop(): Promise<void> {
|
|
1019
1886
|
this.running = false;
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
} catch {
|
|
1025
|
-
// Best effort
|
|
1026
|
-
}
|
|
1887
|
+
this.stopWsProbeLoop();
|
|
1888
|
+
if (this.wsRecoveryTimer) {
|
|
1889
|
+
clearTimeout(this.wsRecoveryTimer);
|
|
1890
|
+
this.wsRecoveryTimer = null;
|
|
1027
1891
|
}
|
|
1028
|
-
this.
|
|
1892
|
+
this.completeFallbackWindow();
|
|
1893
|
+
await this.stopPollLoop();
|
|
1894
|
+
this.cleanupRelaySubscriptions();
|
|
1029
1895
|
|
|
1030
1896
|
if (this.relayAgentClient) {
|
|
1031
1897
|
try {
|
|
@@ -1089,80 +1955,95 @@ export class InboundGateway {
|
|
|
1089
1955
|
}
|
|
1090
1956
|
}
|
|
1091
1957
|
|
|
1092
|
-
private async handleRealtimeMessage(
|
|
1958
|
+
private async handleRealtimeMessage(
|
|
1959
|
+
event: MessageCreatedEvent,
|
|
1960
|
+
options: RealtimeHandlingOptions = {}
|
|
1961
|
+
): Promise<InboundProcessingResult> {
|
|
1093
1962
|
const channel = normalizeChannelName(event.channel);
|
|
1094
|
-
if (!this.config.channels.includes(channel)) return;
|
|
1963
|
+
if (!this.config.channels.includes(channel)) return { committed: true };
|
|
1095
1964
|
|
|
1096
|
-
const messageId = event.message?.id;
|
|
1097
|
-
if (!messageId) return;
|
|
1965
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
1966
|
+
if (!messageId) return { committed: true };
|
|
1098
1967
|
|
|
1099
1968
|
const inbound: InboundMessage = {
|
|
1100
1969
|
id: messageId,
|
|
1101
1970
|
channel,
|
|
1102
|
-
from: event.message
|
|
1103
|
-
text: event.message
|
|
1104
|
-
timestamp: new Date().toISOString(),
|
|
1971
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1972
|
+
text: event.message?.text ?? '',
|
|
1973
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1105
1974
|
};
|
|
1106
1975
|
|
|
1107
|
-
|
|
1976
|
+
return this.processInbound(inbound);
|
|
1108
1977
|
}
|
|
1109
1978
|
|
|
1110
|
-
private async handleRealtimeThreadReply(
|
|
1979
|
+
private async handleRealtimeThreadReply(
|
|
1980
|
+
event: ThreadReplyEvent,
|
|
1981
|
+
options: RealtimeHandlingOptions = {}
|
|
1982
|
+
): Promise<InboundProcessingResult> {
|
|
1111
1983
|
const channel = normalizeChannelName(event.channel);
|
|
1112
|
-
if (!this.config.channels.includes(channel)) return;
|
|
1984
|
+
if (!this.config.channels.includes(channel)) return { committed: true };
|
|
1113
1985
|
|
|
1114
|
-
const messageId = event.message?.id;
|
|
1115
|
-
if (!messageId) return;
|
|
1986
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
1987
|
+
if (!messageId) return { committed: true };
|
|
1116
1988
|
|
|
1117
1989
|
const inbound: InboundMessage = {
|
|
1118
1990
|
id: messageId,
|
|
1119
1991
|
channel,
|
|
1120
|
-
from: event.message
|
|
1121
|
-
text: event.message
|
|
1122
|
-
timestamp: new Date().toISOString(),
|
|
1992
|
+
from: event.message?.agentName ?? 'unknown',
|
|
1993
|
+
text: event.message?.text ?? '',
|
|
1994
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1123
1995
|
threadParentId: event.parentId,
|
|
1124
1996
|
};
|
|
1125
1997
|
|
|
1126
|
-
|
|
1998
|
+
return this.processInbound(inbound);
|
|
1127
1999
|
}
|
|
1128
2000
|
|
|
1129
|
-
private async handleRealtimeDm(
|
|
1130
|
-
|
|
1131
|
-
|
|
2001
|
+
private async handleRealtimeDm(
|
|
2002
|
+
event: DmReceivedEvent,
|
|
2003
|
+
options: RealtimeHandlingOptions = {}
|
|
2004
|
+
): Promise<InboundProcessingResult> {
|
|
2005
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
2006
|
+
if (!messageId) return { committed: true };
|
|
1132
2007
|
|
|
1133
2008
|
const inbound: InboundMessage = {
|
|
1134
2009
|
id: messageId,
|
|
1135
2010
|
channel: 'dm',
|
|
1136
|
-
from: event.message
|
|
1137
|
-
text: event.message
|
|
1138
|
-
timestamp: new Date().toISOString(),
|
|
2011
|
+
from: event.message?.agentName ?? 'unknown',
|
|
2012
|
+
text: event.message?.text ?? '',
|
|
2013
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1139
2014
|
conversationId: event.conversationId,
|
|
1140
2015
|
kind: 'dm',
|
|
1141
2016
|
};
|
|
1142
2017
|
|
|
1143
|
-
|
|
2018
|
+
return this.processInbound(inbound);
|
|
1144
2019
|
}
|
|
1145
2020
|
|
|
1146
|
-
private async handleRealtimeGroupDm(
|
|
1147
|
-
|
|
1148
|
-
|
|
2021
|
+
private async handleRealtimeGroupDm(
|
|
2022
|
+
event: GroupDmReceivedEvent,
|
|
2023
|
+
options: RealtimeHandlingOptions = {}
|
|
2024
|
+
): Promise<InboundProcessingResult> {
|
|
2025
|
+
const messageId = options.eventId ?? event.message?.id;
|
|
2026
|
+
if (!messageId) return { committed: true };
|
|
1149
2027
|
|
|
1150
2028
|
const inbound: InboundMessage = {
|
|
1151
2029
|
id: messageId,
|
|
1152
2030
|
channel: `groupdm:${event.conversationId}`,
|
|
1153
|
-
from: event.message
|
|
1154
|
-
text: event.message
|
|
1155
|
-
timestamp: new Date().toISOString(),
|
|
2031
|
+
from: event.message?.agentName ?? 'unknown',
|
|
2032
|
+
text: event.message?.text ?? '',
|
|
2033
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1156
2034
|
conversationId: event.conversationId,
|
|
1157
2035
|
kind: 'groupdm',
|
|
1158
2036
|
};
|
|
1159
2037
|
|
|
1160
|
-
|
|
2038
|
+
return this.processInbound(inbound);
|
|
1161
2039
|
}
|
|
1162
2040
|
|
|
1163
|
-
private async handleRealtimeCommand(
|
|
2041
|
+
private async handleRealtimeCommand(
|
|
2042
|
+
event: CommandInvokedEvent,
|
|
2043
|
+
options: RealtimeHandlingOptions = {}
|
|
2044
|
+
): Promise<InboundProcessingResult> {
|
|
1164
2045
|
const channel = normalizeChannelName(event.channel);
|
|
1165
|
-
if (!this.config.channels.includes(channel)) return;
|
|
2046
|
+
if (!this.config.channels.includes(channel)) return { committed: true };
|
|
1166
2047
|
|
|
1167
2048
|
// Commands lack a server-assigned event ID, so we synthesize one.
|
|
1168
2049
|
// We include args + timestamp to avoid silently dropping legitimate
|
|
@@ -1170,7 +2051,8 @@ export class InboundGateway {
|
|
|
1170
2051
|
// reconnection replays may deliver a duplicate, but that's less
|
|
1171
2052
|
// harmful than silently swallowing a real command.
|
|
1172
2053
|
const argsSlug = event.args ? `_${event.args}` : '';
|
|
1173
|
-
const syntheticId =
|
|
2054
|
+
const syntheticId =
|
|
2055
|
+
options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
|
|
1174
2056
|
const argsText = event.args ? ` ${event.args}` : '';
|
|
1175
2057
|
|
|
1176
2058
|
const inbound: InboundMessage = {
|
|
@@ -1178,76 +2060,91 @@ export class InboundGateway {
|
|
|
1178
2060
|
channel,
|
|
1179
2061
|
from: event.invokedBy,
|
|
1180
2062
|
text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
|
|
1181
|
-
timestamp: new Date().toISOString(),
|
|
2063
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1182
2064
|
kind: 'command',
|
|
1183
2065
|
};
|
|
1184
2066
|
|
|
1185
|
-
|
|
2067
|
+
return this.processInbound(inbound);
|
|
1186
2068
|
}
|
|
1187
2069
|
|
|
1188
2070
|
private async handleRealtimeReaction(
|
|
1189
2071
|
event: ReactionAddedEvent | ReactionRemovedEvent,
|
|
1190
2072
|
action: 'added' | 'removed',
|
|
1191
|
-
|
|
2073
|
+
options: RealtimeHandlingOptions = {}
|
|
2074
|
+
): Promise<InboundProcessingResult> {
|
|
1192
2075
|
// Include timestamp so add→remove→re-add of the same emoji isn't
|
|
1193
2076
|
// silently dropped within the 15-min dedup window. Reactions are soft
|
|
1194
2077
|
// notifications, so a rare duplicate on SDK reconnect is acceptable.
|
|
1195
|
-
const syntheticId =
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
2078
|
+
const syntheticId =
|
|
2079
|
+
options.eventId ??
|
|
2080
|
+
`reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
|
|
2081
|
+
const text =
|
|
2082
|
+
action === 'added'
|
|
2083
|
+
? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
|
|
2084
|
+
: `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
|
|
1199
2085
|
|
|
1200
2086
|
const inbound: InboundMessage = {
|
|
1201
2087
|
id: syntheticId,
|
|
1202
2088
|
channel: 'reaction',
|
|
1203
2089
|
from: event.agentName,
|
|
1204
2090
|
text,
|
|
1205
|
-
timestamp: new Date().toISOString(),
|
|
2091
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
1206
2092
|
kind: 'reaction',
|
|
1207
2093
|
};
|
|
1208
2094
|
|
|
1209
|
-
|
|
2095
|
+
return this.processInbound(inbound);
|
|
1210
2096
|
}
|
|
1211
2097
|
|
|
1212
|
-
private async
|
|
1213
|
-
if (!this.running) return;
|
|
1214
|
-
if (this.processingMessageIds.has(message.id) || this.isSeen(message.id))
|
|
2098
|
+
private async processInbound(message: InboundMessage): Promise<InboundProcessingResult> {
|
|
2099
|
+
if (!this.running) return { committed: false };
|
|
2100
|
+
if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) {
|
|
2101
|
+
this.duplicateDropCount += 1;
|
|
2102
|
+
return { committed: true, reason: 'duplicate' };
|
|
2103
|
+
}
|
|
1215
2104
|
|
|
1216
2105
|
// Avoid echo loops — skip messages from this claw.
|
|
1217
2106
|
if (message.from === this.config.clawName) {
|
|
1218
|
-
// Only update cursor for real channels with real (non-synthetic) message IDs.
|
|
1219
2107
|
this.markSeen(message.id);
|
|
1220
|
-
return;
|
|
2108
|
+
return { committed: true, reason: 'echo' };
|
|
1221
2109
|
}
|
|
1222
2110
|
|
|
1223
|
-
// Mark as seen immediately to prevent duplicate delivery from concurrent
|
|
1224
|
-
// realtime events processing the same message.
|
|
1225
|
-
this.markSeen(message.id);
|
|
1226
2111
|
this.processingMessageIds.add(message.id);
|
|
1227
2112
|
|
|
1228
2113
|
console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
|
|
1229
2114
|
try {
|
|
1230
2115
|
const result = await this.onMessage(message);
|
|
1231
|
-
console.log(
|
|
2116
|
+
console.log(
|
|
2117
|
+
`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`
|
|
2118
|
+
);
|
|
2119
|
+
if (!result.ok) {
|
|
2120
|
+
return { committed: false, result };
|
|
2121
|
+
}
|
|
2122
|
+
this.markSeen(message.id);
|
|
2123
|
+
return { committed: true, result };
|
|
1232
2124
|
} finally {
|
|
1233
2125
|
this.processingMessageIds.delete(message.id);
|
|
1234
2126
|
}
|
|
1235
2127
|
}
|
|
1236
2128
|
|
|
1237
|
-
/** Format delivery text with channel, sender, and
|
|
2129
|
+
/** Format delivery text with channel, sender, and response hint. */
|
|
1238
2130
|
private formatDeliveryText(message: InboundMessage): string {
|
|
1239
|
-
// Pre-formatted kinds (
|
|
1240
|
-
if (message.kind === '
|
|
2131
|
+
// Pre-formatted kinds (reaction) already have the full text with hints.
|
|
2132
|
+
if (message.kind === 'reaction') {
|
|
1241
2133
|
return message.text;
|
|
1242
2134
|
}
|
|
2135
|
+
if (message.kind === 'command') {
|
|
2136
|
+
return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`;
|
|
2137
|
+
}
|
|
1243
2138
|
if (message.kind === 'dm') {
|
|
1244
|
-
return `[relaycast:dm] @${message.from}: ${message.text}`;
|
|
2139
|
+
return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
|
|
1245
2140
|
}
|
|
1246
2141
|
if (message.kind === 'groupdm') {
|
|
1247
|
-
return `[relaycast:groupdm] @${message.from}: ${message.text}`;
|
|
2142
|
+
return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`;
|
|
2143
|
+
}
|
|
2144
|
+
if (message.threadParentId) {
|
|
2145
|
+
return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`;
|
|
1248
2146
|
}
|
|
1249
|
-
|
|
1250
|
-
return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
|
|
2147
|
+
return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`;
|
|
1251
2148
|
}
|
|
1252
2149
|
|
|
1253
2150
|
/** Handle an inbound Relaycast message. */
|
|
@@ -1269,9 +2166,7 @@ export class InboundGateway {
|
|
|
1269
2166
|
}
|
|
1270
2167
|
}
|
|
1271
2168
|
|
|
1272
|
-
console.warn(
|
|
1273
|
-
`[gateway] Failed to deliver message ${message.id} from @${message.from}`,
|
|
1274
|
-
);
|
|
2169
|
+
console.warn(`[gateway] Failed to deliver message ${message.id} from @${message.from}`);
|
|
1275
2170
|
return { ok: false, method: 'failed', error: 'All delivery methods failed' };
|
|
1276
2171
|
}
|
|
1277
2172
|
|
|
@@ -1323,6 +2218,7 @@ export class InboundGateway {
|
|
|
1323
2218
|
});
|
|
1324
2219
|
}
|
|
1325
2220
|
|
|
2221
|
+
// eslint-disable-next-line complexity
|
|
1326
2222
|
private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1327
2223
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
1328
2224
|
const path = url.pathname;
|
|
@@ -1332,12 +2228,15 @@ export class InboundGateway {
|
|
|
1332
2228
|
|
|
1333
2229
|
if (req.method === 'GET' && path === '/health') {
|
|
1334
2230
|
res.writeHead(200);
|
|
1335
|
-
res.end(
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
2231
|
+
res.end(
|
|
2232
|
+
JSON.stringify({
|
|
2233
|
+
ok: true,
|
|
2234
|
+
status: 'running',
|
|
2235
|
+
active: this.spawnManager.size,
|
|
2236
|
+
uptime: process.uptime(),
|
|
2237
|
+
transport: this.transportHealthSnapshot(),
|
|
2238
|
+
})
|
|
2239
|
+
);
|
|
1341
2240
|
return;
|
|
1342
2241
|
}
|
|
1343
2242
|
|
|
@@ -1366,14 +2265,16 @@ export class InboundGateway {
|
|
|
1366
2265
|
|
|
1367
2266
|
const handle = await this.spawnManager.spawn(spawnOpts);
|
|
1368
2267
|
res.writeHead(200);
|
|
1369
|
-
res.end(
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
2268
|
+
res.end(
|
|
2269
|
+
JSON.stringify({
|
|
2270
|
+
ok: true,
|
|
2271
|
+
name: handle.displayName,
|
|
2272
|
+
agentName: handle.agentName,
|
|
2273
|
+
id: handle.id,
|
|
2274
|
+
gatewayPort: handle.gatewayPort,
|
|
2275
|
+
active: this.spawnManager.size,
|
|
2276
|
+
})
|
|
2277
|
+
);
|
|
1377
2278
|
} catch (err) {
|
|
1378
2279
|
res.writeHead(500);
|
|
1379
2280
|
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
@@ -1384,16 +2285,18 @@ export class InboundGateway {
|
|
|
1384
2285
|
if (req.method === 'GET' && path === '/list') {
|
|
1385
2286
|
const handles = this.spawnManager.list();
|
|
1386
2287
|
res.writeHead(200);
|
|
1387
|
-
res.end(
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
2288
|
+
res.end(
|
|
2289
|
+
JSON.stringify({
|
|
2290
|
+
ok: true,
|
|
2291
|
+
active: handles.length,
|
|
2292
|
+
claws: handles.map((h) => ({
|
|
2293
|
+
name: h.displayName,
|
|
2294
|
+
agentName: h.agentName,
|
|
2295
|
+
id: h.id,
|
|
2296
|
+
gatewayPort: h.gatewayPort,
|
|
2297
|
+
})),
|
|
2298
|
+
})
|
|
2299
|
+
);
|
|
1397
2300
|
return;
|
|
1398
2301
|
}
|
|
1399
2302
|
|