@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,446 @@
1
+ // ── WeBot message handler — bridge between WeBot relay and local Gateway ──
2
+ //
3
+ // Message protocol between bots:
4
+ // [webot:task] <content> — task for the receiving bot to process (expects response)
5
+ // [webot:response] <content> — response to a previous task (no reply needed, notify owner)
6
+ // (no tag) — treated as [webot:task] for backward compat
7
+
8
+ import crypto from "node:crypto";
9
+ import type { PluginLogger } from "openclaw/plugin-sdk";
10
+ import {
11
+ GATEWAY_RECONNECT_DELAY_MS,
12
+ GATEWAY_RPC_TIMEOUT_MS,
13
+ LOCAL_GATEWAY_WS,
14
+ } from "./constants.js";
15
+ import type { FriendRequestFrame, IncomingMessageFrame } from "./protocol.js";
16
+ import type { RelayClient } from "./relay-client.js";
17
+
18
+ type GatewayPendingCallback = (response: unknown) => void;
19
+
20
+ // ── Protocol tag helpers ──
21
+
22
+ const TAG_TASK = "[webot:task]";
23
+ const TAG_RESPONSE = "[webot:response]";
24
+
25
+ type MessageType = "task" | "response";
26
+
27
+ function parseMessageType(content: string): { type: MessageType; body: string } {
28
+ if (content.startsWith(TAG_RESPONSE)) {
29
+ return { type: "response", body: content.slice(TAG_RESPONSE.length).trim() };
30
+ }
31
+ if (content.startsWith(TAG_TASK)) {
32
+ return { type: "task", body: content.slice(TAG_TASK.length).trim() };
33
+ }
34
+ // Untagged → treat as task (backward compat)
35
+ return { type: "task", body: content };
36
+ }
37
+
38
+ export type MessageHandler = ReturnType<typeof createMessageHandler>;
39
+
40
+ export function createMessageHandler(
41
+ relay: RelayClient,
42
+ logger: PluginLogger,
43
+ gatewayToken?: string,
44
+ ) {
45
+ let gwWs: WebSocket | null = null;
46
+ let gwReqCounter = 0;
47
+ const gwPending = new Map<string, GatewayPendingCallback>();
48
+ // Completion callbacks for agent RPCs — called when the second res frame
49
+ // arrives with the actual agent result (the first is just "accepted").
50
+ const gwCompletionCallbacks = new Map<string, (frame: unknown) => void>();
51
+ let gwConnecting = false;
52
+ let gwStopping = false;
53
+
54
+ // Dedup incoming messages (messageId → timestamp) to avoid double-delivery
55
+ // from real-time + offline.messages overlap
56
+ const seenMessages = new Map<string, number>();
57
+ const SEEN_MSG_TTL_MS = 5 * 60_000; // 5 minutes
58
+
59
+ // Content-based dedup: server may send the same message with different
60
+ // messageIds (real-time + offline.messages). Key: `deviceId:contentPrefix`
61
+ const recentContent = new Map<string, number>();
62
+ const CONTENT_DEDUP_TTL_MS = 10_000; // 10 seconds
63
+
64
+ // ── Gateway self-connection ──
65
+
66
+ const connectToGateway = () => {
67
+ if (gwStopping || gwConnecting) return;
68
+ gwConnecting = true;
69
+
70
+ try {
71
+ gwWs = new WebSocket(LOCAL_GATEWAY_WS);
72
+ } catch (err) {
73
+ logger.error(`WeBot: Gateway connect failed: ${String(err)}`);
74
+ gwConnecting = false;
75
+ scheduleGatewayReconnect();
76
+ return;
77
+ }
78
+
79
+ gwWs.onopen = () => {
80
+ gwConnecting = false;
81
+ logger.info("WeBot: gateway WS opened, waiting for connect.challenge…");
82
+ };
83
+
84
+ gwWs.onmessage = (ev) => {
85
+ try {
86
+ const frame = JSON.parse(String(ev.data));
87
+
88
+ // Handle connect.challenge — gateway sends this immediately after WS open
89
+ if (frame.type === "event" && frame.event === "connect.challenge") {
90
+ const nonce =
91
+ frame.payload && typeof frame.payload.nonce === "string"
92
+ ? frame.payload.nonce
93
+ : undefined;
94
+ const connectFrame = {
95
+ type: "req",
96
+ id: `gw-${++gwReqCounter}`,
97
+ method: "connect",
98
+ params: {
99
+ minProtocol: 3,
100
+ maxProtocol: 3,
101
+ client: {
102
+ id: "gateway-client",
103
+ displayName: "WeBot Plugin",
104
+ version: "0.1.0",
105
+ platform: "plugin",
106
+ mode: "backend",
107
+ },
108
+ auth: gatewayToken ? { token: gatewayToken } : undefined,
109
+ role: "operator",
110
+ scopes: ["operator.admin"],
111
+ },
112
+ };
113
+ gwWs!.send(JSON.stringify(connectFrame));
114
+ logger.info("WeBot: connect.challenge received, handshake sent");
115
+ return;
116
+ }
117
+
118
+ // Handle response frames (RPC results + hello-ok)
119
+ if (frame.type === "res" && frame.id) {
120
+ const cb = gwPending.get(frame.id as string);
121
+ if (cb) {
122
+ gwPending.delete(frame.id as string);
123
+ cb(frame);
124
+ } else {
125
+ // Second res frame (agent completion) — invoke completion callback
126
+ const completionCb = gwCompletionCallbacks.get(frame.id as string);
127
+ if (completionCb) {
128
+ gwCompletionCallbacks.delete(frame.id as string);
129
+ try {
130
+ completionCb(frame);
131
+ } catch (err) {
132
+ logger.error(`WeBot: completion callback error: ${String(err)}`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ } catch {
138
+ // ignore malformed frames
139
+ }
140
+ };
141
+
142
+ gwWs.onclose = () => {
143
+ gwConnecting = false;
144
+ if (!gwStopping) {
145
+ scheduleGatewayReconnect();
146
+ }
147
+ };
148
+
149
+ gwWs.onerror = (err) => {
150
+ logger.error(`WeBot: Gateway WS error: ${String(err)}`);
151
+ gwWs?.close();
152
+ };
153
+ };
154
+
155
+ const scheduleGatewayReconnect = () => {
156
+ if (gwStopping) return;
157
+ setTimeout(connectToGateway, GATEWAY_RECONNECT_DELAY_MS);
158
+ };
159
+
160
+ const callGatewayRpc = (
161
+ method: string,
162
+ params: Record<string, unknown>,
163
+ onCompletion?: (frame: unknown) => void,
164
+ ): Promise<unknown> => {
165
+ return new Promise((resolve, reject) => {
166
+ if (!gwWs || gwWs.readyState !== WebSocket.OPEN) {
167
+ reject(new Error("Gateway not connected"));
168
+ return;
169
+ }
170
+
171
+ const id = `gw-${++gwReqCounter}`;
172
+ const timer = setTimeout(() => {
173
+ gwPending.delete(id);
174
+ gwCompletionCallbacks.delete(id);
175
+ reject(new Error("Gateway request timeout"));
176
+ }, GATEWAY_RPC_TIMEOUT_MS);
177
+
178
+ gwPending.set(id, (response) => {
179
+ clearTimeout(timer);
180
+ resolve(response);
181
+ });
182
+
183
+ if (onCompletion) {
184
+ gwCompletionCallbacks.set(id, onCompletion);
185
+ }
186
+
187
+ gwWs.send(JSON.stringify({ type: "req", id, method, params }));
188
+ });
189
+ };
190
+
191
+ // ── Start Gateway connection (call after gateway is listening) ──
192
+ const startGatewayConnection = () => {
193
+ connectToGateway();
194
+ };
195
+
196
+ // ── Listen for incoming WeBot messages ──
197
+ relay.onMessage(async (msg: IncomingMessageFrame) => {
198
+ // Deduplicate by messageId
199
+ if (seenMessages.has(msg.messageId)) {
200
+ logger.info(`WeBot: dedup — skipping already-seen messageId ${msg.messageId}`);
201
+ return;
202
+ }
203
+ seenMessages.set(msg.messageId, Date.now());
204
+
205
+ // Content-based dedup: the server may send the same message under
206
+ // different messageIds (real-time delivery + offline.messages overlap).
207
+ const contentKey = `${msg.fromDeviceId}:${msg.content.slice(0, 200)}`;
208
+ if (recentContent.has(contentKey)) {
209
+ logger.info(`WeBot: dedup — skipping duplicate content from ${msg.fromDeviceId.slice(0, 8)}`);
210
+ return;
211
+ }
212
+ recentContent.set(contentKey, Date.now());
213
+
214
+ // Prune old entries from both dedup maps
215
+ const now = Date.now();
216
+ for (const [id, ts] of seenMessages) {
217
+ if (ts < now - SEEN_MSG_TTL_MS) seenMessages.delete(id);
218
+ }
219
+ for (const [key, ts] of recentContent) {
220
+ if (ts < now - CONTENT_DEDUP_TTL_MS) recentContent.delete(key);
221
+ }
222
+
223
+ const preview =
224
+ msg.content.length > 80
225
+ ? `${msg.content.slice(0, 80)}...`
226
+ : msg.content;
227
+ logger.info(
228
+ `WeBot: message from ${msg.fromUsername} (${msg.fromBotName}): ${preview}`,
229
+ );
230
+
231
+ // Acknowledge delivery (best-effort)
232
+ relay.ackMessage(msg.messageId, "delivered").catch(() => {});
233
+
234
+ const { type: msgType, body } = parseMessageType(msg.content);
235
+ const fromDeviceId = msg.fromDeviceId;
236
+ const sessionKey = `agent:main:webot:dm:${fromDeviceId}`;
237
+ const sessionLabel = `WeBot: ${msg.fromUsername}`;
238
+
239
+ try {
240
+ switch (msgType) {
241
+ // ── RESPONSE: result of a task we previously sent ──
242
+ // Do NOT reply back (break the loop). Notify owner via agent + message tool.
243
+ case "response": {
244
+ logger.info(`WeBot: received task response from ${msg.fromUsername}`);
245
+ // Use a brief trigger as the visible "user" message in session history;
246
+ // put the full content in extraSystemPrompt so the agent can process it
247
+ // without the full bot message appearing as a user message in Web UI.
248
+ const responsePreview = body.length > 60 ? `${body.slice(0, 60)}…` : body;
249
+ await callGatewayRpc("agent", {
250
+ message: `[WeBot response from @${msg.fromUsername}]: ${responsePreview}`,
251
+ sessionKey,
252
+ label: sessionLabel,
253
+ idempotencyKey: crypto.randomUUID(),
254
+ extraSystemPrompt: [
255
+ `This is a RESPONSE from @${msg.fromUsername}'s bot to a task you previously sent.`,
256
+ `Full message content:\n---\n${body}\n---`,
257
+ `DO NOT reply back to this bot via WeBot — the conversation is complete.`,
258
+ `Review the result and notify your owner using the "message" tool about the outcome.`,
259
+ `Summarize what was requested and what the result is.`,
260
+ ].join("\n"),
261
+ });
262
+ // No completion callback → no reply sent back → loop broken
263
+ break;
264
+ }
265
+
266
+ // ── TASK (or untagged): task request for this bot to process ──
267
+ // Process via agent and send tagged response back.
268
+ case "task":
269
+ default: {
270
+ logger.info(`WeBot: task request from ${msg.fromUsername}`);
271
+ // Use a brief trigger as the visible "user" message in session history;
272
+ // put the full content in extraSystemPrompt so the agent can process it
273
+ // without the full bot message appearing as a user message in Web UI.
274
+ const taskPreview = body.length > 60 ? `${body.slice(0, 60)}…` : body;
275
+ await callGatewayRpc(
276
+ "agent",
277
+ {
278
+ message: `[WeBot message from @${msg.fromUsername}]: ${taskPreview}`,
279
+ sessionKey,
280
+ label: sessionLabel,
281
+ idempotencyKey: crypto.randomUUID(),
282
+ extraSystemPrompt: [
283
+ `You are receiving a task message via WeBot from @${msg.fromUsername} (bot: "${msg.fromBotName}").`,
284
+ `Full message content:\n---\n${body}\n---`,
285
+ `Your reply will be automatically sent back to the sender's bot.`,
286
+ ``,
287
+ `CRITICAL — determine who the message is for:`,
288
+ `1. FOR YOUR OWNER (human): Messages that mention "my owner", "your owner", or are clearly social/personal`,
289
+ ` (e.g. dinner invitations, availability questions, plans, favors, personal greetings).`,
290
+ ` → You MUST use the "message" tool to forward this to your owner.`,
291
+ ` → Reply to the sender: "I've forwarded this to my owner, will get back to you."`,
292
+ `2. FOR YOU (the bot): Technical requests, information queries, tasks you can handle directly`,
293
+ ` (e.g. "search for X", "summarize Y", "what's the weather").`,
294
+ ` → Handle it directly and reply with the result.`,
295
+ `3. MIXED: Some messages need both — handle what you can and forward the rest to your owner.`,
296
+ ``,
297
+ `When in doubt about social/personal messages, ALWAYS forward to your owner.`,
298
+ `Keep responses concise and actionable.`,
299
+ ].join("\n"),
300
+ },
301
+ // Tag the reply as [webot:response] so receiving bot won't reply again
302
+ (frame: unknown) => {
303
+ forwardTaggedResponse(frame, fromDeviceId);
304
+ },
305
+ );
306
+ break;
307
+ }
308
+ }
309
+ } catch (err) {
310
+ logger.error(`WeBot: failed to forward to Gateway: ${String(err)}`);
311
+ // Send error feedback to the sender (tagged as response to avoid loop)
312
+ relay
313
+ .sendMessage(`${TAG_RESPONSE} [Error: ${String(err)}]`, { toDeviceId: fromDeviceId })
314
+ .catch(() => { /* best-effort feedback */ });
315
+ }
316
+ });
317
+
318
+ /**
319
+ * Extract agent reply text from the completion frame and send it back
320
+ * to the WeBot sender, prefixed with [webot:response] to prevent loops.
321
+ */
322
+ const forwardTaggedResponse = (frame: unknown, toDeviceId: string) => {
323
+ try {
324
+ const f = frame as {
325
+ payload?: {
326
+ status?: string;
327
+ error?: string;
328
+ result?: { payloads?: Array<{ text?: string }> };
329
+ };
330
+ };
331
+ if (f?.payload?.status === "ok" && f.payload.result?.payloads) {
332
+ const texts = f.payload.result.payloads
333
+ .map((p) => p.text)
334
+ .filter(Boolean);
335
+ const replyText = texts.join("\n").trim();
336
+ if (replyText) {
337
+ relay
338
+ .sendMessage(`${TAG_RESPONSE} ${replyText}`, { toDeviceId })
339
+ .then(() => {
340
+ logger.info(`WeBot: tagged response sent to ${toDeviceId.slice(0, 8)}...`);
341
+ })
342
+ .catch((err) => {
343
+ logger.error(`WeBot: failed to send response: ${String(err)}`);
344
+ });
345
+ } else {
346
+ logger.warn(`WeBot: agent returned empty reply for ${toDeviceId.slice(0, 8)}`);
347
+ }
348
+ } else if (f?.payload?.status === "error") {
349
+ const errorMsg = f.payload.error || "unknown error";
350
+ logger.warn(`WeBot: agent error for ${toDeviceId.slice(0, 8)}: ${errorMsg}`);
351
+ relay
352
+ .sendMessage(`${TAG_RESPONSE} [Task failed: ${errorMsg}]`, { toDeviceId })
353
+ .catch((err) => {
354
+ logger.error(`WeBot: failed to send error response: ${String(err)}`);
355
+ });
356
+ }
357
+ } catch (err) {
358
+ logger.error(`WeBot: reply forwarding error: ${String(err)}`);
359
+ }
360
+ };
361
+
362
+ // ── Listen for incoming friend requests ──
363
+ relay.onFriendRequest(async (frame: FriendRequestFrame) => {
364
+ logger.info(
365
+ `WeBot: friend request from ${frame.fromUsername} (${frame.fromDisplayName})`,
366
+ );
367
+
368
+ try {
369
+ await callGatewayRpc("agent", {
370
+ message: [
371
+ `[WeBot: New friend request]`,
372
+ "",
373
+ `You received a friend request from ${frame.fromDisplayName} (@${frame.fromUsername}).`,
374
+ `Friendship ID: ${frame.friendshipId}`,
375
+ "",
376
+ `Use the webot_friend_accept or webot_friend_reject tool to respond, or tell your owner.`,
377
+ ].join("\n"),
378
+ sessionKey: "agent:main:webot:system",
379
+ label: "WeBot: System",
380
+ idempotencyKey: crypto.randomUUID(),
381
+ });
382
+ logger.info("WeBot: agent notified of friend request");
383
+ } catch (err) {
384
+ logger.error(
385
+ `WeBot: failed to notify agent of friend request: ${String(err)}`,
386
+ );
387
+ }
388
+ });
389
+
390
+ return {
391
+ /** Start the gateway self-connection (call after gateway is ready) */
392
+ startGatewayConnection,
393
+
394
+ /**
395
+ * Send a notification message to the local agent via Gateway RPC.
396
+ * Best-effort — if the gateway is not connected, retries once after a delay.
397
+ */
398
+ notifyAgent: async (
399
+ message: string,
400
+ _metadata?: Record<string, unknown>,
401
+ ) => {
402
+ const attempt = async () => {
403
+ await callGatewayRpc("agent", {
404
+ message,
405
+ sessionKey: "agent:main:webot:system",
406
+ label: "WeBot: System",
407
+ idempotencyKey: crypto.randomUUID(),
408
+ });
409
+ };
410
+ try {
411
+ await attempt();
412
+ } catch {
413
+ // Gateway may not be ready yet — retry once after a short delay
414
+ await new Promise((r) => setTimeout(r, 3_000));
415
+ try {
416
+ await attempt();
417
+ } catch (err) {
418
+ logger.warn(
419
+ `WeBot: failed to notify agent: ${String(err)}`,
420
+ );
421
+ }
422
+ }
423
+ },
424
+
425
+ /**
426
+ * Hook handler for `message_sending` — kept for potential future use.
427
+ * Reply routing is handled via the agent RPC completion callback.
428
+ */
429
+ onMessageSending: async (
430
+ _event: { to?: string; content?: string; metadata?: Record<string, unknown> },
431
+ _ctx: unknown,
432
+ ) => {
433
+ // No-op: reply forwarding is handled by the completion callback
434
+ // in callGatewayRpc. The message_sending hook is currently not
435
+ // invoked by the gateway, so this is a no-op placeholder.
436
+ },
437
+
438
+ /** Stop the Gateway connection */
439
+ stop: () => {
440
+ gwStopping = true;
441
+ gwWs?.close();
442
+ gwPending.clear();
443
+ gwCompletionCallbacks.clear();
444
+ },
445
+ };
446
+ }