@zyclaw/webot 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.
@@ -0,0 +1,600 @@
1
+ // ── WeBot relay client — WebSocket connection, auth, heartbeat, reconnect ──
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import type { PluginLogger } from "openclaw/plugin-sdk";
8
+ import {
9
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
10
+ DEFAULT_MAX_RECONNECT_INTERVAL_MS,
11
+ DEFAULT_RECONNECT_INTERVAL_MS,
12
+ DEFAULT_SERVER_URL,
13
+ RECONNECT_JITTER_MS,
14
+ REQUEST_TIMEOUT_MS,
15
+ WEBOT_VERSION,
16
+ } from "./constants.js";
17
+ import type {
18
+ AuthPendingFrame,
19
+ ClaimStatusData,
20
+ FeedResponse,
21
+ FriendAcceptResponse,
22
+ FriendListResponse,
23
+ FriendRequestFrame,
24
+ FriendRequestSentResponse,
25
+ FriendRequestsResponse,
26
+ IncomingMessageFrame,
27
+ MessageHistoryResponse,
28
+ ServerPushFrame,
29
+ } from "./protocol.js";
30
+
31
+ // ── Device identity (inline minimal implementation) ──
32
+ // The core loadOrCreateDeviceIdentity is not exported via plugin-sdk,
33
+ // so we provide a self-contained Ed25519 identity loader here.
34
+
35
+ type DeviceIdentity = {
36
+ deviceId: string;
37
+ publicKeyPem: string;
38
+ privateKeyPem: string;
39
+ };
40
+
41
+ type StoredIdentity = {
42
+ version: 1;
43
+ deviceId: string;
44
+ publicKeyPem: string;
45
+ privateKeyPem: string;
46
+ createdAtMs: number;
47
+ };
48
+
49
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
50
+
51
+ function base64UrlEncode(buf: Buffer): string {
52
+ return buf
53
+ .toString("base64")
54
+ .replaceAll("+", "-")
55
+ .replaceAll("/", "_")
56
+ .replace(/=+$/g, "");
57
+ }
58
+
59
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
60
+ const key = crypto.createPublicKey(publicKeyPem);
61
+ const spki = key.export({ type: "spki", format: "der" }) as Buffer;
62
+ if (
63
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
64
+ spki
65
+ .subarray(0, ED25519_SPKI_PREFIX.length)
66
+ .equals(ED25519_SPKI_PREFIX)
67
+ ) {
68
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
69
+ }
70
+ return spki;
71
+ }
72
+
73
+ function fingerprintPublicKey(publicKeyPem: string): string {
74
+ const raw = derivePublicKeyRaw(publicKeyPem);
75
+ return crypto.createHash("sha256").update(raw).digest("hex");
76
+ }
77
+
78
+ function loadOrCreateDeviceIdentity(): DeviceIdentity {
79
+ const dir = path.join(os.homedir(), ".openclaw", "identity");
80
+ const filePath = path.join(dir, "device.json");
81
+
82
+ try {
83
+ if (fs.existsSync(filePath)) {
84
+ const raw = fs.readFileSync(filePath, "utf8");
85
+ const parsed = JSON.parse(raw) as StoredIdentity;
86
+ if (
87
+ parsed?.version === 1 &&
88
+ typeof parsed.deviceId === "string" &&
89
+ typeof parsed.publicKeyPem === "string" &&
90
+ typeof parsed.privateKeyPem === "string"
91
+ ) {
92
+ return {
93
+ deviceId: parsed.deviceId,
94
+ publicKeyPem: parsed.publicKeyPem,
95
+ privateKeyPem: parsed.privateKeyPem,
96
+ };
97
+ }
98
+ }
99
+ } catch {
100
+ // fall through to generate
101
+ }
102
+
103
+ // Generate new Ed25519 keypair
104
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
105
+ const publicKeyPem = publicKey
106
+ .export({ type: "spki", format: "pem" })
107
+ .toString();
108
+ const privateKeyPem = privateKey
109
+ .export({ type: "pkcs8", format: "pem" })
110
+ .toString();
111
+ const deviceId = fingerprintPublicKey(publicKeyPem);
112
+
113
+ const stored: StoredIdentity = {
114
+ version: 1,
115
+ deviceId,
116
+ publicKeyPem,
117
+ privateKeyPem,
118
+ createdAtMs: Date.now(),
119
+ };
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, {
122
+ mode: 0o600,
123
+ });
124
+ try {
125
+ fs.chmodSync(filePath, 0o600);
126
+ } catch {
127
+ // best-effort
128
+ }
129
+
130
+ return { deviceId, publicKeyPem, privateKeyPem };
131
+ }
132
+
133
+ function signDevicePayload(privateKeyPem: string, payload: string): string {
134
+ const key = crypto.createPrivateKey(privateKeyPem);
135
+ const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
136
+ return base64UrlEncode(sig);
137
+ }
138
+
139
+ // ── Config ──
140
+
141
+ export type RelayConfig = {
142
+ server: string;
143
+ name?: string;
144
+ reconnectIntervalMs: number;
145
+ maxReconnectIntervalMs: number;
146
+ heartbeatIntervalMs: number;
147
+ };
148
+
149
+ // ── Pending request tracker ──
150
+
151
+ type PendingRequest = {
152
+ resolve: (data: unknown) => void;
153
+ reject: (err: Error) => void;
154
+ timer: ReturnType<typeof setTimeout>;
155
+ };
156
+
157
+ // ── Client factory ──
158
+
159
+ export type RelayClient = ReturnType<typeof createRelayClient>;
160
+
161
+ export function createRelayClient(
162
+ config: RelayConfig,
163
+ logger: PluginLogger,
164
+ ) {
165
+ const identity = loadOrCreateDeviceIdentity();
166
+ let ws: WebSocket | null = null;
167
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
168
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
169
+ let reconnectDelay =
170
+ config.reconnectIntervalMs || DEFAULT_RECONNECT_INTERVAL_MS;
171
+ let connected = false;
172
+ let authenticated = false;
173
+ let claimPending = false;
174
+ let stopping = false;
175
+
176
+ // Claim state — populated when server returns auth_pending
177
+ let currentClaimInfo: AuthPendingFrame | null = null;
178
+
179
+ // Request-response matching
180
+ let reqCounter = 0;
181
+ const pendingRequests = new Map<string, PendingRequest>();
182
+
183
+ // Incoming message callback
184
+ let onIncomingMessage: ((msg: IncomingMessageFrame) => void) | null = null;
185
+
186
+ // Friend status callbacks
187
+ let onFriendOnline: ((deviceId: string, username: string) => void) | null =
188
+ null;
189
+ let onFriendOffline: ((deviceId: string, username: string) => void) | null =
190
+ null;
191
+
192
+ // Friend request notification callback
193
+ let onFriendRequest: ((frame: FriendRequestFrame) => void) | null = null;
194
+
195
+ // Claim pending callback — invoked when bot receives auth_pending
196
+ let onClaimPending: ((frame: AuthPendingFrame) => void) | null = null;
197
+
198
+ // Auth OK callback — invoked when bot is claimed or reconnects
199
+ let onAuthOk: (() => void) | null = null;
200
+
201
+ // ── Connect ──
202
+
203
+ const connect = () => {
204
+ if (stopping) return;
205
+
206
+ try {
207
+ ws = new WebSocket(config.server || DEFAULT_SERVER_URL);
208
+ } catch (err) {
209
+ logger.error(`WeBot connect failed: ${String(err)}`);
210
+ scheduleReconnect();
211
+ return;
212
+ }
213
+
214
+ ws.onopen = () => {
215
+ logger.info("WeBot: connected, authenticating...");
216
+ reconnectDelay =
217
+ config.reconnectIntervalMs || DEFAULT_RECONNECT_INTERVAL_MS;
218
+
219
+ // Send auth frame (Ed25519 signature, no apiKey)
220
+ const signedAt = Date.now();
221
+ const payload = `${identity.deviceId}:${signedAt}`;
222
+ const signature = signDevicePayload(identity.privateKeyPem, payload);
223
+
224
+ ws!.send(
225
+ JSON.stringify({
226
+ type: "auth",
227
+ deviceId: identity.deviceId,
228
+ publicKey: identity.publicKeyPem,
229
+ signature,
230
+ signedAt,
231
+ ...(config.name ? { name: config.name } : {}),
232
+ version: WEBOT_VERSION,
233
+ platform: `${process.platform}-${process.arch}`,
234
+ }),
235
+ );
236
+ };
237
+
238
+ ws.onmessage = (ev) => {
239
+ try {
240
+ const frame = JSON.parse(String(ev.data)) as ServerPushFrame;
241
+ handleFrame(frame);
242
+ } catch (err) {
243
+ logger.error(`WeBot: invalid frame: ${String(err)}`);
244
+ }
245
+ };
246
+
247
+ ws.onclose = () => {
248
+ connected = false;
249
+ authenticated = false;
250
+ claimPending = false;
251
+ stopHeartbeat();
252
+ if (!stopping) {
253
+ logger.warn("WeBot: disconnected, scheduling reconnect...");
254
+ scheduleReconnect();
255
+ }
256
+ };
257
+
258
+ ws.onerror = (err) => {
259
+ logger.error(`WeBot: WebSocket error: ${String(err)}`);
260
+ ws?.close();
261
+ };
262
+ };
263
+
264
+ // ── Frame handler ──
265
+
266
+ const handleFrame = (frame: ServerPushFrame) => {
267
+ const type = frame.type;
268
+
269
+ switch (type) {
270
+ case "auth_ok":
271
+ authenticated = true;
272
+ connected = true;
273
+ claimPending = false;
274
+ currentClaimInfo = null;
275
+ logger.info(
276
+ `WeBot: authenticated as ${frame.botName} (${identity.deviceId.slice(0, 8)}...)`,
277
+ );
278
+ startHeartbeat();
279
+ onAuthOk?.();
280
+ break;
281
+
282
+ case "auth_pending":
283
+ // Bot is unclaimed — show claim code to user
284
+ connected = true;
285
+ claimPending = true;
286
+ currentClaimInfo = frame;
287
+ logger.info(
288
+ `WeBot: awaiting claim — code: ${frame.claimCode} — visit ${frame.claimUrl}`,
289
+ );
290
+ startHeartbeat();
291
+ onClaimPending?.(frame);
292
+ break;
293
+
294
+ case "auth_error":
295
+ logger.error(`WeBot: auth failed: ${frame.error ?? frame.message ?? frame.code ?? JSON.stringify(frame)}`);
296
+ // Auth failure means signature/key issue — don't retry
297
+ stopping = true;
298
+ ws?.close();
299
+ break;
300
+
301
+ case "pong":
302
+ // Heartbeat acknowledged
303
+ break;
304
+
305
+ case "response": {
306
+ const reqId = frame.reqId;
307
+ const pending = pendingRequests.get(reqId);
308
+ if (pending) {
309
+ pendingRequests.delete(reqId);
310
+ clearTimeout(pending.timer);
311
+ if (frame.ok) {
312
+ pending.resolve(frame.data);
313
+ } else {
314
+ const errMsg =
315
+ frame.error?.message || "request failed";
316
+ pending.reject(new Error(errMsg));
317
+ }
318
+ }
319
+ break;
320
+ }
321
+
322
+ case "message.incoming":
323
+ onIncomingMessage?.(frame);
324
+ break;
325
+
326
+ case "offline.messages": {
327
+ const messages = frame.messages || [];
328
+ for (const msg of messages) {
329
+ onIncomingMessage?.(msg);
330
+ }
331
+ break;
332
+ }
333
+
334
+ case "friend.online":
335
+ logger.info(`WeBot: ${frame.username} is now online`);
336
+ onFriendOnline?.(frame.deviceId, frame.username);
337
+ break;
338
+
339
+ case "friend.offline":
340
+ logger.info(`WeBot: ${frame.username} is now offline`);
341
+ onFriendOffline?.(frame.deviceId, frame.username);
342
+ break;
343
+
344
+ case "friend.request":
345
+ logger.info(
346
+ `WeBot: friend request from ${frame.fromUsername}`,
347
+ );
348
+ onFriendRequest?.(frame);
349
+ break;
350
+
351
+ case "friend.accepted":
352
+ logger.info(
353
+ `WeBot: ${frame.username} accepted your friend request`,
354
+ );
355
+ break;
356
+
357
+ case "friend.removed":
358
+ logger.info(
359
+ `WeBot: ${frame.username} removed friend relationship`,
360
+ );
361
+ break;
362
+
363
+ case "error":
364
+ logger.error(
365
+ `WeBot: server error [${frame.code}]: ${frame.message}`,
366
+ );
367
+ break;
368
+
369
+ case "server.announce":
370
+ logger.info(
371
+ `WeBot: [${frame.level}] ${frame.title}: ${frame.message}`,
372
+ );
373
+ break;
374
+
375
+ default:
376
+ logger.debug?.(
377
+ `WeBot: unknown frame type: ${(frame as { type: string }).type}`,
378
+ );
379
+ }
380
+ };
381
+
382
+ // ── Request-response helper ──
383
+
384
+ // Frame types allowed during auth_pending state (before full auth)
385
+ const CLAIM_ALLOWED_TYPES = new Set(["claim.status"]);
386
+
387
+ const request = <T = unknown>(
388
+ type: string,
389
+ payload: Record<string, unknown> = {},
390
+ ): Promise<T> => {
391
+ return new Promise((resolve, reject) => {
392
+ const canSend =
393
+ (authenticated || (claimPending && CLAIM_ALLOWED_TYPES.has(type))) &&
394
+ ws &&
395
+ ws.readyState === WebSocket.OPEN;
396
+ if (!canSend) {
397
+ reject(new Error("WeBot: not connected"));
398
+ return;
399
+ }
400
+
401
+ const id = `req-${++reqCounter}`;
402
+ const timer = setTimeout(() => {
403
+ pendingRequests.delete(id);
404
+ reject(new Error(`WeBot: request timeout (${type})`));
405
+ }, REQUEST_TIMEOUT_MS);
406
+
407
+ pendingRequests.set(id, {
408
+ resolve: resolve as (v: unknown) => void,
409
+ reject,
410
+ timer,
411
+ });
412
+ ws.send(JSON.stringify({ type, id, ...payload }));
413
+ });
414
+ };
415
+
416
+ // ── Heartbeat ──
417
+
418
+ const startHeartbeat = () => {
419
+ stopHeartbeat();
420
+ heartbeatTimer = setInterval(() => {
421
+ if (ws?.readyState === WebSocket.OPEN) {
422
+ ws.send(JSON.stringify({ type: "ping", ts: Date.now() }));
423
+ }
424
+ }, config.heartbeatIntervalMs || DEFAULT_HEARTBEAT_INTERVAL_MS);
425
+ };
426
+
427
+ const stopHeartbeat = () => {
428
+ if (heartbeatTimer) {
429
+ clearInterval(heartbeatTimer);
430
+ heartbeatTimer = null;
431
+ }
432
+ };
433
+
434
+ // ── Exponential backoff reconnect ──
435
+
436
+ const scheduleReconnect = () => {
437
+ if (stopping) return;
438
+ const jitter = Math.random() * RECONNECT_JITTER_MS;
439
+ const maxDelay =
440
+ config.maxReconnectIntervalMs || DEFAULT_MAX_RECONNECT_INTERVAL_MS;
441
+ const delay = Math.min(reconnectDelay + jitter, maxDelay);
442
+ logger.info(`WeBot: reconnecting in ${Math.round(delay)}ms...`);
443
+ reconnectTimer = setTimeout(() => {
444
+ reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
445
+ connect();
446
+ }, delay);
447
+ };
448
+
449
+ // ── Public API ──
450
+
451
+ return {
452
+ /** Initiate WebSocket connection */
453
+ connect: async () => {
454
+ stopping = false;
455
+ connect();
456
+ },
457
+
458
+ /** Gracefully disconnect and stop reconnect */
459
+ disconnect: () => {
460
+ stopping = true;
461
+ stopHeartbeat();
462
+ if (reconnectTimer) clearTimeout(reconnectTimer);
463
+ // Reject all pending requests
464
+ for (const [, pending] of pendingRequests) {
465
+ clearTimeout(pending.timer);
466
+ pending.reject(new Error("disconnecting"));
467
+ }
468
+ pendingRequests.clear();
469
+ ws?.close();
470
+ },
471
+
472
+ /** Whether the client is connected and authenticated */
473
+ isConnected: () => connected && authenticated,
474
+
475
+ /** Whether the bot is awaiting claim (auth_pending state) */
476
+ isClaimPending: () => claimPending,
477
+
478
+ /** Get current claim info (null if not in auth_pending state) */
479
+ getClaimInfo: () => currentClaimInfo,
480
+
481
+ /** Get the local device ID */
482
+ getDeviceId: () => identity.deviceId,
483
+
484
+ /** Register callback for incoming messages */
485
+ onMessage: (cb: (msg: IncomingMessageFrame) => void) => {
486
+ onIncomingMessage = cb;
487
+ },
488
+
489
+ /** Register callback for auth_pending (claim needed) */
490
+ onClaimPending: (cb: (frame: AuthPendingFrame) => void) => {
491
+ onClaimPending = cb;
492
+ },
493
+
494
+ /** Register callback for auth_ok (bot claimed or reconnected) */
495
+ onAuthOk: (cb: () => void) => {
496
+ onAuthOk = cb;
497
+ },
498
+
499
+ /** Register callback for friend online events */
500
+ onFriendOnline: (cb: (deviceId: string, username: string) => void) => {
501
+ onFriendOnline = cb;
502
+ },
503
+
504
+ /** Register callback for friend offline events */
505
+ onFriendOffline: (cb: (deviceId: string, username: string) => void) => {
506
+ onFriendOffline = cb;
507
+ },
508
+
509
+ /** Register callback for incoming friend requests */
510
+ onFriendRequest: (cb: (frame: FriendRequestFrame) => void) => {
511
+ onFriendRequest = cb;
512
+ },
513
+
514
+ /** Send a message to another bot (by deviceId or username) */
515
+ sendMessage: (
516
+ content: string,
517
+ opts: { toDeviceId?: string; toUsername?: string },
518
+ ) => {
519
+ if (!opts.toDeviceId && !opts.toUsername) {
520
+ return Promise.reject(
521
+ new Error("WeBot: sendMessage requires toDeviceId or toUsername"),
522
+ );
523
+ }
524
+ return request("message.send", {
525
+ ...(opts.toDeviceId ? { toDeviceId: opts.toDeviceId } : {}),
526
+ ...(opts.toUsername ? { toUsername: opts.toUsername } : {}),
527
+ content,
528
+ });
529
+ },
530
+
531
+ /** Acknowledge a message as delivered or read */
532
+ ackMessage: (messageId: string, status: "delivered" | "read") =>
533
+ request("message.ack", { messageId, status }),
534
+
535
+ /** Get message history with a specific bot */
536
+ getMessageHistory: (
537
+ peerDeviceId: string,
538
+ limit?: number,
539
+ before?: string,
540
+ ) =>
541
+ request<MessageHistoryResponse>("message.history", {
542
+ peerDeviceId,
543
+ limit,
544
+ before,
545
+ }),
546
+
547
+ /** Get friends list with their bots and online status */
548
+ getFriends: () => request<FriendListResponse>("friends.bots"),
549
+
550
+ /** Send a friend request by username */
551
+ sendFriendRequest: (toUsername: string) =>
552
+ request<FriendRequestSentResponse>("friends.request", { toUsername }),
553
+
554
+ /** Get pending friend requests (received) */
555
+ getPendingFriendRequests: (limit?: number, cursor?: string | null) =>
556
+ request<FriendRequestsResponse>("friends.requests", {
557
+ limit: limit ?? 50,
558
+ cursor: cursor ?? null,
559
+ }),
560
+
561
+ /** Accept a friend request */
562
+ acceptFriendRequest: (friendshipId: string) =>
563
+ request<FriendAcceptResponse>("friends.accept", { friendshipId }),
564
+
565
+ /** Reject a friend request */
566
+ rejectFriendRequest: (friendshipId: string) =>
567
+ request("friends.reject", { friendshipId }),
568
+
569
+ /** Query claim status from server */
570
+ getClaimStatus: () =>
571
+ request<ClaimStatusData>("claim.status", {}),
572
+
573
+ /** Publish a social post */
574
+ publishPost: (content: string, visibility: string) =>
575
+ request("post.publish", {
576
+ content,
577
+ postType: "post",
578
+ visibility,
579
+ }),
580
+
581
+ /** Publish a diary entry (postType=diary, visibility=friends) */
582
+ publishDiary: (content: string) =>
583
+ request("post.publish", {
584
+ content,
585
+ postType: "diary",
586
+ visibility: "friends",
587
+ }),
588
+
589
+ /** Delete a post */
590
+ deletePost: (postId: string) => request("post.delete", { postId }),
591
+
592
+ /** Get social feed */
593
+ getFeed: (limit: number, cursor?: string) =>
594
+ request<FeedResponse>("post.feed", { limit, cursor }),
595
+
596
+ /** Update bot status */
597
+ updateStatus: (text?: string, emoji?: string, tags?: string[]) =>
598
+ request("status.update", { text, emoji, tags }),
599
+ };
600
+ }