openclaw-mobile 1.0.2 → 1.0.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- # openclaw-mobile
1
+ # @openclaw/mobile
2
2
 
3
3
  OpenClaw channel plugin for the Mobile app. Routes messages through a Cloudflare Worker relay.
4
4
 
@@ -8,66 +8,61 @@ OpenClaw channel plugin for the Mobile app. Routes messages through a Cloudflare
8
8
  Mobile App ←WS→ [CF Worker / Durable Object] ←WS→ [OpenClaw Plugin] → Gateway Pipeline
9
9
  ```
10
10
 
11
- 1. The plugin registers an `openclaw-mobile` channel with the Gateway and connects to the relay as `role=plugin`.
12
- 2. Each mobile app session connects to the relay as `role=app&session=<uuid>` with its own UUID.
13
- 3. **App Plugin**: the relay stamps every inbound message with the sender's `sessionKey`, then forwards to the plugin.
14
- 4. **Plugin App**: the plugin echoes `sessionKey` in every reply; the relay delivers it only to the matching app session.
15
- 5. Multiple users can share the same relay without message cross-talk isolation is per `sessionKey`, not per room.
16
- 6. AI replies go through `outbound.sendText` Gateway automatically filters security markers and thinking blocks.
17
- 7. A 30-second ping keepalive prevents the Cloudflare Durable Object from hibernating.
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
18
 
19
19
  ## Prerequisites
20
20
 
21
21
  - OpenClaw Gateway installed and running (`npm install -g openclaw@latest`)
22
+ - A deployed Cloudflare Worker relay (see `../relay/`)
22
23
  - Node.js 22+
23
24
 
24
25
  ## Install
25
26
 
26
- ```bash
27
- openclaw plugins install openclaw-mobile
28
- ```
29
-
30
- That's it. The plugin ships with a built-in public relay — **no configuration required to get started**.
31
-
32
- ## Quick Start
33
-
34
- ### 1. Install the plugin
27
+ From npm (when published):
35
28
 
36
29
  ```bash
37
- openclaw plugins install openclaw-mobile
30
+ openclaw plugins install @openclaw/mobile
38
31
  ```
39
32
 
40
- ### 2. Start the Gateway
33
+ For local development:
41
34
 
42
35
  ```bash
43
- openclaw gateway
36
+ openclaw plugins install -l ./plugin
44
37
  ```
45
38
 
46
- The `openclaw-mobile` channel will appear in the Control UI at http://127.0.0.1:18789/ with status **Running: Yes** and **Connected: Yes**.
47
-
48
- ### 3. Connect the mobile app
39
+ ## Quick Start
49
40
 
50
- The app connects directly to the relay — no QR code or pairing step needed:
41
+ ### 1. Deploy the relay
51
42
 
52
- ```
53
- wss://openclaw-relay.eldermoo8718.workers.dev/ws?role=app&room=default&session=<uuid>
43
+ ```bash
44
+ cd relay
45
+ npm install
46
+ npx wrangler deploy
54
47
  ```
55
48
 
56
- The app generates its own `session` UUID on first launch and reuses it. Each session is fully isolated.
49
+ Note the deployed URL (e.g. `wss://openclaw-relay.your-name.workers.dev`).
57
50
 
58
- ### 4. Verify
51
+ If you want relay authentication, set a secret:
59
52
 
60
- Open the Control UI and navigate to the **OpenClaw Mobile** channel. You should see:
53
+ ```bash
54
+ npx wrangler secret put RELAY_TOKEN
55
+ ```
61
56
 
62
- - **Running**: Yes
63
- - **Configured**: Yes
64
- - **Connected**: Yes
57
+ ### 2. Install the plugin
65
58
 
66
- ## Configuration
59
+ ```bash
60
+ openclaw plugins install -l ./plugin
61
+ ```
67
62
 
68
- Configuration is optional. The plugin works out of the box with the built-in public relay.
63
+ ### 3. Configure
69
64
 
70
- To use your own self-hosted relay, add to `~/.openclaw/openclaw.json`:
65
+ Add to `~/.openclaw/openclaw.json`:
71
66
 
72
67
  ```json
73
68
  {
@@ -75,8 +70,10 @@ To use your own self-hosted relay, add to `~/.openclaw/openclaw.json`:
75
70
  "openclaw-mobile": {
76
71
  "accounts": {
77
72
  "default": {
78
- "relayUrl": "wss://your-relay.workers.dev",
79
- "relayToken": "your-secret-token"
73
+ "enabled": true,
74
+ "relayUrl": "wss://openclaw-relay.your-name.workers.dev",
75
+ "relayToken": "your-secret-token",
76
+ "roomId": "default"
80
77
  }
81
78
  }
82
79
  }
@@ -84,66 +81,74 @@ To use your own self-hosted relay, add to `~/.openclaw/openclaw.json`:
84
81
  }
85
82
  ```
86
83
 
87
- You can also edit these fields in the Control UI config form — changes take effect immediately without restarting the Gateway.
84
+ ### 4. Restart the Gateway
88
85
 
89
- | Field | Type | Default | Description |
90
- |-------|------|---------|-------------|
91
- | `enabled` | boolean | `true` | Enable/disable this account |
92
- | `relayUrl` | string | built-in public relay | CF Worker relay WebSocket URL |
93
- | `relayToken` | string | `""` | Shared secret (only needed for self-hosted relay with auth) |
86
+ ```bash
87
+ openclaw gateway stop
88
+ openclaw gateway --port 18789
89
+ ```
94
90
 
95
- ## Self-hosting the relay
91
+ ### 5. Verify
96
92
 
97
- If you want to run your own relay instead of using the built-in public one:
93
+ Open the Control UI at http://127.0.0.1:18789/ and navigate to the **OpenClaw Mobile** channel page. You should see:
98
94
 
99
- ```bash
100
- cd relay
101
- npm install
102
- npx wrangler deploy
103
- ```
95
+ - **Running**: Yes
96
+ - **Configured**: Yes
97
+ - **Connected**: Yes
104
98
 
105
- Note the deployed URL and set it as `relayUrl` in your config. To add authentication:
99
+ You can also check logs:
106
100
 
107
101
  ```bash
108
- npx wrangler secret put RELAY_TOKEN
102
+ tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
109
103
  ```
110
104
 
111
- Then set the same value as `relayToken` in your config.
105
+ ## Configuration
106
+
107
+ All config lives under `channels.openclaw-mobile.accounts.<accountId>` in `~/.openclaw/openclaw.json`.
108
+
109
+ | Field | Type | Required | Default | Description |
110
+ |-------|------|----------|---------|-------------|
111
+ | `enabled` | boolean | No | `true` | Enable/disable this account |
112
+ | `relayUrl` | string | **Yes** | — | CF Worker relay WebSocket URL |
113
+ | `relayToken` | string | No | `""` | Shared secret for relay authentication (must match the Worker's `RELAY_TOKEN` secret) |
114
+ | `roomId` | string | No | `"default"` | Room ID for relay routing (isolates conversations) |
115
+
116
+ You can also edit these fields directly in the Control UI config form.
112
117
 
113
118
  ## Control UI
114
119
 
115
120
  The plugin integrates with the OpenClaw Control UI:
116
121
 
117
122
  - **Status panel** — shows Running, Configured, Connected, Last inbound, and error details
118
- - **Config form** — editable fields for Relay URL, Relay Token, and an Enable/Disable toggle
123
+ - **Config form** — editable fields for Relay URL, Relay Token, Room ID, and an Enable/Disable toggle
124
+ - **Enable/Disable** — the toggle in the UI writes `enabled: true/false` to the config and restarts the channel
119
125
 
120
126
  ## How the relay works
121
127
 
122
128
  The relay is a Cloudflare Worker with a Durable Object (`RelayRoom`):
123
129
 
124
- - The plugin connects as `role=plugin`.
125
- - Each app session connects as `role=app&session=<uuid>`.
126
- - **App Plugin**: the relay injects `sessionKey` into the message JSON before forwarding.
127
- - **Plugin App**: the relay reads `sessionKey` from the reply and delivers only to the matching app WebSocket.
128
- - The DO uses `setWebSocketAutoResponse("ping", "pong")` for keepalive without waking from hibernation.
129
- - Optional `RELAY_TOKEN` secret gates access to the relay.
130
+ - Each "room" is a named DO instance that bridges two WebSocket roles: `plugin` (the Gateway) and `app` (the mobile client)
131
+ - Messages from one role are forwarded to all peers of the opposite role
132
+ - The DO uses `setWebSocketAutoResponse("ping", "pong")` for keepalive without waking from hibernation
133
+ - The plugin sends a `ping` every 30 seconds to prevent idle disconnection
134
+ - Optional `RELAY_TOKEN` secret gates access to the relay
130
135
 
131
136
  ## Message Protocol
132
137
 
133
138
  ### App → Plugin (via relay)
134
139
 
135
- The app sends:
136
-
137
140
  ```json
138
141
  {
139
142
  "type": "message",
140
143
  "content": "Hello",
144
+ "sessionKey": "relay-session",
141
145
  "senderId": "mobile-user",
142
146
  "senderName": "Mobile User"
143
147
  }
144
148
  ```
145
149
 
146
- The relay automatically stamps `sessionKey` from the `?session=` query param. The app does not need to include it.
150
+ - `sessionKey` the app's local session identifier; the plugin echoes it back in replies so the app can match them
151
+ - `senderId` / `senderName` — optional; defaults to `"mobile-user"` / `"Mobile User"`
147
152
 
148
153
  ### Plugin → App (via relay)
149
154
 
@@ -152,25 +157,24 @@ The relay automatically stamps `sessionKey` from the `?session=` query param. Th
152
157
  "type": "message",
153
158
  "role": "assistant",
154
159
  "content": "AI reply text",
155
- "sessionKey": "<uuid>"
160
+ "sessionKey": "relay-session"
156
161
  }
157
162
  ```
158
163
 
159
- The relay uses `sessionKey` to route the reply only to the originating app session.
160
-
161
- ## E2E Encryption
162
-
163
- The plugin performs an X25519 ECDH key exchange with each app session independently, deriving a per-session AES-256-GCM key via HKDF-SHA256. All messages after the handshake are encrypted. Each session has its own key.
164
+ 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.
164
165
 
165
166
  ## Troubleshooting
166
167
 
167
168
  | Problem | Cause | Fix |
168
169
  |---------|-------|-----|
169
170
  | "Channel config schema unavailable" in Control UI | Gateway loaded before plugin was installed | Restart the Gateway: `openclaw gateway stop && openclaw gateway` |
170
- | Running: No | `relayUrl` unreachable | Check logs; verify the relay Worker is deployed |
171
- | Connected: No | Relay URL wrong or Worker not deployed | Verify with `curl https://openclaw-relay.eldermoo8718.workers.dev/health` |
172
- | App messages not received | App not sending `?session=<uuid>` | Ensure the app includes a `session` query param when connecting |
171
+ | Running: No | `startAccount` returned early (old plugin version) | Update the plugin and restart Gateway |
172
+ | 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` |
173
+ | Relay keeps disconnecting | Ping keepalive not working or network issue | Check logs for "WebSocket error"; ensure relay Worker is deployed with Durable Objects enabled |
174
+ | "relayUrl not configured" in logs | Missing `relayUrl` in account config | Add `relayUrl` under `channels.openclaw-mobile.accounts.default` |
175
+ | Enable toggle in UI doesn't match JSON | Missing `setAccountEnabled` adapter (old plugin version) | Update the plugin and restart Gateway |
173
176
  | `relayToken` mismatch | Token in config doesn't match Worker secret | Ensure `relayToken` matches the `RELAY_TOKEN` secret set on the Worker |
177
+ | App stuck loading, no reply shown | sessionKey mismatch between app and plugin | Ensure app sends `sessionKey` in message payload and plugin version ≥ current |
174
178
 
175
179
  ### Useful commands
176
180
 
@@ -185,5 +189,5 @@ openclaw channels status --probe
185
189
  tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
186
190
 
187
191
  # Verify relay is reachable
188
- curl https://openclaw-relay.eldermoo8718.workers.dev/health
192
+ curl https://openclaw-relay.your-name.workers.dev/health
189
193
  ```
package/index.ts CHANGED
@@ -1,15 +1,9 @@
1
1
  /**
2
2
  * OpenClaw Mobile Channel Plugin
3
3
  *
4
- * Bridges Gateway CF Worker relay Mobile App via WebSocket.
5
- *
6
- * Routing model (no QR pairing):
7
- * - Plugin connects to the relay as "plugin" role using the configured relayUrl.
8
- * - Each mobile app session connects as "app" role with its own sessionKey UUID.
9
- * - The relay stamps every inbound app message with the sender's sessionKey.
10
- * - The plugin echoes that sessionKey back in every reply so the relay routes
11
- * the response only to the originating app session.
12
- * - Multiple app sessions share the same relay room without interference.
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
+ * No external dependencies uses Node.js built-in WebSocket.
13
7
  *
14
8
  * Config (in ~/.openclaw/openclaw.json):
15
9
  * {
@@ -19,7 +13,8 @@
19
13
  * "default": {
20
14
  * "enabled": true,
21
15
  * "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
22
- * "relayToken": "your-secret"
16
+ * "relayToken": "your-secret",
17
+ * "roomId": "default"
23
18
  * }
24
19
  * }
25
20
  * }
@@ -27,14 +22,15 @@
27
22
  * }
28
23
  */
29
24
 
30
- import { randomUUID } from "node:crypto";
31
-
32
25
  // ── Plugin runtime (set during register) ────────────────────────────────────
33
26
 
34
27
  let pluginRuntime: any = null;
35
28
 
36
29
  // ── E2E Encryption (X25519 ECDH + AES-256-GCM) ──────────────────────────────
30
+ // Uses Web Crypto API. Node 18+ uses standalone "X25519" algorithm name,
31
+ // while browsers use { name: "ECDH", namedCurve: "X25519" }.
37
32
 
33
+ // Runtime detection — resolved once on first use
38
34
  let _x25519Algo: { gen: any; imp: any; derive: string } | null = null;
39
35
  async function getX25519Algo() {
40
36
  if (_x25519Algo) return _x25519Algo;
@@ -59,16 +55,29 @@ function makeE2EState(): E2EState {
59
55
 
60
56
  async function e2eInit(state: E2EState): Promise<string> {
61
57
  const algo = await getX25519Algo();
62
- state.localKeyPair = await crypto.subtle.generateKey(algo.gen, true, ["deriveKey"]);
58
+ state.localKeyPair = await crypto.subtle.generateKey(
59
+ algo.gen,
60
+ true,
61
+ ["deriveKey"]
62
+ );
63
63
  const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
64
- return JSON.stringify({ type: "handshake", pubkey: bufToBase64Url(pubKeyRaw) });
64
+ const pubKeyB64 = bufToBase64Url(pubKeyRaw);
65
+ return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
65
66
  }
66
67
 
67
68
  async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
68
69
  if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
69
70
  const algo = await getX25519Algo();
71
+
70
72
  const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
71
- const peerPublicKey = await crypto.subtle.importKey("raw", peerPubKeyBytes.buffer as ArrayBuffer, algo.imp, false, []);
73
+ const peerPublicKey = await crypto.subtle.importKey(
74
+ "raw",
75
+ peerPubKeyBytes.buffer as ArrayBuffer,
76
+ algo.imp,
77
+ false,
78
+ []
79
+ );
80
+
72
81
  const ecdhRawKey = await crypto.subtle.deriveKey(
73
82
  { name: algo.derive, public: peerPublicKey },
74
83
  state.localKeyPair.privateKey,
@@ -77,29 +86,51 @@ async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promi
77
86
  ["encrypt", "decrypt"]
78
87
  );
79
88
  const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
80
- const hkdfKey = await crypto.subtle.importKey("raw", ecdhRawBytes as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]);
89
+
90
+ // HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
91
+ const hkdfKey = await crypto.subtle.importKey(
92
+ "raw", ecdhRawBytes as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]
93
+ );
81
94
  state.sharedKey = await crypto.subtle.deriveKey(
82
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("openclaw-e2e-v1") },
95
+ {
96
+ name: "HKDF",
97
+ hash: "SHA-256",
98
+ salt: new Uint8Array(0),
99
+ info: new TextEncoder().encode("openclaw-e2e-v1"),
100
+ },
83
101
  hkdfKey,
84
102
  { name: "AES-GCM", length: 256 },
85
103
  false,
86
104
  ["encrypt", "decrypt"]
87
105
  );
106
+
88
107
  state.ready = true;
89
108
  }
90
109
 
91
110
  async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
92
111
  if (!state.sharedKey) throw new Error("[E2E] Not ready");
93
112
  const nonce = crypto.getRandomValues(new Uint8Array(12));
94
- const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, state.sharedKey, new TextEncoder().encode(plaintext));
95
- return JSON.stringify({ type: "encrypted", nonce: bufToBase64Url(nonce), ct: bufToBase64Url(ct) });
113
+ const ct = await crypto.subtle.encrypt(
114
+ { name: "AES-GCM", iv: nonce },
115
+ state.sharedKey,
116
+ new TextEncoder().encode(plaintext)
117
+ );
118
+ return JSON.stringify({
119
+ type: "encrypted",
120
+ nonce: bufToBase64Url(nonce),
121
+ ct: bufToBase64Url(ct),
122
+ });
96
123
  }
97
124
 
98
125
  async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Promise<string> {
99
126
  if (!state.sharedKey) throw new Error("[E2E] Not ready");
100
127
  const nonce = base64UrlToBuf(nonceB64);
101
128
  const ct = base64UrlToBuf(ctB64);
102
- const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce.buffer as ArrayBuffer }, state.sharedKey, ct.buffer as ArrayBuffer);
129
+ const plain = await crypto.subtle.decrypt(
130
+ { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer },
131
+ state.sharedKey,
132
+ ct.buffer as ArrayBuffer
133
+ );
103
134
  return new TextDecoder().decode(plain);
104
135
  }
105
136
 
@@ -119,26 +150,6 @@ function base64UrlToBuf(b64: string): Uint8Array {
119
150
  return bytes;
120
151
  }
121
152
 
122
- // ── Per-session E2E state ────────────────────────────────────────────────────
123
- //
124
- // Each app session gets its own E2E key exchange. The plugin maintains a map
125
- // of sessionKey → E2EState so concurrent sessions are independently encrypted.
126
-
127
- const sessionE2E = new Map<string, E2EState>();
128
-
129
- function getSessionE2E(sessionKey: string): E2EState {
130
- let state = sessionE2E.get(sessionKey);
131
- if (!state) {
132
- state = makeE2EState();
133
- sessionE2E.set(sessionKey, state);
134
- }
135
- return state;
136
- }
137
-
138
- function dropSessionE2E(sessionKey: string): void {
139
- sessionE2E.delete(sessionKey);
140
- }
141
-
142
153
  // ── Relay state (per account) ────────────────────────────────────────────────
143
154
 
144
155
  interface RelayState {
@@ -147,20 +158,25 @@ interface RelayState {
147
158
  pingTimer: ReturnType<typeof setInterval> | null;
148
159
  statusSink: ((patch: Record<string, unknown>) => void) | null;
149
160
  gatewayCtx: any;
161
+ /**
162
+ * Per-session E2E state keyed by the app's session UUID.
163
+ * Each app connection gets its own X25519 keypair + AES-256-GCM shared key
164
+ * so multiple users can be active simultaneously without key collisions.
165
+ */
166
+ e2eSessions: Map<string, E2EState>;
150
167
  }
151
168
 
152
169
  const relayStates = new Map<string, RelayState>();
153
170
 
154
171
  const RECONNECT_DELAY = 5000;
155
- const PING_INTERVAL = 30_000;
172
+ const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
156
173
  const DEFAULT_ACCOUNT_ID = "default";
157
174
  const CHANNEL_ID = "openclaw-mobile";
158
- const DEFAULT_RELAY_URL = "wss://openclaw-relay.eldermoo8718.workers.dev";
159
175
 
160
176
  function getRelayState(accountId: string): RelayState {
161
177
  let state = relayStates.get(accountId);
162
178
  if (!state) {
163
- state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null };
179
+ state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, e2eSessions: new Map() };
164
180
  relayStates.set(accountId, state);
165
181
  }
166
182
  return state;
@@ -173,25 +189,27 @@ interface ResolvedAccount {
173
189
  enabled: boolean;
174
190
  relayUrl: string;
175
191
  relayToken: string;
176
- room: string;
192
+ roomId: string;
177
193
  configured: boolean;
178
194
  }
179
195
 
180
196
  function listAccountIds(cfg: any): string[] {
181
- return Object.keys(cfg.channels?.[CHANNEL_ID]?.accounts ?? {});
197
+ return Object.keys(
198
+ cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
199
+ );
182
200
  }
183
201
 
184
202
  function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
185
203
  const id = accountId ?? DEFAULT_ACCOUNT_ID;
186
- const acct = cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
187
- const relayUrl = acct.relayUrl || DEFAULT_RELAY_URL;
204
+ const acct =
205
+ cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
188
206
  return {
189
207
  accountId: id,
190
208
  enabled: acct.enabled !== false,
191
- relayUrl,
209
+ relayUrl: acct.relayUrl ?? "",
192
210
  relayToken: acct.relayToken ?? "",
193
- room: acct.room || "default",
194
- configured: true, // always configured — default relay is built-in
211
+ roomId: acct.roomId ?? "default",
212
+ configured: Boolean(acct.relayUrl),
195
213
  };
196
214
  }
197
215
 
@@ -216,9 +234,9 @@ const mobileConfigSchema = {
216
234
  type: "string" as const,
217
235
  description: "Shared secret for relay authentication",
218
236
  },
219
- room: {
237
+ roomId: {
220
238
  type: "string" as const,
221
- description: "Relay room name isolates this gateway from others sharing the same relay",
239
+ description: "Room ID for relay routing",
222
240
  },
223
241
  },
224
242
  additionalProperties: false,
@@ -230,14 +248,14 @@ const mobileConfigSchema = {
230
248
  uiHints: {
231
249
  relayUrl: {
232
250
  label: "Relay URL",
233
- placeholder: DEFAULT_RELAY_URL,
251
+ placeholder: "wss://openclaw-relay.xxx.workers.dev",
234
252
  },
235
253
  relayToken: {
236
254
  label: "Relay Token",
237
255
  sensitive: true,
238
256
  },
239
- room: {
240
- label: "Room",
257
+ roomId: {
258
+ label: "Room ID",
241
259
  placeholder: "default",
242
260
  },
243
261
  },
@@ -262,7 +280,8 @@ const channel = {
262
280
  configSchema: mobileConfigSchema,
263
281
  config: {
264
282
  listAccountIds: (cfg: any) => listAccountIds(cfg),
265
- resolveAccount: (cfg: any, accountId?: string) => resolveAccount(cfg, accountId),
283
+ resolveAccount: (cfg: any, accountId?: string) =>
284
+ resolveAccount(cfg, accountId),
266
285
  defaultAccountId: () => DEFAULT_ACCOUNT_ID,
267
286
  setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
268
287
  const key = accountId || DEFAULT_ACCOUNT_ID;
@@ -274,7 +293,13 @@ const channel = {
274
293
  ...cfg.channels,
275
294
  [CHANNEL_ID]: {
276
295
  ...base,
277
- accounts: { ...accounts, [key]: { ...accounts[key], enabled } },
296
+ accounts: {
297
+ ...accounts,
298
+ [key]: {
299
+ ...accounts[key],
300
+ enabled,
301
+ },
302
+ },
278
303
  },
279
304
  },
280
305
  };
@@ -301,17 +326,32 @@ const channel = {
301
326
  let outText = text ?? "";
302
327
  if (runtime) {
303
328
  const cfg = runtime.config.loadConfig();
304
- const tableMode = runtime.channel.text.resolveMarkdownTableMode({ cfg, channel: CHANNEL_ID, accountId: aid });
329
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
330
+ cfg,
331
+ channel: CHANNEL_ID,
332
+ accountId: aid,
333
+ });
305
334
  outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
306
335
  }
307
336
 
308
- const sessionKey = session?.key ?? null;
309
- const plainMsg = JSON.stringify({ type: "message", role: "assistant", content: outText, sessionKey });
310
- const e2e = sessionKey ? sessionE2E.get(sessionKey) : null;
311
- const outMsg = e2e?.ready ? await e2eEncrypt(e2e, plainMsg) : plainMsg;
337
+ const replySessionKey = session?.key ?? null;
338
+ const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
339
+ const plainMsg = JSON.stringify({
340
+ type: "message",
341
+ role: "assistant",
342
+ content: outText,
343
+ sessionKey: replySessionKey,
344
+ });
345
+ const outMsg = sessionE2E?.ready
346
+ ? await e2eEncrypt(sessionE2E, plainMsg)
347
+ : plainMsg;
312
348
  state.ws.send(outMsg);
313
349
 
314
- return { channel: CHANNEL_ID, to: to ?? "mobile-user", messageId: `mobile-${Date.now()}` };
350
+ return {
351
+ channel: CHANNEL_ID,
352
+ to: to ?? "mobile-user",
353
+ messageId: `mobile-${Date.now()}`,
354
+ };
315
355
  },
316
356
  },
317
357
 
@@ -338,7 +378,6 @@ const channel = {
338
378
  enabled: account.enabled,
339
379
  configured: account.configured,
340
380
  relayUrl: account.relayUrl || "(not set)",
341
- room: account.room || "default",
342
381
  running: runtime?.running ?? false,
343
382
  connected: runtime?.connected ?? false,
344
383
  lastStartAt: runtime?.lastStartAt ?? null,
@@ -358,7 +397,8 @@ const channel = {
358
397
  cleanupRelay(state);
359
398
 
360
399
  state.gatewayCtx = ctx;
361
- state.statusSink = (patch: Record<string, unknown>) => ctx.setStatus({ accountId, ...patch });
400
+ state.statusSink = (patch: Record<string, unknown>) =>
401
+ ctx.setStatus({ accountId, ...patch });
362
402
 
363
403
  ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
364
404
 
@@ -383,8 +423,15 @@ const channel = {
383
423
  },
384
424
  stopAccount: async (ctx: any) => {
385
425
  const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
386
- cleanupRelay(getRelayState(accountId));
387
- ctx.setStatus?.({ accountId, running: false, connected: false, lastStopAt: new Date().toISOString() });
426
+ const state = getRelayState(accountId);
427
+ cleanupRelay(state);
428
+
429
+ ctx.setStatus?.({
430
+ accountId,
431
+ running: false,
432
+ connected: false,
433
+ lastStopAt: new Date().toISOString(),
434
+ });
388
435
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
389
436
  },
390
437
  },
@@ -393,11 +440,20 @@ const channel = {
393
440
  // ── Relay bridge ─────────────────────────────────────────────────────────────
394
441
 
395
442
  function cleanupRelay(state: RelayState) {
396
- if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
397
- if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
398
- if (state.ws) { try { state.ws.close(); } catch {} state.ws = null; }
399
- // Drop all per-session E2E state
400
- sessionE2E.clear();
443
+ if (state.pingTimer) {
444
+ clearInterval(state.pingTimer);
445
+ state.pingTimer = null;
446
+ }
447
+ if (state.reconnectTimer) {
448
+ clearTimeout(state.reconnectTimer);
449
+ state.reconnectTimer = null;
450
+ }
451
+ if (state.ws) {
452
+ try { state.ws.close(); } catch {}
453
+ state.ws = null;
454
+ }
455
+ // Clear all per-session E2E states — new handshakes needed on reconnect
456
+ state.e2eSessions.clear();
401
457
  }
402
458
 
403
459
  function connectRelay(ctx: any, account: ResolvedAccount) {
@@ -405,7 +461,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
405
461
  const state = getRelayState(accountId);
406
462
 
407
463
  const base = account.relayUrl.replace(/\/$/, "");
408
- const params = new URLSearchParams({ role: "plugin", room: account.room });
464
+ const params = new URLSearchParams({
465
+ role: "plugin",
466
+ room: account.roomId,
467
+ });
409
468
  if (account.relayToken) params.set("token", account.relayToken);
410
469
 
411
470
  const url = `${base}/ws?${params.toString()}`;
@@ -423,25 +482,48 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
423
482
 
424
483
  state.ws.addEventListener("open", () => {
425
484
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
426
- if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
485
+ if (state.reconnectTimer) {
486
+ clearTimeout(state.reconnectTimer);
487
+ state.reconnectTimer = null;
488
+ }
427
489
  if (state.pingTimer) clearInterval(state.pingTimer);
428
490
  state.pingTimer = setInterval(() => {
429
- if (state.ws?.readyState === WebSocket.OPEN) state.ws.send("ping");
491
+ if (state.ws?.readyState === WebSocket.OPEN) {
492
+ state.ws.send("ping");
493
+ }
430
494
  }, PING_INTERVAL);
431
- state.statusSink?.({ connected: true, lastConnectedAt: new Date().toISOString(), lastError: null });
495
+
496
+ state.statusSink?.({
497
+ connected: true,
498
+ lastConnectedAt: new Date().toISOString(),
499
+ lastError: null,
500
+ });
501
+
502
+ // E2E handshakes are initiated per-session when `peer_joined` arrives
503
+ // (each app connection carries its own sessionKey UUID).
432
504
  });
433
505
 
434
506
  state.ws.addEventListener("message", (event: MessageEvent) => {
435
507
  handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
436
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`);
508
+ ctx.log?.error?.(
509
+ `[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
510
+ );
437
511
  });
438
512
  });
439
513
 
440
514
  state.ws.addEventListener("close", () => {
441
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`);
515
+ ctx.log?.info?.(
516
+ `[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
517
+ );
442
518
  state.ws = null;
443
- if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
444
- state.statusSink?.({ connected: false, lastDisconnect: new Date().toISOString() });
519
+ if (state.pingTimer) {
520
+ clearInterval(state.pingTimer);
521
+ state.pingTimer = null;
522
+ }
523
+ state.statusSink?.({
524
+ connected: false,
525
+ lastDisconnect: new Date().toISOString(),
526
+ });
445
527
  scheduleReconnect(ctx, account);
446
528
  });
447
529
 
@@ -461,22 +543,29 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
461
543
  }
462
544
 
463
545
  async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
546
+ // Skip ping/pong
464
547
  if (raw === "ping" || raw === "pong") return;
465
548
 
466
549
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
467
550
 
468
551
  const msg = JSON.parse(raw);
469
552
 
470
- // New app session joined — initiate E2E handshake for that session
553
+ // App session joined — initiate per-session E2E handshake
471
554
  if (msg.type === "peer_joined") {
472
555
  const sessionKey = msg.sessionKey as string | undefined;
473
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (sessionKey=${sessionKey}), sending handshake`);
474
- if (sessionKey) dropSessionE2E(sessionKey);
475
- const e2eState = sessionKey ? getSessionE2E(sessionKey) : makeE2EState();
476
- const handshakeMsg = await e2eInit(e2eState);
477
- // Wrap handshake with the sessionKey so the relay routes it to the right app
478
- const wrappedHandshake = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
479
- if (state.ws?.readyState === WebSocket.OPEN) state.ws.send(wrappedHandshake);
556
+ if (!sessionKey) {
557
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
558
+ return;
559
+ }
560
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
561
+ const sessionE2E = makeE2EState();
562
+ state.e2eSessions.set(sessionKey, sessionE2E);
563
+ const handshakeMsg = await e2eInit(sessionE2E);
564
+ // Attach sessionKey so the relay can route the handshake back to the right app
565
+ const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
566
+ if (state.ws?.readyState === WebSocket.OPEN) {
567
+ state.ws.send(handshakeWithSession);
568
+ }
480
569
  return;
481
570
  }
482
571
 
@@ -484,34 +573,44 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
484
573
  if (msg.type === "handshake") {
485
574
  const sessionKey = msg.sessionKey as string | undefined;
486
575
  const peerPubKey = msg.pubkey as string | undefined;
487
- if (!peerPubKey) {
488
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
576
+ if (!sessionKey || !peerPubKey) {
577
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
489
578
  return;
490
579
  }
491
- const e2eState = sessionKey ? getSessionE2E(sessionKey) : makeE2EState();
492
- await e2eHandleHandshake(e2eState, peerPubKey);
493
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake complete for session=${sessionKey}`);
580
+ let sessionE2E = state.e2eSessions.get(sessionKey);
581
+ if (!sessionE2E) {
582
+ // App initiated handshake without a prior peer_joined (e.g. plugin reconnected)
583
+ sessionE2E = makeE2EState();
584
+ await e2eInit(sessionE2E);
585
+ state.e2eSessions.set(sessionKey, sessionE2E);
586
+ }
587
+ await e2eHandleHandshake(sessionE2E, peerPubKey);
588
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
494
589
  return;
495
590
  }
496
591
 
497
- // Encrypted message from an app session
592
+ // E2E encrypted message decrypt using the per-session key
498
593
  if (msg.type === "encrypted") {
499
594
  const sessionKey = msg.sessionKey as string | undefined;
500
- const e2eState = sessionKey ? sessionE2E.get(sessionKey) : null;
501
- if (!e2eState?.ready) {
502
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg but session not ready (session=${sessionKey}), dropping`);
595
+ if (!sessionKey) {
596
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
597
+ return;
598
+ }
599
+ const sessionE2E = state.e2eSessions.get(sessionKey);
600
+ if (!sessionE2E?.ready) {
601
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
503
602
  return;
504
603
  }
505
- const plaintext = await e2eDecrypt(e2eState, msg.nonce, msg.ct);
506
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted (session=${sessionKey}): ${plaintext.slice(0, 200)}`);
604
+ const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
605
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
507
606
  const innerMsg = JSON.parse(plaintext);
508
- // Preserve sessionKey from the outer envelope
509
- if (sessionKey && !innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
607
+ // Preserve sessionKey through decryption so handleInbound can use it
608
+ if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
510
609
  await handleInbound(ctx, accountId, innerMsg);
511
610
  return;
512
611
  }
513
612
 
514
- // Plaintext message
613
+ // Plaintext message (no E2E or during handshake)
515
614
  await handleInbound(ctx, accountId, msg);
516
615
  }
517
616
 
@@ -528,15 +627,13 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
528
627
 
529
628
  const runtime = pluginRuntime;
530
629
  if (!runtime) {
531
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available`);
630
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
532
631
  return;
533
632
  }
534
633
 
535
634
  const senderId = msg.senderId ?? "mobile-user";
536
635
  const senderName = msg.senderName ?? "Mobile User";
537
636
  const text = String(msg.content);
538
- // sessionKey from the relay-stamped message — used to route the reply back
539
- const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
540
637
 
541
638
  try {
542
639
  const cfg = runtime.config.loadConfig();
@@ -548,26 +645,33 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
548
645
  peer: { kind: "direct", id: senderId },
549
646
  });
550
647
 
551
- // Derive an isolated Gateway session per app sessionKey so each mobile
552
- // conversation has its own context. Format: "agent:main:mobile-<uuid>"
553
- let gatewaySessionKey: string;
648
+ // If the app specified a sessionKey (UUID-based), derive an isolated
649
+ // Gateway session so each mobile conversation has its own context.
650
+ // Format: "agent:main:mobile-<uuid>"
651
+ // No appSessionKey → use the route's mainSessionKey (default conversation).
652
+ let sessionKey: string;
653
+ const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
554
654
  if (appSessionKey) {
655
+ // Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
555
656
  const mainKey = route.mainSessionKey as string;
556
- const colonIdx = mainKey.lastIndexOf(":");
557
- const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : "";
558
- gatewaySessionKey = `${agentPrefix}mobile-${appSessionKey}`;
657
+ const colonIdx = mainKey.lastIndexOf(':');
658
+ const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
659
+ sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
559
660
  } else {
560
- gatewaySessionKey = route.mainSessionKey;
661
+ sessionKey = route.mainSessionKey;
561
662
  }
562
-
563
663
  const from = `${CHANNEL_ID}:${senderId}`;
564
664
  const to = `user:${senderId}`;
565
665
 
566
- runtime.channel.activity.record({ channel: CHANNEL_ID, accountId, direction: "inbound" });
666
+ runtime.channel.activity.record({
667
+ channel: CHANNEL_ID,
668
+ accountId,
669
+ direction: "inbound",
670
+ });
567
671
 
568
672
  runtime.system.enqueueSystemEvent(
569
673
  `Mobile message from ${senderName}: ${text.slice(0, 160)}`,
570
- { sessionKey: gatewaySessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
674
+ { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
571
675
  );
572
676
 
573
677
  const body = runtime.channel.reply.formatInboundEnvelope({
@@ -585,7 +689,7 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
585
689
  CommandBody: text,
586
690
  From: from,
587
691
  To: to,
588
- SessionKey: gatewaySessionKey,
692
+ SessionKey: sessionKey,
589
693
  AccountId: accountId,
590
694
  ChatType: "direct",
591
695
  ConversationLabel: `Mobile DM from ${senderName}`,
@@ -597,8 +701,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
597
701
  OriginatingTo: to,
598
702
  });
599
703
 
600
- const textLimit = runtime.channel.text.resolveTextChunkLimit(cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 });
601
- const tableMode = runtime.channel.text.resolveMarkdownTableMode({ cfg, channel: CHANNEL_ID, accountId });
704
+ const textLimit = runtime.channel.text.resolveTextChunkLimit(
705
+ cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
706
+ );
707
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
708
+ cfg, channel: CHANNEL_ID, accountId,
709
+ });
602
710
 
603
711
  const { dispatcher, replyOptions, markDispatchIdle } =
604
712
  runtime.channel.reply.createReplyDispatcherWithTyping({
@@ -609,40 +717,58 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
609
717
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
610
718
  return;
611
719
  }
612
- const replyText = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
720
+ const replyText = runtime.channel.text.convertMarkdownTables(
721
+ payload.text ?? "", tableMode,
722
+ );
613
723
  const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
614
724
  const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
725
+ // The relay routes plugin→app by sessionKey in the JSON payload
726
+ const replySessionKey = appSessionKey ?? sessionKey;
727
+ const sessionE2E = relayState.e2eSessions.get(replySessionKey);
615
728
  for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
616
729
  if (!chunk) continue;
617
730
  const plainMsg = JSON.stringify({
618
731
  type: "message",
619
732
  role: "assistant",
620
733
  content: chunk,
621
- sessionKey: appSessionKey, // relay uses this to route to the right app
734
+ sessionKey: replySessionKey,
622
735
  });
623
- const e2e = appSessionKey ? sessionE2E.get(appSessionKey) : null;
624
- const outMsg = e2e?.ready ? await e2eEncrypt(e2e, plainMsg) : plainMsg;
736
+ // Encrypt with the per-session key if handshake is complete
737
+ const outMsg = sessionE2E?.ready
738
+ ? await e2eEncrypt(sessionE2E, plainMsg)
739
+ : plainMsg;
625
740
  relayState.ws.send(outMsg);
626
741
  }
627
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to session=${appSessionKey}`);
742
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
628
743
  },
629
744
  onError: (err: any, info: any) => {
630
745
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
631
746
  },
632
747
  });
633
748
 
634
- await runtime.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions });
749
+ await runtime.channel.reply.dispatchReplyFromConfig({
750
+ ctx: ctxPayload,
751
+ cfg,
752
+ dispatcher,
753
+ replyOptions,
754
+ });
635
755
  markDispatchIdle();
636
756
 
637
757
  const sessionCfg = cfg.session;
638
- const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, { agentId: route.agentId });
758
+ const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
759
+ agentId: route.agentId,
760
+ });
639
761
  await runtime.channel.session.updateLastRoute({
640
762
  storePath,
641
- sessionKey: gatewaySessionKey,
642
- deliveryContext: { channel: CHANNEL_ID, to, accountId },
763
+ sessionKey,
764
+ deliveryContext: {
765
+ channel: CHANNEL_ID,
766
+ to,
767
+ accountId,
768
+ },
643
769
  });
644
770
 
645
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound processed, gatewaySession=${gatewaySessionKey} appSession=${appSessionKey}`);
771
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
646
772
  } catch (e) {
647
773
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
648
774
  }
@@ -654,36 +780,4 @@ export default function register(api: any) {
654
780
  pluginRuntime = api.runtime;
655
781
  api.registerChannel({ plugin: channel });
656
782
  api.logger?.info?.("[openclaw-mobile] Plugin registered");
657
-
658
- // On first install (no channel config yet), write the default account so
659
- // Control UI shows it immediately without requiring a gateway restart.
660
- try {
661
- const cfg = api.runtime.config.loadConfig();
662
- const channelCfg = cfg.channels?.[CHANNEL_ID] ?? {};
663
- const accounts = (channelCfg as any).accounts ?? {};
664
- if (!accounts[DEFAULT_ACCOUNT_ID]) {
665
- const updatedCfg = {
666
- ...cfg,
667
- channels: {
668
- ...cfg.channels,
669
- [CHANNEL_ID]: {
670
- ...channelCfg,
671
- accounts: {
672
- ...accounts,
673
- [DEFAULT_ACCOUNT_ID]: {
674
- enabled: true,
675
- relayUrl: DEFAULT_RELAY_URL,
676
- relayToken: "",
677
- room: "default",
678
- },
679
- },
680
- },
681
- },
682
- };
683
- api.runtime.config.writeConfigFile(updatedCfg);
684
- api.logger?.info?.(`[${CHANNEL_ID}] Wrote default account config on first install`);
685
- }
686
- } catch (err) {
687
- api.logger?.warn?.(`[${CHANNEL_ID}] Could not write default account config: ${err}`);
688
- }
689
783
  }
@@ -12,30 +12,31 @@
12
12
  "properties": {
13
13
  "relayUrl": {
14
14
  "type": "string",
15
- "description": "CF Worker relay WebSocket URL. Leave empty to use the default public relay."
15
+ "description": "CF Worker relay WebSocket URL (e.g. wss://openclaw-relay.xxx.workers.dev)"
16
16
  },
17
17
  "relayToken": {
18
18
  "type": "string",
19
- "description": "Shared secret for relay authentication (only needed if you self-host the relay with a token)"
19
+ "description": "Shared secret for relay authentication"
20
20
  },
21
- "room": {
21
+ "roomId": {
22
22
  "type": "string",
23
- "description": "Relay room name — isolates this gateway from others sharing the same relay (default: \"default\")"
23
+ "description": "Room ID for relay routing",
24
+ "default": "default"
24
25
  }
25
26
  }
26
27
  },
27
28
  "uiHints": {
28
29
  "relayUrl": {
29
30
  "label": "Relay URL",
30
- "placeholder": "wss://openclaw-relay.eldermoo8718.workers.dev"
31
+ "placeholder": "wss://openclaw-relay.xxx.workers.dev"
31
32
  },
32
33
  "relayToken": {
33
34
  "label": "Relay Token",
34
35
  "sensitive": true
35
36
  },
36
- "room": {
37
- "label": "Room",
37
+ "roomId": {
38
+ "label": "Room ID",
38
39
  "placeholder": "default"
39
40
  }
40
41
  }
41
- }
42
+ }
package/package.json CHANGED
@@ -1,29 +1,31 @@
1
1
  {
2
- "name": "openclaw-mobile",
3
- "version": "1.0.2",
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
- "devDependencies": {
19
- "typescript": "^5.7.0"
20
- },
21
- "peerDependencies": {
22
- "openclaw": "*"
23
- },
24
- "peerDependenciesMeta": {
2
+ "name": "openclaw-mobile",
3
+ "version": "1.0.3",
4
+ "description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
5
+ "main": "index.ts",
6
+ "type": "module",
25
7
  "openclaw": {
26
- "optional": true
8
+ "plugin": "./openclaw.plugin.json",
9
+ "extensions": [
10
+ "./index.ts"
11
+ ]
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "plugin",
16
+ "channel",
17
+ "mobile"
18
+ ],
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "typescript": "^5.7.0"
22
+ },
23
+ "peerDependencies": {
24
+ "openclaw": "*"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "openclaw": {
28
+ "optional": true
29
+ }
27
30
  }
28
- }
29
- }
31
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ // Visit https://aka.ms/tsconfig to read more about this file
3
+ "compilerOptions": {
4
+ // File Layout
5
+ // "rootDir": "./src",
6
+ // "outDir": "./dist",
7
+
8
+ // Environment Settings
9
+ // See also https://aka.ms/tsconfig/module
10
+ "module": "nodenext",
11
+ "target": "esnext",
12
+ "types": [],
13
+ // For nodejs:
14
+ // "lib": ["esnext"],
15
+ // "types": ["node"],
16
+ // and npm install -D @types/node
17
+
18
+ // Other Outputs
19
+ "sourceMap": true,
20
+ "declaration": true,
21
+ "declarationMap": true,
22
+
23
+ // Stricter Typechecking Options
24
+ "noUncheckedIndexedAccess": true,
25
+ "exactOptionalPropertyTypes": true,
26
+
27
+ // Style Options
28
+ // "noImplicitReturns": true,
29
+ // "noImplicitOverride": true,
30
+ // "noUnusedLocals": true,
31
+ // "noUnusedParameters": true,
32
+ // "noFallthroughCasesInSwitch": true,
33
+ // "noPropertyAccessFromIndexSignature": true,
34
+
35
+ // Recommended Options
36
+ "strict": true,
37
+ "jsx": "react-jsx",
38
+ "verbatimModuleSyntax": true,
39
+ "isolatedModules": true,
40
+ "noUncheckedSideEffectImports": true,
41
+ "moduleDetection": "force",
42
+ "skipLibCheck": true,
43
+ }
44
+ }