openclaw-mobile 1.0.1 → 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
 
@@ -369,43 +409,6 @@ const channel = {
369
409
  return;
370
410
  }
371
411
 
372
- // Write default values back to config so Control UI shows them
373
- const runtime = pluginRuntime;
374
- if (runtime) {
375
- try {
376
- const currentCfg = runtime.config.loadConfig();
377
- const channelCfg = currentCfg.channels?.[CHANNEL_ID] ?? {};
378
- const accounts = (channelCfg as any).accounts ?? {};
379
- const existing = accounts[accountId] ?? {};
380
- const needsWrite =
381
- !existing.relayUrl ||
382
- !existing.room;
383
- if (needsWrite) {
384
- const updatedCfg = {
385
- ...currentCfg,
386
- channels: {
387
- ...currentCfg.channels,
388
- [CHANNEL_ID]: {
389
- ...channelCfg,
390
- accounts: {
391
- ...accounts,
392
- [accountId]: {
393
- ...existing,
394
- relayUrl: existing.relayUrl || account.relayUrl,
395
- room: existing.room || account.room,
396
- },
397
- },
398
- },
399
- },
400
- };
401
- await runtime.config.writeConfigFile(updatedCfg);
402
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Wrote default config values`);
403
- }
404
- } catch (err) {
405
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Could not write default config: ${err}`);
406
- }
407
- }
408
-
409
412
  connectRelay(ctx, account);
410
413
 
411
414
  const signal: AbortSignal | undefined = ctx.abortSignal;
@@ -420,8 +423,15 @@ const channel = {
420
423
  },
421
424
  stopAccount: async (ctx: any) => {
422
425
  const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
423
- cleanupRelay(getRelayState(accountId));
424
- 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
+ });
425
435
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
426
436
  },
427
437
  },
@@ -430,11 +440,20 @@ const channel = {
430
440
  // ── Relay bridge ─────────────────────────────────────────────────────────────
431
441
 
432
442
  function cleanupRelay(state: RelayState) {
433
- if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
434
- if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
435
- if (state.ws) { try { state.ws.close(); } catch {} state.ws = null; }
436
- // Drop all per-session E2E state
437
- 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();
438
457
  }
439
458
 
440
459
  function connectRelay(ctx: any, account: ResolvedAccount) {
@@ -442,7 +461,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
442
461
  const state = getRelayState(accountId);
443
462
 
444
463
  const base = account.relayUrl.replace(/\/$/, "");
445
- const params = new URLSearchParams({ role: "plugin", room: account.room });
464
+ const params = new URLSearchParams({
465
+ role: "plugin",
466
+ room: account.roomId,
467
+ });
446
468
  if (account.relayToken) params.set("token", account.relayToken);
447
469
 
448
470
  const url = `${base}/ws?${params.toString()}`;
@@ -460,25 +482,48 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
460
482
 
461
483
  state.ws.addEventListener("open", () => {
462
484
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
463
- if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
485
+ if (state.reconnectTimer) {
486
+ clearTimeout(state.reconnectTimer);
487
+ state.reconnectTimer = null;
488
+ }
464
489
  if (state.pingTimer) clearInterval(state.pingTimer);
465
490
  state.pingTimer = setInterval(() => {
466
- if (state.ws?.readyState === WebSocket.OPEN) state.ws.send("ping");
491
+ if (state.ws?.readyState === WebSocket.OPEN) {
492
+ state.ws.send("ping");
493
+ }
467
494
  }, PING_INTERVAL);
468
- 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).
469
504
  });
470
505
 
471
506
  state.ws.addEventListener("message", (event: MessageEvent) => {
472
507
  handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
473
- 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
+ );
474
511
  });
475
512
  });
476
513
 
477
514
  state.ws.addEventListener("close", () => {
478
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`);
515
+ ctx.log?.info?.(
516
+ `[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
517
+ );
479
518
  state.ws = null;
480
- if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
481
- 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
+ });
482
527
  scheduleReconnect(ctx, account);
483
528
  });
484
529
 
@@ -498,22 +543,29 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
498
543
  }
499
544
 
500
545
  async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
546
+ // Skip ping/pong
501
547
  if (raw === "ping" || raw === "pong") return;
502
548
 
503
549
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
504
550
 
505
551
  const msg = JSON.parse(raw);
506
552
 
507
- // New app session joined — initiate E2E handshake for that session
553
+ // App session joined — initiate per-session E2E handshake
508
554
  if (msg.type === "peer_joined") {
509
555
  const sessionKey = msg.sessionKey as string | undefined;
510
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (sessionKey=${sessionKey}), sending handshake`);
511
- if (sessionKey) dropSessionE2E(sessionKey);
512
- const e2eState = sessionKey ? getSessionE2E(sessionKey) : makeE2EState();
513
- const handshakeMsg = await e2eInit(e2eState);
514
- // Wrap handshake with the sessionKey so the relay routes it to the right app
515
- const wrappedHandshake = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
516
- 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
+ }
517
569
  return;
518
570
  }
519
571
 
@@ -521,34 +573,44 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
521
573
  if (msg.type === "handshake") {
522
574
  const sessionKey = msg.sessionKey as string | undefined;
523
575
  const peerPubKey = msg.pubkey as string | undefined;
524
- if (!peerPubKey) {
525
- 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`);
526
578
  return;
527
579
  }
528
- const e2eState = sessionKey ? getSessionE2E(sessionKey) : makeE2EState();
529
- await e2eHandleHandshake(e2eState, peerPubKey);
530
- 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`);
531
589
  return;
532
590
  }
533
591
 
534
- // Encrypted message from an app session
592
+ // E2E encrypted message decrypt using the per-session key
535
593
  if (msg.type === "encrypted") {
536
594
  const sessionKey = msg.sessionKey as string | undefined;
537
- const e2eState = sessionKey ? sessionE2E.get(sessionKey) : null;
538
- if (!e2eState?.ready) {
539
- 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`);
540
602
  return;
541
603
  }
542
- const plaintext = await e2eDecrypt(e2eState, msg.nonce, msg.ct);
543
- 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)}`);
544
606
  const innerMsg = JSON.parse(plaintext);
545
- // Preserve sessionKey from the outer envelope
546
- if (sessionKey && !innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
607
+ // Preserve sessionKey through decryption so handleInbound can use it
608
+ if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
547
609
  await handleInbound(ctx, accountId, innerMsg);
548
610
  return;
549
611
  }
550
612
 
551
- // Plaintext message
613
+ // Plaintext message (no E2E or during handshake)
552
614
  await handleInbound(ctx, accountId, msg);
553
615
  }
554
616
 
@@ -565,15 +627,13 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
565
627
 
566
628
  const runtime = pluginRuntime;
567
629
  if (!runtime) {
568
- 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`);
569
631
  return;
570
632
  }
571
633
 
572
634
  const senderId = msg.senderId ?? "mobile-user";
573
635
  const senderName = msg.senderName ?? "Mobile User";
574
636
  const text = String(msg.content);
575
- // sessionKey from the relay-stamped message — used to route the reply back
576
- const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
577
637
 
578
638
  try {
579
639
  const cfg = runtime.config.loadConfig();
@@ -585,26 +645,33 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
585
645
  peer: { kind: "direct", id: senderId },
586
646
  });
587
647
 
588
- // Derive an isolated Gateway session per app sessionKey so each mobile
589
- // conversation has its own context. Format: "agent:main:mobile-<uuid>"
590
- 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;
591
654
  if (appSessionKey) {
655
+ // Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
592
656
  const mainKey = route.mainSessionKey as string;
593
- const colonIdx = mainKey.lastIndexOf(":");
594
- const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : "";
595
- gatewaySessionKey = `${agentPrefix}mobile-${appSessionKey}`;
657
+ const colonIdx = mainKey.lastIndexOf(':');
658
+ const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
659
+ sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
596
660
  } else {
597
- gatewaySessionKey = route.mainSessionKey;
661
+ sessionKey = route.mainSessionKey;
598
662
  }
599
-
600
663
  const from = `${CHANNEL_ID}:${senderId}`;
601
664
  const to = `user:${senderId}`;
602
665
 
603
- 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
+ });
604
671
 
605
672
  runtime.system.enqueueSystemEvent(
606
673
  `Mobile message from ${senderName}: ${text.slice(0, 160)}`,
607
- { sessionKey: gatewaySessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
674
+ { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
608
675
  );
609
676
 
610
677
  const body = runtime.channel.reply.formatInboundEnvelope({
@@ -622,7 +689,7 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
622
689
  CommandBody: text,
623
690
  From: from,
624
691
  To: to,
625
- SessionKey: gatewaySessionKey,
692
+ SessionKey: sessionKey,
626
693
  AccountId: accountId,
627
694
  ChatType: "direct",
628
695
  ConversationLabel: `Mobile DM from ${senderName}`,
@@ -634,8 +701,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
634
701
  OriginatingTo: to,
635
702
  });
636
703
 
637
- const textLimit = runtime.channel.text.resolveTextChunkLimit(cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 });
638
- 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
+ });
639
710
 
640
711
  const { dispatcher, replyOptions, markDispatchIdle } =
641
712
  runtime.channel.reply.createReplyDispatcherWithTyping({
@@ -646,40 +717,58 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
646
717
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
647
718
  return;
648
719
  }
649
- const replyText = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
720
+ const replyText = runtime.channel.text.convertMarkdownTables(
721
+ payload.text ?? "", tableMode,
722
+ );
650
723
  const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
651
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);
652
728
  for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
653
729
  if (!chunk) continue;
654
730
  const plainMsg = JSON.stringify({
655
731
  type: "message",
656
732
  role: "assistant",
657
733
  content: chunk,
658
- sessionKey: appSessionKey, // relay uses this to route to the right app
734
+ sessionKey: replySessionKey,
659
735
  });
660
- const e2e = appSessionKey ? sessionE2E.get(appSessionKey) : null;
661
- 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;
662
740
  relayState.ws.send(outMsg);
663
741
  }
664
- 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}`);
665
743
  },
666
744
  onError: (err: any, info: any) => {
667
745
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
668
746
  },
669
747
  });
670
748
 
671
- 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
+ });
672
755
  markDispatchIdle();
673
756
 
674
757
  const sessionCfg = cfg.session;
675
- 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
+ });
676
761
  await runtime.channel.session.updateLastRoute({
677
762
  storePath,
678
- sessionKey: gatewaySessionKey,
679
- deliveryContext: { channel: CHANNEL_ID, to, accountId },
763
+ sessionKey,
764
+ deliveryContext: {
765
+ channel: CHANNEL_ID,
766
+ to,
767
+ accountId,
768
+ },
680
769
  });
681
770
 
682
- 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}`);
683
772
  } catch (e) {
684
773
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
685
774
  }
@@ -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.1",
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
+ }