@coclaw/openclaw-coclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +153 -0
- package/index.js +156 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +70 -0
- package/src/api.js +57 -0
- package/src/channel-plugin.js +71 -0
- package/src/cli-registrar.js +87 -0
- package/src/cli.js +132 -0
- package/src/common/bot-binding.js +76 -0
- package/src/common/errors.js +23 -0
- package/src/common/gateway-notify.js +104 -0
- package/src/common/messages.js +33 -0
- package/src/config.js +194 -0
- package/src/message-model.js +67 -0
- package/src/realtime-bridge.js +546 -0
- package/src/runtime.js +10 -0
- package/src/session-manager/manager.js +274 -0
- package/src/transport-adapter.js +50 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/* c8 ignore start */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import nodePath from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { clearConfig, getBindingsPath, readConfig } from './config.js';
|
|
7
|
+
import { getRuntime } from './runtime.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000';
|
|
10
|
+
const DEFAULT_GATEWAY_WS_URL = 'ws://127.0.0.1:18789';
|
|
11
|
+
const RECONNECT_MS = 10_000;
|
|
12
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
let serverWs = null;
|
|
15
|
+
let gatewayWs = null;
|
|
16
|
+
let reconnectTimer = null;
|
|
17
|
+
let connectTimer = null;
|
|
18
|
+
let started = false;
|
|
19
|
+
let gatewayReady = false;
|
|
20
|
+
let gatewayConnectReqId = null;
|
|
21
|
+
let gatewayRpcSeq = 0;
|
|
22
|
+
const gatewayPendingRequests = new Map();
|
|
23
|
+
let mainSessionEnsurePromise = null;
|
|
24
|
+
let mainSessionEnsured = false;
|
|
25
|
+
|
|
26
|
+
function logBridgeDebug(message) {
|
|
27
|
+
if (typeof currentLogger?.debug === 'function') {
|
|
28
|
+
currentLogger.debug(`[coclaw] ${message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let currentLogger = console;
|
|
32
|
+
let currentPluginConfig = {};
|
|
33
|
+
let intentionallyClosed = false;
|
|
34
|
+
|
|
35
|
+
function toServerWsUrl(baseUrl, token) {
|
|
36
|
+
const url = new URL(baseUrl);
|
|
37
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
38
|
+
url.pathname = '/api/v1/bots/stream';
|
|
39
|
+
url.searchParams.set('token', token);
|
|
40
|
+
return url.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 脱敏 URL 中的 token 参数,用于日志输出
|
|
44
|
+
function maskUrlToken(url) {
|
|
45
|
+
return url.replace(/([?&]token=)[^&]+/, '$1***');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveGatewayWsUrl() {
|
|
49
|
+
return currentPluginConfig?.gatewayWsUrl
|
|
50
|
+
?? process.env.COCLAW_GATEWAY_WS_URL
|
|
51
|
+
?? DEFAULT_GATEWAY_WS_URL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function clearTokenLocal() {
|
|
55
|
+
const cfg = await readConfig();
|
|
56
|
+
if (!cfg?.token) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await clearConfig();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closeGatewayWs() {
|
|
63
|
+
if (!gatewayWs) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
gatewayWs.close(1000, 'server-disconnect');
|
|
68
|
+
}
|
|
69
|
+
catch {}
|
|
70
|
+
gatewayWs = null;
|
|
71
|
+
gatewayReady = false;
|
|
72
|
+
gatewayConnectReqId = null;
|
|
73
|
+
for (const [, settle] of gatewayPendingRequests) {
|
|
74
|
+
settle({ ok: false, error: 'gateway_closed' });
|
|
75
|
+
}
|
|
76
|
+
gatewayPendingRequests.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function forwardToServer(payload) {
|
|
80
|
+
if (!serverWs || serverWs.readyState !== 1) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
serverWs.send(JSON.stringify(payload));
|
|
85
|
+
}
|
|
86
|
+
catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveGatewayAuthToken() {
|
|
90
|
+
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
|
|
91
|
+
if (envToken) {
|
|
92
|
+
return envToken;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const rt = getRuntime();
|
|
96
|
+
if (rt?.config?.loadConfig) {
|
|
97
|
+
const cfg = rt.config.loadConfig();
|
|
98
|
+
const token = cfg?.gateway?.auth?.token;
|
|
99
|
+
return typeof token === 'string' && token.trim() ? token.trim() : '';
|
|
100
|
+
}
|
|
101
|
+
const cfgPath = process.env.OPENCLAW_CONFIG_PATH
|
|
102
|
+
? nodePath.resolve(process.env.OPENCLAW_CONFIG_PATH)
|
|
103
|
+
: nodePath.join(os.homedir(), '.openclaw', 'openclaw.json');
|
|
104
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
105
|
+
const cfg = JSON.parse(raw);
|
|
106
|
+
const token = cfg?.gateway?.auth?.token;
|
|
107
|
+
return typeof token === 'string' && token.trim() ? token.trim() : '';
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function nextGatewayReqId(prefix = 'coclaw-rpc') {
|
|
115
|
+
gatewayRpcSeq += 1;
|
|
116
|
+
return `${prefix}-${Date.now()}-${gatewayRpcSeq}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function gatewayRpc(method, params = {}, options = {}) {
|
|
120
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 1500;
|
|
121
|
+
const ready = await waitGatewayReady(timeoutMs);
|
|
122
|
+
if (!ready || !gatewayWs || gatewayWs.readyState !== 1 || !gatewayReady) {
|
|
123
|
+
return { ok: false, error: 'gateway_not_ready' };
|
|
124
|
+
}
|
|
125
|
+
const ws = gatewayWs;
|
|
126
|
+
const id = nextGatewayReqId('coclaw-gw');
|
|
127
|
+
return await new Promise((resolve) => {
|
|
128
|
+
let finished = false;
|
|
129
|
+
const settle = (result) => {
|
|
130
|
+
if (finished) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
finished = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
gatewayPendingRequests.delete(id);
|
|
136
|
+
resolve(result);
|
|
137
|
+
};
|
|
138
|
+
gatewayPendingRequests.set(id, settle);
|
|
139
|
+
const timer = setTimeout(() => settle({ ok: false, error: 'timeout' }), timeoutMs);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
try {
|
|
142
|
+
ws.send(JSON.stringify({
|
|
143
|
+
type: 'req',
|
|
144
|
+
id,
|
|
145
|
+
method,
|
|
146
|
+
params,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
settle({ ok: false, error: 'send_failed' });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// eslint-disable-next-line no-unused-vars -- 功能暂时禁用,保留函数体供后续修复参考
|
|
156
|
+
async function ensureMainSessionKey() {
|
|
157
|
+
if (mainSessionEnsured) {
|
|
158
|
+
return { ok: true, state: 'ready' };
|
|
159
|
+
}
|
|
160
|
+
if (mainSessionEnsurePromise) {
|
|
161
|
+
return await mainSessionEnsurePromise;
|
|
162
|
+
}
|
|
163
|
+
mainSessionEnsurePromise = (async () => {
|
|
164
|
+
const key = 'agent:main:main';
|
|
165
|
+
const resolved = await gatewayRpc('sessions.resolve', { key }, { timeoutMs: 2000 });
|
|
166
|
+
const resolvedSessionId = resolved?.response?.result?.entry?.sessionId;
|
|
167
|
+
if (resolved?.ok === true && typeof resolvedSessionId === 'string' && resolvedSessionId.trim()) {
|
|
168
|
+
mainSessionEnsured = true;
|
|
169
|
+
logBridgeDebug(`main session key ensure: ready key=${key} sessionId=${resolvedSessionId}`);
|
|
170
|
+
return { ok: true, state: 'ready', sessionId: resolvedSessionId };
|
|
171
|
+
}
|
|
172
|
+
const reset = await gatewayRpc('sessions.reset', { key, reason: 'new' }, { timeoutMs: 2500 });
|
|
173
|
+
if (reset?.ok !== true) {
|
|
174
|
+
return { ok: false, error: reset?.error ?? 'sessions_reset_failed' };
|
|
175
|
+
}
|
|
176
|
+
const verify = await gatewayRpc('sessions.resolve', { key }, { timeoutMs: 2000 });
|
|
177
|
+
const verifySessionId = verify?.response?.result?.entry?.sessionId;
|
|
178
|
+
if (verify?.ok === true && typeof verifySessionId === 'string' && verifySessionId.trim()) {
|
|
179
|
+
mainSessionEnsured = true;
|
|
180
|
+
logBridgeDebug(`main session key ensure: created key=${key} sessionId=${verifySessionId}`);
|
|
181
|
+
return { ok: true, state: 'created', sessionId: verifySessionId };
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, error: verify?.error ?? 'sessions_resolve_after_reset_failed' };
|
|
184
|
+
})();
|
|
185
|
+
try {
|
|
186
|
+
const result = await mainSessionEnsurePromise;
|
|
187
|
+
if (!result?.ok) {
|
|
188
|
+
currentLogger.warn?.(`[coclaw] ensure main session key failed: ${result?.error ?? 'unknown'}`);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
mainSessionEnsurePromise = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sendGatewayConnectRequest(ws) {
|
|
198
|
+
gatewayConnectReqId = `coclaw-connect-${Date.now()}`;
|
|
199
|
+
logBridgeDebug(`gateway connect request -> id=${gatewayConnectReqId}`);
|
|
200
|
+
const authToken = resolveGatewayAuthToken();
|
|
201
|
+
const params = {
|
|
202
|
+
minProtocol: 3,
|
|
203
|
+
maxProtocol: 3,
|
|
204
|
+
client: {
|
|
205
|
+
id: 'gateway-client',
|
|
206
|
+
version: 'dev',
|
|
207
|
+
platform: process.platform,
|
|
208
|
+
mode: 'backend',
|
|
209
|
+
},
|
|
210
|
+
caps: [],
|
|
211
|
+
role: 'operator',
|
|
212
|
+
scopes: ['operator.admin'],
|
|
213
|
+
auth: authToken ? { token: authToken } : undefined,
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
ws.send(JSON.stringify({
|
|
217
|
+
type: 'req',
|
|
218
|
+
id: gatewayConnectReqId,
|
|
219
|
+
method: 'connect',
|
|
220
|
+
params,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
gatewayConnectReqId = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ensureGatewayConnection() {
|
|
229
|
+
if (gatewayWs || !serverWs || serverWs.readyState !== 1) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
233
|
+
if (!WebSocketCtor) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const ws = new WebSocketCtor(resolveGatewayWsUrl());
|
|
237
|
+
gatewayWs = ws;
|
|
238
|
+
gatewayReady = false;
|
|
239
|
+
gatewayConnectReqId = null;
|
|
240
|
+
|
|
241
|
+
ws.addEventListener('message', (event) => {
|
|
242
|
+
let payload = null;
|
|
243
|
+
try {
|
|
244
|
+
payload = JSON.parse(String(event.data ?? '{}'));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!payload || typeof payload !== 'object') {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (payload.type === 'event' && payload.event === 'connect.challenge') {
|
|
253
|
+
logBridgeDebug('gateway event <- connect.challenge');
|
|
254
|
+
sendGatewayConnectRequest(ws);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (payload.type === 'res' && gatewayConnectReqId && payload.id === gatewayConnectReqId) {
|
|
258
|
+
if (payload.ok === true) {
|
|
259
|
+
gatewayReady = true;
|
|
260
|
+
logBridgeDebug(`gateway connect ok <- id=${payload.id}`);
|
|
261
|
+
gatewayConnectReqId = null;
|
|
262
|
+
// [DISABLED] ensureMainSessionKey 存在 bug,每次重连都会误触 sessions.reset
|
|
263
|
+
// 导致对话被频繁重置。详见 docs/ensure-main-session-bug-analysis.md
|
|
264
|
+
// void ensureMainSessionKey();
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
gatewayReady = false;
|
|
268
|
+
gatewayConnectReqId = null;
|
|
269
|
+
currentLogger.warn?.(`[coclaw] gateway connect failed: ${payload?.error?.message ?? 'unknown'}`);
|
|
270
|
+
try {
|
|
271
|
+
ws.close(1008, 'gateway_connect_failed');
|
|
272
|
+
}
|
|
273
|
+
catch {}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (payload.type === 'res' && typeof payload.id === 'string') {
|
|
278
|
+
const settle = gatewayPendingRequests.get(payload.id);
|
|
279
|
+
if (settle) {
|
|
280
|
+
settle({
|
|
281
|
+
ok: payload.ok === true,
|
|
282
|
+
response: payload,
|
|
283
|
+
error: payload?.error?.message ?? payload?.error?.code,
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!gatewayReady) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (payload.type === 'res' || payload.type === 'event') {
|
|
292
|
+
forwardToServer(payload);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
ws.addEventListener('open', () => {
|
|
297
|
+
// wait for connect.challenge
|
|
298
|
+
});
|
|
299
|
+
ws.addEventListener('close', () => {
|
|
300
|
+
gatewayWs = null;
|
|
301
|
+
gatewayReady = false;
|
|
302
|
+
gatewayConnectReqId = null;
|
|
303
|
+
});
|
|
304
|
+
ws.addEventListener('error', () => {});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function waitGatewayReady(timeoutMs = 1500) {
|
|
308
|
+
ensureGatewayConnection();
|
|
309
|
+
if (gatewayWs && gatewayWs.readyState === 1 && gatewayReady) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const ws = gatewayWs;
|
|
313
|
+
if (!ws) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
return await new Promise((resolve) => {
|
|
317
|
+
let done = false;
|
|
318
|
+
const finish = (ok) => {
|
|
319
|
+
if (done) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
done = true;
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
clearInterval(poller);
|
|
325
|
+
ws.removeEventListener?.('error', onError);
|
|
326
|
+
ws.removeEventListener?.('close', onClose);
|
|
327
|
+
resolve(ok);
|
|
328
|
+
};
|
|
329
|
+
const onError = () => finish(false);
|
|
330
|
+
const onClose = () => finish(false);
|
|
331
|
+
const poller = setInterval(() => {
|
|
332
|
+
if (gatewayWs !== ws) {
|
|
333
|
+
finish(false);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (gatewayReady && ws.readyState === 1) {
|
|
337
|
+
finish(true);
|
|
338
|
+
}
|
|
339
|
+
}, 25);
|
|
340
|
+
poller.unref?.();
|
|
341
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
342
|
+
timer.unref?.();
|
|
343
|
+
ws.addEventListener('error', onError);
|
|
344
|
+
ws.addEventListener('close', onClose);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function handleGatewayRequestFromServer(payload) {
|
|
349
|
+
const ready = await waitGatewayReady();
|
|
350
|
+
if (!ready || !gatewayWs || gatewayWs.readyState !== 1) {
|
|
351
|
+
logBridgeDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
|
|
352
|
+
forwardToServer({
|
|
353
|
+
type: 'res',
|
|
354
|
+
id: payload.id,
|
|
355
|
+
ok: false,
|
|
356
|
+
error: {
|
|
357
|
+
code: 'GATEWAY_OFFLINE',
|
|
358
|
+
message: 'Gateway is offline',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
logBridgeDebug(`gateway req -> id=${payload.id} method=${payload.method}`);
|
|
365
|
+
gatewayWs.send(JSON.stringify({
|
|
366
|
+
type: 'req',
|
|
367
|
+
id: payload.id,
|
|
368
|
+
method: payload.method,
|
|
369
|
+
params: payload.params ?? {},
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
forwardToServer({
|
|
374
|
+
type: 'res',
|
|
375
|
+
id: payload.id,
|
|
376
|
+
ok: false,
|
|
377
|
+
error: {
|
|
378
|
+
code: 'GATEWAY_SEND_FAILED',
|
|
379
|
+
message: 'Failed to send request to gateway',
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function clearConnectTimer() {
|
|
386
|
+
if (!connectTimer) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
clearTimeout(connectTimer);
|
|
390
|
+
connectTimer = null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function scheduleReconnect() {
|
|
394
|
+
if (!started || reconnectTimer) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
reconnectTimer = setTimeout(async () => {
|
|
398
|
+
reconnectTimer = null;
|
|
399
|
+
await connectIfNeeded();
|
|
400
|
+
}, RECONNECT_MS);
|
|
401
|
+
reconnectTimer.unref?.();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function connectIfNeeded() {
|
|
405
|
+
if (!started || serverWs) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const bindingsPath = getBindingsPath();
|
|
410
|
+
const cfg = await readConfig();
|
|
411
|
+
if (!cfg?.token) {
|
|
412
|
+
currentLogger.warn?.(`[coclaw] realtime bridge skip connect: missing token in ${bindingsPath}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const baseUrl = currentPluginConfig?.serverUrl ?? cfg.serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
417
|
+
const target = toServerWsUrl(baseUrl, cfg.token);
|
|
418
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
419
|
+
if (!WebSocketCtor) {
|
|
420
|
+
currentLogger.warn?.('[coclaw] WebSocket not available, skip realtime bridge');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const maskedTarget = maskUrlToken(target);
|
|
425
|
+
currentLogger.info?.(`[coclaw] realtime bridge connecting: ${maskedTarget} (cfg: ${bindingsPath})`);
|
|
426
|
+
intentionallyClosed = false;
|
|
427
|
+
const sock = new WebSocketCtor(target);
|
|
428
|
+
serverWs = sock;
|
|
429
|
+
clearConnectTimer();
|
|
430
|
+
connectTimer = setTimeout(() => {
|
|
431
|
+
if (serverWs !== sock || intentionallyClosed) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
currentLogger.warn?.(`[coclaw] realtime bridge connect timeout, will retry: ${maskedTarget}`);
|
|
435
|
+
serverWs = null;
|
|
436
|
+
closeGatewayWs();
|
|
437
|
+
scheduleReconnect();
|
|
438
|
+
try {
|
|
439
|
+
sock.close(4000, 'connect_timeout');
|
|
440
|
+
}
|
|
441
|
+
catch {}
|
|
442
|
+
}, CONNECT_TIMEOUT_MS);
|
|
443
|
+
connectTimer.unref?.();
|
|
444
|
+
|
|
445
|
+
sock.addEventListener('open', () => {
|
|
446
|
+
clearConnectTimer();
|
|
447
|
+
currentLogger.info?.(`[coclaw] realtime bridge connected: ${maskedTarget}`);
|
|
448
|
+
ensureGatewayConnection();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
sock.addEventListener('message', async (event) => {
|
|
452
|
+
try {
|
|
453
|
+
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
454
|
+
if (payload?.type === 'bot.unbound') {
|
|
455
|
+
await clearTokenLocal();
|
|
456
|
+
try {
|
|
457
|
+
sock.close(4001, 'bot_unbound');
|
|
458
|
+
}
|
|
459
|
+
catch {}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (payload?.type === 'req' || payload?.type === 'rpc.req') {
|
|
463
|
+
void handleGatewayRequestFromServer({
|
|
464
|
+
id: payload.id,
|
|
465
|
+
method: payload.method,
|
|
466
|
+
params: payload.params ?? {},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
currentLogger.warn?.(`[coclaw] realtime message parse failed: ${String(err?.message ?? err)}`);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
sock.addEventListener('close', async (event) => {
|
|
476
|
+
clearConnectTimer();
|
|
477
|
+
// 若 serverWs 已指向新实例(如 refresh 后),跳过旧 sock 的清理
|
|
478
|
+
if (serverWs !== null && serverWs !== sock) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const wasIntentional = intentionallyClosed;
|
|
482
|
+
serverWs = null;
|
|
483
|
+
intentionallyClosed = false;
|
|
484
|
+
closeGatewayWs();
|
|
485
|
+
|
|
486
|
+
if (event?.code === 4001 || event?.code === 4003) {
|
|
487
|
+
await clearTokenLocal();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!wasIntentional) {
|
|
492
|
+
currentLogger.warn?.(`[coclaw] realtime bridge closed (${event?.code ?? 'unknown'}: ${event?.reason ?? 'n/a'}), will retry in ${RECONNECT_MS}ms`);
|
|
493
|
+
scheduleReconnect();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
sock.addEventListener('error', (err) => {
|
|
498
|
+
if (serverWs !== sock || intentionallyClosed) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
clearConnectTimer();
|
|
502
|
+
currentLogger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
|
|
503
|
+
serverWs = null;
|
|
504
|
+
closeGatewayWs();
|
|
505
|
+
scheduleReconnect();
|
|
506
|
+
try {
|
|
507
|
+
sock.close(4000, 'connect_error');
|
|
508
|
+
}
|
|
509
|
+
catch {}
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export async function startRealtimeBridge({ logger, pluginConfig } = {}) {
|
|
514
|
+
currentLogger = logger ?? console;
|
|
515
|
+
currentPluginConfig = pluginConfig ?? {};
|
|
516
|
+
started = true;
|
|
517
|
+
await connectIfNeeded();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function refreshRealtimeBridge() {
|
|
521
|
+
// 停止再启动,确保用新 token 重连
|
|
522
|
+
await stopRealtimeBridge();
|
|
523
|
+
await startRealtimeBridge({
|
|
524
|
+
logger: currentLogger,
|
|
525
|
+
pluginConfig: currentPluginConfig,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function stopRealtimeBridge() {
|
|
530
|
+
started = false;
|
|
531
|
+
clearConnectTimer();
|
|
532
|
+
if (reconnectTimer) {
|
|
533
|
+
clearTimeout(reconnectTimer);
|
|
534
|
+
reconnectTimer = null;
|
|
535
|
+
}
|
|
536
|
+
closeGatewayWs();
|
|
537
|
+
if (serverWs) {
|
|
538
|
+
intentionallyClosed = true;
|
|
539
|
+
try {
|
|
540
|
+
serverWs.close(1000, 'stopped');
|
|
541
|
+
}
|
|
542
|
+
catch {}
|
|
543
|
+
serverWs = null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/* c8 ignore stop */
|