openclaw-mobile 1.0.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/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # openclaw-mobile
2
+
3
+ OpenClaw channel plugin for the Mobile app. Routes messages through a Cloudflare Worker relay.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Mobile App ←WS→ [CF Worker / Durable Object] ←WS→ [OpenClaw Plugin] → Gateway Pipeline
9
+ ```
10
+
11
+ 1. Plugin registers an `openclaw-mobile` channel with the Gateway
12
+ 2. Gateway manages the channel lifecycle (start, stop, health monitoring)
13
+ 3. The plugin connects outbound to the CF Worker relay via WebSocket
14
+ 4. User messages from the mobile app flow through the relay into the Gateway's inbound pipeline
15
+ 5. AI replies go through `outbound.sendText` — Gateway automatically filters security markers and thinking blocks
16
+ 6. Filtered replies are forwarded to the mobile app via the relay
17
+ 7. A 30-second ping keepalive prevents the Cloudflare Durable Object from hibernating and dropping the connection
18
+
19
+ ## Prerequisites
20
+
21
+ - OpenClaw Gateway installed and running (`npm install -g openclaw@latest`)
22
+ - A deployed Cloudflare Worker relay (see `../relay/`)
23
+ - Node.js 22+
24
+
25
+ ## Install
26
+
27
+ From npm (when published):
28
+
29
+ ```bash
30
+ openclaw plugins install openclaw-mobile
31
+ ```
32
+
33
+ For local development:
34
+
35
+ ```bash
36
+ openclaw plugins install -l ./plugin
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### 1. Deploy the relay
42
+
43
+ ```bash
44
+ cd relay
45
+ npm install
46
+ npx wrangler deploy
47
+ ```
48
+
49
+ Note the deployed URL (e.g. `wss://openclaw-relay.your-name.workers.dev`).
50
+
51
+ If you want relay authentication, set a secret:
52
+
53
+ ```bash
54
+ npx wrangler secret put RELAY_TOKEN
55
+ ```
56
+
57
+ ### 2. Install the plugin
58
+
59
+ From npm:
60
+
61
+ ```bash
62
+ openclaw plugins install openclaw-mobile
63
+ ```
64
+
65
+ For local development:
66
+
67
+ ```bash
68
+ openclaw plugins install -l ./plugin
69
+ cd plugin && npm install # installs qrcode-terminal for inline QR display
70
+ ```
71
+
72
+ > When installing from npm, `qrcode-terminal` is installed automatically as an optional dependency.
73
+
74
+ ### 3. Configure
75
+
76
+ Add to `~/.openclaw/openclaw.json`:
77
+
78
+ ```json
79
+ {
80
+ "channels": {
81
+ "openclaw-mobile": {
82
+ "accounts": {
83
+ "default": {
84
+ "enabled": true,
85
+ "relayUrl": "wss://openclaw-relay.your-name.workers.dev",
86
+ "relayToken": "your-secret-token",
87
+ "roomId": "default"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ > **Tip:** Leave `roomId` as `"default"` — the plugin will auto-generate a unique UUID on first start and save it to config automatically.
96
+
97
+ ### 4. Start the Gateway
98
+
99
+ ```bash
100
+ openclaw gateway --port 18789
101
+ ```
102
+
103
+ On startup, the plugin prints a pairing QR code to the terminal:
104
+
105
+ ```
106
+ ┌──────────────────────────────────────────────────┐
107
+ │ OpenClaw Mobile — Scan to connect (account: default)
108
+ ├──────────────────────────────────────────────────┤
109
+ │ ▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄ │
110
+ │ █ █ ██▄▄█ █ █ │
111
+ │ ... │
112
+ ├──────────────────────────────────────────────────┤
113
+ │ openclaw://relay?url=wss%3A%2F%2F...&room=... │
114
+ └──────────────────────────────────────────────────┘
115
+ ```
116
+
117
+ ### 5. Pair the mobile app
118
+
119
+ Open the **OpenClaw Mobile** app, tap **Scan QR Code**, and point the camera at the QR code printed in the terminal. The app will automatically connect to the relay using the correct room ID.
120
+
121
+ ### 6. Verify
122
+
123
+ Open the Control UI at http://127.0.0.1:18789/ and navigate to the **OpenClaw Mobile** channel page. You should see:
124
+
125
+ - **Running**: Yes
126
+ - **Configured**: Yes
127
+ - **Connected**: Yes
128
+
129
+ You can also check logs:
130
+
131
+ ```bash
132
+ tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
133
+ ```
134
+
135
+ ## Configuration
136
+
137
+ All config lives under `channels.openclaw-mobile.accounts.<accountId>` in `~/.openclaw/openclaw.json`.
138
+
139
+ | Field | Type | Required | Default | Description |
140
+ |-------|------|----------|---------|-------------|
141
+ | `enabled` | boolean | No | `true` | Enable/disable this account |
142
+ | `relayUrl` | string | **Yes** | — | CF Worker relay WebSocket URL |
143
+ | `relayToken` | string | No | `""` | Shared secret for relay authentication (must match the Worker's `RELAY_TOKEN` secret) |
144
+ | `roomId` | string | No | `"default"` | Room ID for relay routing (isolates conversations) |
145
+
146
+ You can also edit these fields directly in the Control UI config form.
147
+
148
+ ## Control UI
149
+
150
+ The plugin integrates with the OpenClaw Control UI:
151
+
152
+ - **Status panel** — shows Running, Configured, Connected, Last inbound, and error details
153
+ - **Config form** — editable fields for Relay URL, Relay Token, Room ID, and an Enable/Disable toggle
154
+ - **Enable/Disable** — the toggle in the UI writes `enabled: true/false` to the config and restarts the channel
155
+
156
+ ## How the relay works
157
+
158
+ The relay is a Cloudflare Worker with a Durable Object (`RelayRoom`):
159
+
160
+ - Each "room" is a named DO instance that bridges two WebSocket roles: `plugin` (the Gateway) and `app` (the mobile client)
161
+ - Messages from one role are forwarded to all peers of the opposite role
162
+ - The DO uses `setWebSocketAutoResponse("ping", "pong")` for keepalive without waking from hibernation
163
+ - The plugin sends a `ping` every 30 seconds to prevent idle disconnection
164
+ - Optional `RELAY_TOKEN` secret gates access to the relay
165
+
166
+ ## Message Protocol
167
+
168
+ ### App → Plugin (via relay)
169
+
170
+ ```json
171
+ {
172
+ "type": "message",
173
+ "content": "Hello",
174
+ "sessionKey": "relay-session",
175
+ "senderId": "mobile-user",
176
+ "senderName": "Mobile User"
177
+ }
178
+ ```
179
+
180
+ - `sessionKey` — the app's local session identifier; the plugin echoes it back in replies so the app can match them
181
+ - `senderId` / `senderName` — optional; defaults to `"mobile-user"` / `"Mobile User"`
182
+
183
+ ### Plugin → App (via relay)
184
+
185
+ ```json
186
+ {
187
+ "type": "message",
188
+ "role": "assistant",
189
+ "content": "AI reply text",
190
+ "sessionKey": "relay-session"
191
+ }
192
+ ```
193
+
194
+ The plugin echoes back the exact `sessionKey` it received from the app, so the Flutter client can route the reply to the correct chat session.
195
+
196
+ ## Troubleshooting
197
+
198
+ | Problem | Cause | Fix |
199
+ |---------|-------|-----|
200
+ | "Channel config schema unavailable" in Control UI | Gateway loaded before plugin was installed | Restart the Gateway: `openclaw gateway stop && openclaw gateway` |
201
+ | Running: No | `startAccount` returned early (old plugin version) | Update the plugin and restart Gateway |
202
+ | Connected: No | Relay URL wrong or Worker not deployed | Check `relayUrl` in config; verify Worker is live with `curl https://your-relay.workers.dev/health` |
203
+ | Relay keeps disconnecting | Ping keepalive not working or network issue | Check logs for "WebSocket error"; ensure relay Worker is deployed with Durable Objects enabled |
204
+ | "relayUrl not configured" in logs | Missing `relayUrl` in account config | Add `relayUrl` under `channels.openclaw-mobile.accounts.default` |
205
+ | Enable toggle in UI doesn't match JSON | Missing `setAccountEnabled` adapter (old plugin version) | Update the plugin and restart Gateway |
206
+ | `relayToken` mismatch | Token in config doesn't match Worker secret | Ensure `relayToken` matches the `RELAY_TOKEN` secret set on the Worker |
207
+ | App stuck loading, no reply shown | sessionKey mismatch between app and plugin | Ensure app sends `sessionKey` in message payload and plugin version ≥ current |
208
+
209
+ ### Useful commands
210
+
211
+ ```bash
212
+ # Check plugin is loaded
213
+ openclaw plugins list
214
+
215
+ # Check channel status
216
+ openclaw channels status --probe
217
+
218
+ # Live logs
219
+ tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
220
+
221
+ # Verify relay is reachable
222
+ curl https://openclaw-relay.your-name.workers.dev/health
223
+ ```
package/index.ts ADDED
@@ -0,0 +1,837 @@
1
+ /**
2
+ * OpenClaw Mobile Channel Plugin
3
+ *
4
+ * Registers "openclaw-mobile" channel that bridges Gateway <-> CF Worker relay <-> Mobile App.
5
+ * Plugin runs inside the Gateway process, connects outbound to the relay.
6
+ *
7
+ * On first start, if roomId is "default", a UUID is auto-generated and saved to config.
8
+ * A QR code is printed to the terminal so the mobile app can scan and connect.
9
+ *
10
+ * Config (in ~/.openclaw/openclaw.json):
11
+ * {
12
+ * "channels": {
13
+ * "openclaw-mobile": {
14
+ * "accounts": {
15
+ * "default": {
16
+ * "enabled": true,
17
+ * "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
18
+ * "relayToken": "your-secret",
19
+ * "roomId": "auto-generated-uuid"
20
+ * }
21
+ * }
22
+ * }
23
+ * }
24
+ * }
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+
29
+ // ── Plugin runtime (set during register) ────────────────────────────────────
30
+
31
+ let pluginRuntime: any = null;
32
+
33
+ // ── E2E Encryption (X25519 ECDH + AES-256-GCM) ──────────────────────────────
34
+ // Uses Web Crypto API. Node 18+ uses standalone "X25519" algorithm name,
35
+ // while browsers use { name: "ECDH", namedCurve: "X25519" }.
36
+
37
+ // Runtime detection — resolved once on first use
38
+ let _x25519Algo: { gen: any; imp: any; derive: string } | null = null;
39
+ async function getX25519Algo() {
40
+ if (_x25519Algo) return _x25519Algo;
41
+ try {
42
+ await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "X25519" } as any, true, ["deriveKey"]);
43
+ _x25519Algo = { gen: { name: "ECDH", namedCurve: "X25519" }, imp: { name: "ECDH", namedCurve: "X25519" }, derive: "ECDH" };
44
+ } catch {
45
+ _x25519Algo = { gen: { name: "X25519" }, imp: { name: "X25519" }, derive: "X25519" };
46
+ }
47
+ return _x25519Algo;
48
+ }
49
+
50
+ interface E2EState {
51
+ localKeyPair: CryptoKeyPair | null;
52
+ sharedKey: CryptoKey | null;
53
+ ready: boolean;
54
+ }
55
+
56
+ function makeE2EState(): E2EState {
57
+ return { localKeyPair: null, sharedKey: null, ready: false };
58
+ }
59
+
60
+ async function e2eInit(state: E2EState): Promise<string> {
61
+ const algo = await getX25519Algo();
62
+ state.localKeyPair = await crypto.subtle.generateKey(
63
+ algo.gen,
64
+ true,
65
+ ["deriveKey"]
66
+ );
67
+ const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
68
+ const pubKeyB64 = bufToBase64Url(pubKeyRaw);
69
+ return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
70
+ }
71
+
72
+ async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
73
+ if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
74
+ const algo = await getX25519Algo();
75
+
76
+ const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
77
+ const peerPublicKey = await crypto.subtle.importKey(
78
+ "raw",
79
+ peerPubKeyBytes.buffer as ArrayBuffer,
80
+ algo.imp,
81
+ false,
82
+ []
83
+ );
84
+
85
+ const ecdhRawKey = await crypto.subtle.deriveKey(
86
+ { name: algo.derive, public: peerPublicKey },
87
+ state.localKeyPair.privateKey,
88
+ { name: "AES-GCM", length: 256 },
89
+ true,
90
+ ["encrypt", "decrypt"]
91
+ );
92
+ const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
93
+
94
+ // HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
95
+ const hkdfKey = await crypto.subtle.importKey(
96
+ "raw", ecdhRawBytes as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]
97
+ );
98
+ state.sharedKey = await crypto.subtle.deriveKey(
99
+ {
100
+ name: "HKDF",
101
+ hash: "SHA-256",
102
+ salt: new Uint8Array(0),
103
+ info: new TextEncoder().encode("openclaw-e2e-v1"),
104
+ },
105
+ hkdfKey,
106
+ { name: "AES-GCM", length: 256 },
107
+ false,
108
+ ["encrypt", "decrypt"]
109
+ );
110
+
111
+ state.ready = true;
112
+ }
113
+
114
+ async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
115
+ if (!state.sharedKey) throw new Error("[E2E] Not ready");
116
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
117
+ const ct = await crypto.subtle.encrypt(
118
+ { name: "AES-GCM", iv: nonce },
119
+ state.sharedKey,
120
+ new TextEncoder().encode(plaintext)
121
+ );
122
+ return JSON.stringify({
123
+ type: "encrypted",
124
+ nonce: bufToBase64Url(nonce),
125
+ ct: bufToBase64Url(ct),
126
+ });
127
+ }
128
+
129
+ async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Promise<string> {
130
+ if (!state.sharedKey) throw new Error("[E2E] Not ready");
131
+ const nonce = base64UrlToBuf(nonceB64);
132
+ const ct = base64UrlToBuf(ctB64);
133
+ const plain = await crypto.subtle.decrypt(
134
+ { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer },
135
+ state.sharedKey,
136
+ ct.buffer as ArrayBuffer
137
+ );
138
+ return new TextDecoder().decode(plain);
139
+ }
140
+
141
+ function bufToBase64Url(buf: ArrayBuffer | Uint8Array): string {
142
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
143
+ let binary = "";
144
+ for (const b of bytes) binary += String.fromCharCode(b);
145
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
146
+ }
147
+
148
+ function base64UrlToBuf(b64: string): Uint8Array {
149
+ const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
150
+ const pad = (4 - padded.length % 4) % 4;
151
+ const binary = atob(padded + "=".repeat(pad));
152
+ const bytes = new Uint8Array(binary.length);
153
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
154
+ return bytes;
155
+ }
156
+
157
+ // ── Relay state (per account) ────────────────────────────────────────────────
158
+
159
+ interface RelayState {
160
+ ws: WebSocket | null;
161
+ reconnectTimer: ReturnType<typeof setTimeout> | null;
162
+ pingTimer: ReturnType<typeof setInterval> | null;
163
+ statusSink: ((patch: Record<string, unknown>) => void) | null;
164
+ gatewayCtx: any;
165
+ /** Last sessionKey received from the app — echoed back in replies */
166
+ appSessionKey: string | null;
167
+ /** E2E encryption state for this account */
168
+ e2e: E2EState;
169
+ }
170
+
171
+ const relayStates = new Map<string, RelayState>();
172
+
173
+ const RECONNECT_DELAY = 5000;
174
+ const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
175
+ const DEFAULT_ACCOUNT_ID = "default";
176
+ const CHANNEL_ID = "openclaw-mobile";
177
+
178
+ function getRelayState(accountId: string): RelayState {
179
+ let state = relayStates.get(accountId);
180
+ if (!state) {
181
+ state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, appSessionKey: null, e2e: makeE2EState() };
182
+ relayStates.set(accountId, state);
183
+ }
184
+ return state;
185
+ }
186
+
187
+ // ── Account resolution ───────────────────────────────────────────────────────
188
+
189
+ interface ResolvedAccount {
190
+ accountId: string;
191
+ enabled: boolean;
192
+ relayUrl: string;
193
+ relayToken: string;
194
+ roomId: string;
195
+ configured: boolean;
196
+ }
197
+
198
+ function listAccountIds(cfg: any): string[] {
199
+ return Object.keys(
200
+ cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
201
+ );
202
+ }
203
+
204
+ function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
205
+ const id = accountId ?? DEFAULT_ACCOUNT_ID;
206
+ const acct =
207
+ cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
208
+ return {
209
+ accountId: id,
210
+ enabled: acct.enabled !== false,
211
+ relayUrl: acct.relayUrl ?? "",
212
+ relayToken: acct.relayToken ?? "",
213
+ roomId: acct.roomId ?? "default",
214
+ configured: Boolean(acct.relayUrl),
215
+ };
216
+ }
217
+
218
+ // ── Channel config schema (JSON Schema for Control UI) ───────────────────────
219
+
220
+ const mobileConfigSchema = {
221
+ schema: {
222
+ $schema: "http://json-schema.org/draft-07/schema#",
223
+ type: "object" as const,
224
+ properties: {
225
+ accounts: {
226
+ type: "object" as const,
227
+ additionalProperties: {
228
+ type: "object" as const,
229
+ properties: {
230
+ enabled: { type: "boolean" as const },
231
+ relayUrl: {
232
+ type: "string" as const,
233
+ description: "CF Worker relay WebSocket URL",
234
+ },
235
+ relayToken: {
236
+ type: "string" as const,
237
+ description: "Shared secret for relay authentication",
238
+ },
239
+ roomId: {
240
+ type: "string" as const,
241
+ description: "Room ID for relay routing",
242
+ },
243
+ },
244
+ additionalProperties: false,
245
+ },
246
+ },
247
+ },
248
+ additionalProperties: false,
249
+ },
250
+ uiHints: {
251
+ relayUrl: {
252
+ label: "Relay URL",
253
+ placeholder: "wss://openclaw-relay.xxx.workers.dev",
254
+ },
255
+ relayToken: {
256
+ label: "Relay Token",
257
+ sensitive: true,
258
+ },
259
+ roomId: {
260
+ label: "Room ID",
261
+ placeholder: "default",
262
+ },
263
+ },
264
+ };
265
+
266
+ // ── QR Code pairing helper ───────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Build the openclaw://relay URI that the mobile app scans to connect.
270
+ */
271
+ function buildPairingUri(relayUrl: string, roomId: string, relayToken?: string): string {
272
+ const params = new URLSearchParams({ url: relayUrl, room: roomId });
273
+ if (relayToken) params.set("token", relayToken);
274
+ return `openclaw://relay?${params.toString()}`;
275
+ }
276
+
277
+ /**
278
+ * Print a QR code + pairing URI to stdout.
279
+ * Uses qrcode-terminal if available; falls back to plain text.
280
+ */
281
+ async function printPairingQr(uri: string, accountId: string): Promise<void> {
282
+ const border = "─".repeat(50);
283
+ console.log(`\n┌${border}┐`);
284
+ console.log(`│ OpenClaw Mobile — Scan to connect (account: ${accountId.padEnd(Math.max(0, 50 - 38))} │`);
285
+ console.log(`├${border}┤`);
286
+
287
+ try {
288
+ // Dynamically import qrcode-terminal (optional dependency)
289
+ const mod = await import("qrcode-terminal");
290
+ const qr: any = mod.default ?? mod;
291
+ await new Promise<void>((resolve) => {
292
+ qr.generate(uri, { small: true }, (qrStr: string) => {
293
+ const lines = qrStr.split("\n");
294
+ for (const line of lines) {
295
+ console.log(`│ ${line}`);
296
+ }
297
+ resolve();
298
+ });
299
+ });
300
+ } catch {
301
+ // qrcode-terminal not installed — print plain URI
302
+ console.log(`│`);
303
+ console.log(`│ (Install qrcode-terminal for inline QR display)`);
304
+ console.log(`│`);
305
+ }
306
+
307
+ console.log(`├${border}┤`);
308
+ console.log(`│ ${uri.slice(0, 48).padEnd(48)} │`);
309
+ if (uri.length > 48) {
310
+ console.log(`│ ${uri.slice(48, 96).padEnd(48)} │`);
311
+ }
312
+ console.log(`└${border}┘\n`);
313
+ }
314
+
315
+ // ── Channel plugin ───────────────────────────────────────────────────────────
316
+
317
+ const channel = {
318
+ id: CHANNEL_ID,
319
+ meta: {
320
+ id: CHANNEL_ID,
321
+ label: "OpenClaw Mobile",
322
+ selectionLabel: "OpenClaw Mobile App",
323
+ blurb: "Chat via the OpenClaw Mobile app through a relay.",
324
+ detailLabel: "Mobile App",
325
+ aliases: ["mobile"],
326
+ },
327
+ capabilities: {
328
+ chatTypes: ["direct"],
329
+ },
330
+ reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
331
+ configSchema: mobileConfigSchema,
332
+ config: {
333
+ listAccountIds: (cfg: any) => listAccountIds(cfg),
334
+ resolveAccount: (cfg: any, accountId?: string) =>
335
+ resolveAccount(cfg, accountId),
336
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
337
+ setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
338
+ const key = accountId || DEFAULT_ACCOUNT_ID;
339
+ const base = cfg.channels?.[CHANNEL_ID] ?? {};
340
+ const accounts = base.accounts ?? {};
341
+ return {
342
+ ...cfg,
343
+ channels: {
344
+ ...cfg.channels,
345
+ [CHANNEL_ID]: {
346
+ ...base,
347
+ accounts: {
348
+ ...accounts,
349
+ [key]: {
350
+ ...accounts[key],
351
+ enabled,
352
+ },
353
+ },
354
+ },
355
+ },
356
+ };
357
+ },
358
+ isConfigured: (account: ResolvedAccount) => account.configured,
359
+ describeAccount: (account: ResolvedAccount) => ({
360
+ accountId: account.accountId,
361
+ enabled: account.enabled,
362
+ configured: account.configured,
363
+ relayUrl: account.relayUrl || "(not set)",
364
+ }),
365
+ },
366
+ outbound: {
367
+ deliveryMode: "direct" as const,
368
+
369
+ sendText: async ({ text, to, accountId, session }: any) => {
370
+ const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
371
+ const state = getRelayState(aid);
372
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
373
+ return { ok: false, error: "relay not connected" };
374
+ }
375
+
376
+ const runtime = pluginRuntime;
377
+ let outText = text ?? "";
378
+ if (runtime) {
379
+ const cfg = runtime.config.loadConfig();
380
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
381
+ cfg,
382
+ channel: CHANNEL_ID,
383
+ accountId: aid,
384
+ });
385
+ outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
386
+ }
387
+
388
+ const plainMsg = JSON.stringify({
389
+ type: "message",
390
+ role: "assistant",
391
+ content: outText,
392
+ sessionKey: session?.key,
393
+ });
394
+ const outMsg = state.e2e.ready
395
+ ? await e2eEncrypt(state.e2e, plainMsg)
396
+ : plainMsg;
397
+ state.ws.send(outMsg);
398
+
399
+ return {
400
+ channel: CHANNEL_ID,
401
+ to: to ?? "mobile-user",
402
+ messageId: `mobile-${Date.now()}`,
403
+ };
404
+ },
405
+ },
406
+
407
+ status: {
408
+ defaultRuntime: {
409
+ accountId: DEFAULT_ACCOUNT_ID,
410
+ running: false,
411
+ connected: false,
412
+ lastConnectedAt: null,
413
+ lastDisconnect: null,
414
+ lastStartAt: null,
415
+ lastStopAt: null,
416
+ lastError: null,
417
+ lastInboundAt: null,
418
+ },
419
+ buildChannelSummary: ({ snapshot }: any) => ({
420
+ configured: snapshot.configured ?? false,
421
+ running: snapshot.running ?? false,
422
+ connected: snapshot.connected ?? false,
423
+ lastInboundAt: snapshot.lastInboundAt ?? null,
424
+ }),
425
+ buildAccountSnapshot: ({ account, runtime }: any) => ({
426
+ accountId: account.accountId,
427
+ enabled: account.enabled,
428
+ configured: account.configured,
429
+ relayUrl: account.relayUrl || "(not set)",
430
+ running: runtime?.running ?? false,
431
+ connected: runtime?.connected ?? false,
432
+ lastStartAt: runtime?.lastStartAt ?? null,
433
+ lastStopAt: runtime?.lastStopAt ?? null,
434
+ lastError: runtime?.lastError ?? null,
435
+ lastInboundAt: runtime?.lastInboundAt ?? null,
436
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
437
+ }),
438
+ },
439
+
440
+ gateway: {
441
+ startAccount: async (ctx: any) => {
442
+ let account: ResolvedAccount = ctx.account;
443
+ const accountId = account.accountId;
444
+ const state = getRelayState(accountId);
445
+
446
+ cleanupRelay(state);
447
+
448
+ state.gatewayCtx = ctx;
449
+ state.statusSink = (patch: Record<string, unknown>) =>
450
+ ctx.setStatus({ accountId, ...patch });
451
+
452
+ ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
453
+
454
+ if (!account.configured) {
455
+ const msg = "relayUrl not configured";
456
+ ctx.setStatus({ accountId, lastError: msg });
457
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
458
+ return;
459
+ }
460
+
461
+ // Auto-generate a UUID room ID if still using the placeholder "default"
462
+ if (account.roomId === "default" || !account.roomId) {
463
+ const newRoomId = randomUUID();
464
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Auto-generating room ID: ${newRoomId}`);
465
+ try {
466
+ await ctx.updateAccountConfig?.({ accountId, patch: { roomId: newRoomId } });
467
+ account = { ...account, roomId: newRoomId };
468
+ } catch {
469
+ // updateAccountConfig not available in this Gateway version — continue with generated ID
470
+ account = { ...account, roomId: newRoomId };
471
+ }
472
+ }
473
+
474
+ // Print pairing QR code to terminal
475
+ const pairingUri = buildPairingUri(account.relayUrl, account.roomId, account.relayToken || undefined);
476
+ printPairingQr(pairingUri, accountId).catch(() => {});
477
+
478
+ connectRelay(ctx, account);
479
+
480
+ const signal: AbortSignal | undefined = ctx.abortSignal;
481
+ if (signal) {
482
+ await new Promise<void>((resolve) => {
483
+ if (signal.aborted) { resolve(); return; }
484
+ signal.addEventListener("abort", () => resolve(), { once: true });
485
+ });
486
+ cleanupRelay(state);
487
+ ctx.setStatus({ accountId, running: false, connected: false });
488
+ }
489
+ },
490
+ stopAccount: async (ctx: any) => {
491
+ const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
492
+ const state = getRelayState(accountId);
493
+ cleanupRelay(state);
494
+
495
+ ctx.setStatus?.({
496
+ accountId,
497
+ running: false,
498
+ connected: false,
499
+ lastStopAt: new Date().toISOString(),
500
+ });
501
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
502
+ },
503
+ },
504
+ };
505
+
506
+ // ── Relay bridge ─────────────────────────────────────────────────────────────
507
+
508
+ function cleanupRelay(state: RelayState) {
509
+ if (state.pingTimer) {
510
+ clearInterval(state.pingTimer);
511
+ state.pingTimer = null;
512
+ }
513
+ if (state.reconnectTimer) {
514
+ clearTimeout(state.reconnectTimer);
515
+ state.reconnectTimer = null;
516
+ }
517
+ if (state.ws) {
518
+ try { state.ws.close(); } catch {}
519
+ state.ws = null;
520
+ }
521
+ // Reset E2E state on disconnect — new handshake needed on reconnect
522
+ state.e2e = makeE2EState();
523
+ }
524
+
525
+ function connectRelay(ctx: any, account: ResolvedAccount) {
526
+ const accountId = account.accountId;
527
+ const state = getRelayState(accountId);
528
+
529
+ const base = account.relayUrl.replace(/\/$/, "");
530
+ const params = new URLSearchParams({
531
+ role: "plugin",
532
+ room: account.roomId,
533
+ });
534
+ if (account.relayToken) params.set("token", account.relayToken);
535
+
536
+ const url = `${base}/ws?${params.toString()}`;
537
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Connecting to relay: ${url}`);
538
+
539
+ try {
540
+ state.ws = new WebSocket(url);
541
+ } catch (err) {
542
+ const msg = `WebSocket create failed: ${err}`;
543
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
544
+ state.statusSink?.({ lastError: msg });
545
+ scheduleReconnect(ctx, account);
546
+ return;
547
+ }
548
+
549
+ state.ws.addEventListener("open", () => {
550
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
551
+ if (state.reconnectTimer) {
552
+ clearTimeout(state.reconnectTimer);
553
+ state.reconnectTimer = null;
554
+ }
555
+ if (state.pingTimer) clearInterval(state.pingTimer);
556
+ state.pingTimer = setInterval(() => {
557
+ if (state.ws?.readyState === WebSocket.OPEN) {
558
+ state.ws.send("ping");
559
+ }
560
+ }, PING_INTERVAL);
561
+
562
+ state.statusSink?.({
563
+ connected: true,
564
+ lastConnectedAt: new Date().toISOString(),
565
+ lastError: null,
566
+ });
567
+
568
+ // Initiate E2E handshake
569
+ state.e2e = makeE2EState();
570
+ e2eInit(state.e2e).then((handshakeMsg) => {
571
+ if (state.ws?.readyState === WebSocket.OPEN) {
572
+ state.ws.send(handshakeMsg);
573
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake sent`);
574
+ }
575
+ }).catch((err) => {
576
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Init failed: ${err}`);
577
+ });
578
+ });
579
+
580
+ state.ws.addEventListener("message", (event: MessageEvent) => {
581
+ handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
582
+ ctx.log?.error?.(
583
+ `[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
584
+ );
585
+ });
586
+ });
587
+
588
+ state.ws.addEventListener("close", () => {
589
+ ctx.log?.info?.(
590
+ `[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
591
+ );
592
+ state.ws = null;
593
+ if (state.pingTimer) {
594
+ clearInterval(state.pingTimer);
595
+ state.pingTimer = null;
596
+ }
597
+ state.statusSink?.({
598
+ connected: false,
599
+ lastDisconnect: new Date().toISOString(),
600
+ });
601
+ scheduleReconnect(ctx, account);
602
+ });
603
+
604
+ state.ws.addEventListener("error", () => {
605
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
606
+ state.statusSink?.({ lastError: "WebSocket error" });
607
+ });
608
+ }
609
+
610
+ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
611
+ const state = getRelayState(account.accountId);
612
+ if (state.reconnectTimer) return;
613
+ state.reconnectTimer = setTimeout(() => {
614
+ state.reconnectTimer = null;
615
+ connectRelay(ctx, account);
616
+ }, RECONNECT_DELAY);
617
+ }
618
+
619
+ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
620
+ // Skip ping/pong
621
+ if (raw === "ping" || raw === "pong") return;
622
+
623
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
624
+
625
+ const msg = JSON.parse(raw);
626
+
627
+ // App just joined the room — (re-)send our handshake so it can complete E2E
628
+ if (msg.type === "peer_joined") {
629
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Peer joined (role=${msg.role}), re-sending handshake`);
630
+ state.e2e = makeE2EState();
631
+ const handshakeMsg = await e2eInit(state.e2e);
632
+ if (state.ws?.readyState === WebSocket.OPEN) {
633
+ state.ws.send(handshakeMsg);
634
+ }
635
+ return;
636
+ }
637
+
638
+ // E2E handshake from app
639
+ if (msg.type === "handshake") {
640
+ const peerPubKey = msg.pubkey as string | undefined;
641
+ if (!peerPubKey) {
642
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
643
+ return;
644
+ }
645
+ await e2eHandleHandshake(state.e2e, peerPubKey);
646
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake complete — channel encrypted`);
647
+ return;
648
+ }
649
+
650
+ // E2E encrypted message — decrypt then process
651
+ if (msg.type === "encrypted") {
652
+ if (!state.e2e.ready) {
653
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Received encrypted msg but not ready, dropping`);
654
+ return;
655
+ }
656
+ const plaintext = await e2eDecrypt(state.e2e, msg.nonce, msg.ct);
657
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted: ${plaintext.slice(0, 200)}`);
658
+ const innerMsg = JSON.parse(plaintext);
659
+ await handleInbound(ctx, accountId, innerMsg);
660
+ return;
661
+ }
662
+
663
+ // Plaintext message (fallback during handshake or if E2E disabled)
664
+ await handleInbound(ctx, accountId, msg);
665
+ }
666
+
667
+ async function handleInbound(ctx: any, accountId: string, msg: any) {
668
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleInbound: type=${msg.type} content=${String(msg.content ?? "").slice(0, 100)}`);
669
+
670
+ if (msg.type !== "message" || !msg.content) {
671
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Skipping non-message: type=${msg.type}`);
672
+ return;
673
+ }
674
+
675
+ const state = getRelayState(accountId);
676
+ state.statusSink?.({ lastInboundAt: new Date().toISOString() });
677
+
678
+ // Remember the sessionKey the app sent so we can echo it back in the reply
679
+ if (msg.sessionKey) {
680
+ state.appSessionKey = String(msg.sessionKey);
681
+ }
682
+
683
+ const runtime = pluginRuntime;
684
+ if (!runtime) {
685
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
686
+ return;
687
+ }
688
+
689
+ const senderId = msg.senderId ?? "mobile-user";
690
+ const senderName = msg.senderName ?? "Mobile User";
691
+ const text = String(msg.content);
692
+
693
+ try {
694
+ const cfg = runtime.config.loadConfig();
695
+
696
+ const route = runtime.channel.routing.resolveAgentRoute({
697
+ cfg,
698
+ channel: CHANNEL_ID,
699
+ accountId,
700
+ peer: { kind: "direct", id: senderId },
701
+ });
702
+
703
+ // If the app specified a sessionKey (UUID-based), derive an isolated
704
+ // Gateway session so each mobile conversation has its own context.
705
+ // Format: "agent:main:mobile-<uuid>"
706
+ // No appSessionKey → use the route's mainSessionKey (default conversation).
707
+ let sessionKey: string;
708
+ const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
709
+ if (appSessionKey) {
710
+ // Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
711
+ const mainKey = route.mainSessionKey as string;
712
+ const colonIdx = mainKey.lastIndexOf(':');
713
+ const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
714
+ sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
715
+ } else {
716
+ sessionKey = route.mainSessionKey;
717
+ }
718
+ const from = `${CHANNEL_ID}:${senderId}`;
719
+ const to = `user:${senderId}`;
720
+
721
+ runtime.channel.activity.record({
722
+ channel: CHANNEL_ID,
723
+ accountId,
724
+ direction: "inbound",
725
+ });
726
+
727
+ runtime.system.enqueueSystemEvent(
728
+ `Mobile message from ${senderName}: ${text.slice(0, 160)}`,
729
+ { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
730
+ );
731
+
732
+ const body = runtime.channel.reply.formatInboundEnvelope({
733
+ channel: "OpenClaw Mobile",
734
+ from: `${senderName} (mobile)`,
735
+ body: text,
736
+ chatType: "direct",
737
+ sender: { name: senderName, id: senderId },
738
+ });
739
+
740
+ const ctxPayload = runtime.channel.reply.finalizeInboundContext({
741
+ Body: body,
742
+ BodyForAgent: text,
743
+ RawBody: text,
744
+ CommandBody: text,
745
+ From: from,
746
+ To: to,
747
+ SessionKey: sessionKey,
748
+ AccountId: accountId,
749
+ ChatType: "direct",
750
+ ConversationLabel: `Mobile DM from ${senderName}`,
751
+ SenderName: senderName,
752
+ SenderId: senderId,
753
+ Provider: CHANNEL_ID,
754
+ Surface: CHANNEL_ID,
755
+ OriginatingChannel: CHANNEL_ID,
756
+ OriginatingTo: to,
757
+ });
758
+
759
+ const textLimit = runtime.channel.text.resolveTextChunkLimit(
760
+ cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
761
+ );
762
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
763
+ cfg, channel: CHANNEL_ID, accountId,
764
+ });
765
+
766
+ const { dispatcher, replyOptions, markDispatchIdle } =
767
+ runtime.channel.reply.createReplyDispatcherWithTyping({
768
+ humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
769
+ deliver: async (payload: any) => {
770
+ const relayState = getRelayState(accountId);
771
+ if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
772
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
773
+ return;
774
+ }
775
+ const replyText = runtime.channel.text.convertMarkdownTables(
776
+ payload.text ?? "", tableMode,
777
+ );
778
+ const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
779
+ const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
780
+ // Echo back the sessionKey the app sent (so the app can match the reply)
781
+ const replySessionKey = relayState.appSessionKey ?? sessionKey;
782
+ for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
783
+ if (!chunk) continue;
784
+ const plainMsg = JSON.stringify({
785
+ type: "message",
786
+ role: "assistant",
787
+ content: chunk,
788
+ sessionKey: replySessionKey,
789
+ });
790
+ // Encrypt if E2E handshake is complete
791
+ const outMsg = relayState.e2e.ready
792
+ ? await e2eEncrypt(relayState.e2e, plainMsg)
793
+ : plainMsg;
794
+ relayState.ws.send(outMsg);
795
+ }
796
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
797
+ },
798
+ onError: (err: any, info: any) => {
799
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
800
+ },
801
+ });
802
+
803
+ await runtime.channel.reply.dispatchReplyFromConfig({
804
+ ctx: ctxPayload,
805
+ cfg,
806
+ dispatcher,
807
+ replyOptions,
808
+ });
809
+ markDispatchIdle();
810
+
811
+ const sessionCfg = cfg.session;
812
+ const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
813
+ agentId: route.agentId,
814
+ });
815
+ await runtime.channel.session.updateLastRoute({
816
+ storePath,
817
+ sessionKey,
818
+ deliveryContext: {
819
+ channel: CHANNEL_ID,
820
+ to,
821
+ accountId,
822
+ },
823
+ });
824
+
825
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
826
+ } catch (e) {
827
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
828
+ }
829
+ }
830
+
831
+ // ── Plugin entry ─────────────────────────────────────────────────────────────
832
+
833
+ export default function register(api: any) {
834
+ pluginRuntime = api.runtime;
835
+ api.registerChannel({ plugin: channel });
836
+ api.logger?.info?.("[openclaw-mobile] Plugin registered");
837
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "id": "openclaw-mobile",
3
+ "name": "OpenClaw Mobile",
4
+ "version": "1.0.0",
5
+ "description": "Mobile app channel for OpenClaw — chat via the OpenClaw Mobile app through a Cloudflare Worker relay.",
6
+ "channels": [
7
+ "openclaw-mobile"
8
+ ],
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "relayUrl": {
14
+ "type": "string",
15
+ "description": "CF Worker relay WebSocket URL (e.g. wss://openclaw-relay.xxx.workers.dev)"
16
+ },
17
+ "relayToken": {
18
+ "type": "string",
19
+ "description": "Shared secret for relay authentication"
20
+ },
21
+ "roomId": {
22
+ "type": "string",
23
+ "description": "Room ID for relay routing",
24
+ "default": "default"
25
+ }
26
+ }
27
+ },
28
+ "uiHints": {
29
+ "relayUrl": {
30
+ "label": "Relay URL",
31
+ "placeholder": "wss://openclaw-relay.xxx.workers.dev"
32
+ },
33
+ "relayToken": {
34
+ "label": "Relay Token",
35
+ "sensitive": true
36
+ },
37
+ "roomId": {
38
+ "label": "Room ID",
39
+ "placeholder": "default"
40
+ }
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "openclaw-mobile",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "files": [
8
+ "index.ts",
9
+ "openclaw.plugin.json",
10
+ "README.md"
11
+ ],
12
+ "openclaw": {
13
+ "plugin": "./openclaw.plugin.json",
14
+ "extensions": ["./index.ts"]
15
+ },
16
+ "keywords": ["openclaw", "plugin", "channel", "mobile"],
17
+ "license": "MIT",
18
+ "optionalDependencies": {
19
+ "qrcode-terminal": "^0.12.0"
20
+ },
21
+ "devDependencies": {
22
+ "typescript": "^5.7.0"
23
+ },
24
+ "peerDependencies": {
25
+ "openclaw": "*"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "openclaw": {
29
+ "optional": true
30
+ }
31
+ }
32
+ }