openclaw-mobile 1.0.2 → 1.0.4

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;
@@ -168,30 +184,42 @@ function getRelayState(accountId: string): RelayState {
168
184
 
169
185
  // ── Account resolution ───────────────────────────────────────────────────────
170
186
 
187
+ /** Default relay deployed at https://github.com/openclaw/openclaw-mobile */
188
+ const DEFAULT_RELAY_URL = "wss://openclaw-relay.eldermoo8718.workers.dev";
189
+ const DEFAULT_ROOM_ID = "default";
190
+
171
191
  interface ResolvedAccount {
172
192
  accountId: string;
173
193
  enabled: boolean;
174
194
  relayUrl: string;
175
195
  relayToken: string;
176
- room: string;
196
+ roomId: string;
177
197
  configured: boolean;
178
198
  }
179
199
 
180
200
  function listAccountIds(cfg: any): string[] {
181
- return Object.keys(cfg.channels?.[CHANNEL_ID]?.accounts ?? {});
201
+ const ids = Object.keys(
202
+ cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
203
+ );
204
+ // Always expose at least the default account so the Control UI
205
+ // shows a pre-filled entry without the user having to click "Add Entry".
206
+ if (!ids.includes(DEFAULT_ACCOUNT_ID)) ids.unshift(DEFAULT_ACCOUNT_ID);
207
+ return ids;
182
208
  }
183
209
 
184
210
  function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
185
211
  const id = accountId ?? DEFAULT_ACCOUNT_ID;
186
- const acct = cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
212
+ const acct =
213
+ cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
187
214
  const relayUrl = acct.relayUrl || DEFAULT_RELAY_URL;
215
+ const roomId = acct.roomId || DEFAULT_ROOM_ID;
188
216
  return {
189
217
  accountId: id,
190
218
  enabled: acct.enabled !== false,
191
219
  relayUrl,
192
220
  relayToken: acct.relayToken ?? "",
193
- room: acct.room || "default",
194
- configured: true, // always configured default relay is built-in
221
+ roomId,
222
+ configured: true, // always ready out of the box with the default relay
195
223
  };
196
224
  }
197
225
 
@@ -216,9 +244,9 @@ const mobileConfigSchema = {
216
244
  type: "string" as const,
217
245
  description: "Shared secret for relay authentication",
218
246
  },
219
- room: {
247
+ roomId: {
220
248
  type: "string" as const,
221
- description: "Relay room name isolates this gateway from others sharing the same relay",
249
+ description: "Room ID for relay routing",
222
250
  },
223
251
  },
224
252
  additionalProperties: false,
@@ -236,9 +264,9 @@ const mobileConfigSchema = {
236
264
  label: "Relay Token",
237
265
  sensitive: true,
238
266
  },
239
- room: {
240
- label: "Room",
241
- placeholder: "default",
267
+ roomId: {
268
+ label: "Room ID",
269
+ placeholder: DEFAULT_ROOM_ID,
242
270
  },
243
271
  },
244
272
  };
@@ -262,7 +290,8 @@ const channel = {
262
290
  configSchema: mobileConfigSchema,
263
291
  config: {
264
292
  listAccountIds: (cfg: any) => listAccountIds(cfg),
265
- resolveAccount: (cfg: any, accountId?: string) => resolveAccount(cfg, accountId),
293
+ resolveAccount: (cfg: any, accountId?: string) =>
294
+ resolveAccount(cfg, accountId),
266
295
  defaultAccountId: () => DEFAULT_ACCOUNT_ID,
267
296
  setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
268
297
  const key = accountId || DEFAULT_ACCOUNT_ID;
@@ -274,7 +303,13 @@ const channel = {
274
303
  ...cfg.channels,
275
304
  [CHANNEL_ID]: {
276
305
  ...base,
277
- accounts: { ...accounts, [key]: { ...accounts[key], enabled } },
306
+ accounts: {
307
+ ...accounts,
308
+ [key]: {
309
+ ...accounts[key],
310
+ enabled,
311
+ },
312
+ },
278
313
  },
279
314
  },
280
315
  };
@@ -284,7 +319,8 @@ const channel = {
284
319
  accountId: account.accountId,
285
320
  enabled: account.enabled,
286
321
  configured: account.configured,
287
- relayUrl: account.relayUrl || "(not set)",
322
+ relayUrl: account.relayUrl,
323
+ roomId: account.roomId,
288
324
  }),
289
325
  },
290
326
  outbound: {
@@ -301,17 +337,32 @@ const channel = {
301
337
  let outText = text ?? "";
302
338
  if (runtime) {
303
339
  const cfg = runtime.config.loadConfig();
304
- const tableMode = runtime.channel.text.resolveMarkdownTableMode({ cfg, channel: CHANNEL_ID, accountId: aid });
340
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
341
+ cfg,
342
+ channel: CHANNEL_ID,
343
+ accountId: aid,
344
+ });
305
345
  outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
306
346
  }
307
347
 
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;
348
+ const replySessionKey = session?.key ?? null;
349
+ const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
350
+ const plainMsg = JSON.stringify({
351
+ type: "message",
352
+ role: "assistant",
353
+ content: outText,
354
+ sessionKey: replySessionKey,
355
+ });
356
+ const outMsg = sessionE2E?.ready
357
+ ? await e2eEncrypt(sessionE2E, plainMsg)
358
+ : plainMsg;
312
359
  state.ws.send(outMsg);
313
360
 
314
- return { channel: CHANNEL_ID, to: to ?? "mobile-user", messageId: `mobile-${Date.now()}` };
361
+ return {
362
+ channel: CHANNEL_ID,
363
+ to: to ?? "mobile-user",
364
+ messageId: `mobile-${Date.now()}`,
365
+ };
315
366
  },
316
367
  },
317
368
 
@@ -338,7 +389,6 @@ const channel = {
338
389
  enabled: account.enabled,
339
390
  configured: account.configured,
340
391
  relayUrl: account.relayUrl || "(not set)",
341
- room: account.room || "default",
342
392
  running: runtime?.running ?? false,
343
393
  connected: runtime?.connected ?? false,
344
394
  lastStartAt: runtime?.lastStartAt ?? null,
@@ -358,7 +408,8 @@ const channel = {
358
408
  cleanupRelay(state);
359
409
 
360
410
  state.gatewayCtx = ctx;
361
- state.statusSink = (patch: Record<string, unknown>) => ctx.setStatus({ accountId, ...patch });
411
+ state.statusSink = (patch: Record<string, unknown>) =>
412
+ ctx.setStatus({ accountId, ...patch });
362
413
 
363
414
  ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
364
415
 
@@ -383,8 +434,15 @@ const channel = {
383
434
  },
384
435
  stopAccount: async (ctx: any) => {
385
436
  const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
386
- cleanupRelay(getRelayState(accountId));
387
- ctx.setStatus?.({ accountId, running: false, connected: false, lastStopAt: new Date().toISOString() });
437
+ const state = getRelayState(accountId);
438
+ cleanupRelay(state);
439
+
440
+ ctx.setStatus?.({
441
+ accountId,
442
+ running: false,
443
+ connected: false,
444
+ lastStopAt: new Date().toISOString(),
445
+ });
388
446
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
389
447
  },
390
448
  },
@@ -393,11 +451,20 @@ const channel = {
393
451
  // ── Relay bridge ─────────────────────────────────────────────────────────────
394
452
 
395
453
  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();
454
+ if (state.pingTimer) {
455
+ clearInterval(state.pingTimer);
456
+ state.pingTimer = null;
457
+ }
458
+ if (state.reconnectTimer) {
459
+ clearTimeout(state.reconnectTimer);
460
+ state.reconnectTimer = null;
461
+ }
462
+ if (state.ws) {
463
+ try { state.ws.close(); } catch {}
464
+ state.ws = null;
465
+ }
466
+ // Clear all per-session E2E states — new handshakes needed on reconnect
467
+ state.e2eSessions.clear();
401
468
  }
402
469
 
403
470
  function connectRelay(ctx: any, account: ResolvedAccount) {
@@ -405,7 +472,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
405
472
  const state = getRelayState(accountId);
406
473
 
407
474
  const base = account.relayUrl.replace(/\/$/, "");
408
- const params = new URLSearchParams({ role: "plugin", room: account.room });
475
+ const params = new URLSearchParams({
476
+ role: "plugin",
477
+ room: account.roomId,
478
+ });
409
479
  if (account.relayToken) params.set("token", account.relayToken);
410
480
 
411
481
  const url = `${base}/ws?${params.toString()}`;
@@ -423,25 +493,48 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
423
493
 
424
494
  state.ws.addEventListener("open", () => {
425
495
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
426
- if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
496
+ if (state.reconnectTimer) {
497
+ clearTimeout(state.reconnectTimer);
498
+ state.reconnectTimer = null;
499
+ }
427
500
  if (state.pingTimer) clearInterval(state.pingTimer);
428
501
  state.pingTimer = setInterval(() => {
429
- if (state.ws?.readyState === WebSocket.OPEN) state.ws.send("ping");
502
+ if (state.ws?.readyState === WebSocket.OPEN) {
503
+ state.ws.send("ping");
504
+ }
430
505
  }, PING_INTERVAL);
431
- state.statusSink?.({ connected: true, lastConnectedAt: new Date().toISOString(), lastError: null });
506
+
507
+ state.statusSink?.({
508
+ connected: true,
509
+ lastConnectedAt: new Date().toISOString(),
510
+ lastError: null,
511
+ });
512
+
513
+ // E2E handshakes are initiated per-session when `peer_joined` arrives
514
+ // (each app connection carries its own sessionKey UUID).
432
515
  });
433
516
 
434
517
  state.ws.addEventListener("message", (event: MessageEvent) => {
435
518
  handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
436
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`);
519
+ ctx.log?.error?.(
520
+ `[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
521
+ );
437
522
  });
438
523
  });
439
524
 
440
525
  state.ws.addEventListener("close", () => {
441
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`);
526
+ ctx.log?.info?.(
527
+ `[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
528
+ );
442
529
  state.ws = null;
443
- if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
444
- state.statusSink?.({ connected: false, lastDisconnect: new Date().toISOString() });
530
+ if (state.pingTimer) {
531
+ clearInterval(state.pingTimer);
532
+ state.pingTimer = null;
533
+ }
534
+ state.statusSink?.({
535
+ connected: false,
536
+ lastDisconnect: new Date().toISOString(),
537
+ });
445
538
  scheduleReconnect(ctx, account);
446
539
  });
447
540
 
@@ -461,22 +554,29 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
461
554
  }
462
555
 
463
556
  async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
557
+ // Skip ping/pong
464
558
  if (raw === "ping" || raw === "pong") return;
465
559
 
466
560
  ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
467
561
 
468
562
  const msg = JSON.parse(raw);
469
563
 
470
- // New app session joined — initiate E2E handshake for that session
564
+ // App session joined — initiate per-session E2E handshake
471
565
  if (msg.type === "peer_joined") {
472
566
  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);
567
+ if (!sessionKey) {
568
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
569
+ return;
570
+ }
571
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
572
+ const sessionE2E = makeE2EState();
573
+ state.e2eSessions.set(sessionKey, sessionE2E);
574
+ const handshakeMsg = await e2eInit(sessionE2E);
575
+ // Attach sessionKey so the relay can route the handshake back to the right app
576
+ const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
577
+ if (state.ws?.readyState === WebSocket.OPEN) {
578
+ state.ws.send(handshakeWithSession);
579
+ }
480
580
  return;
481
581
  }
482
582
 
@@ -484,34 +584,44 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
484
584
  if (msg.type === "handshake") {
485
585
  const sessionKey = msg.sessionKey as string | undefined;
486
586
  const peerPubKey = msg.pubkey as string | undefined;
487
- if (!peerPubKey) {
488
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
587
+ if (!sessionKey || !peerPubKey) {
588
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
489
589
  return;
490
590
  }
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}`);
591
+ let sessionE2E = state.e2eSessions.get(sessionKey);
592
+ if (!sessionE2E) {
593
+ // App initiated handshake without a prior peer_joined (e.g. plugin reconnected)
594
+ sessionE2E = makeE2EState();
595
+ await e2eInit(sessionE2E);
596
+ state.e2eSessions.set(sessionKey, sessionE2E);
597
+ }
598
+ await e2eHandleHandshake(sessionE2E, peerPubKey);
599
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
494
600
  return;
495
601
  }
496
602
 
497
- // Encrypted message from an app session
603
+ // E2E encrypted message decrypt using the per-session key
498
604
  if (msg.type === "encrypted") {
499
605
  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`);
606
+ if (!sessionKey) {
607
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
503
608
  return;
504
609
  }
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)}`);
610
+ const sessionE2E = state.e2eSessions.get(sessionKey);
611
+ if (!sessionE2E?.ready) {
612
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
613
+ return;
614
+ }
615
+ const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
616
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
507
617
  const innerMsg = JSON.parse(plaintext);
508
- // Preserve sessionKey from the outer envelope
509
- if (sessionKey && !innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
618
+ // Preserve sessionKey through decryption so handleInbound can use it
619
+ if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
510
620
  await handleInbound(ctx, accountId, innerMsg);
511
621
  return;
512
622
  }
513
623
 
514
- // Plaintext message
624
+ // Plaintext message (no E2E or during handshake)
515
625
  await handleInbound(ctx, accountId, msg);
516
626
  }
517
627
 
@@ -528,15 +638,13 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
528
638
 
529
639
  const runtime = pluginRuntime;
530
640
  if (!runtime) {
531
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available`);
641
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
532
642
  return;
533
643
  }
534
644
 
535
645
  const senderId = msg.senderId ?? "mobile-user";
536
646
  const senderName = msg.senderName ?? "Mobile User";
537
647
  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
648
 
541
649
  try {
542
650
  const cfg = runtime.config.loadConfig();
@@ -548,26 +656,33 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
548
656
  peer: { kind: "direct", id: senderId },
549
657
  });
550
658
 
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;
659
+ // If the app specified a sessionKey (UUID-based), derive an isolated
660
+ // Gateway session so each mobile conversation has its own context.
661
+ // Format: "agent:main:mobile-<uuid>"
662
+ // No appSessionKey → use the route's mainSessionKey (default conversation).
663
+ let sessionKey: string;
664
+ const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
554
665
  if (appSessionKey) {
666
+ // Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
555
667
  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}`;
668
+ const colonIdx = mainKey.lastIndexOf(':');
669
+ const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
670
+ sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
559
671
  } else {
560
- gatewaySessionKey = route.mainSessionKey;
672
+ sessionKey = route.mainSessionKey;
561
673
  }
562
-
563
674
  const from = `${CHANNEL_ID}:${senderId}`;
564
675
  const to = `user:${senderId}`;
565
676
 
566
- runtime.channel.activity.record({ channel: CHANNEL_ID, accountId, direction: "inbound" });
677
+ runtime.channel.activity.record({
678
+ channel: CHANNEL_ID,
679
+ accountId,
680
+ direction: "inbound",
681
+ });
567
682
 
568
683
  runtime.system.enqueueSystemEvent(
569
684
  `Mobile message from ${senderName}: ${text.slice(0, 160)}`,
570
- { sessionKey: gatewaySessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
685
+ { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
571
686
  );
572
687
 
573
688
  const body = runtime.channel.reply.formatInboundEnvelope({
@@ -585,7 +700,7 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
585
700
  CommandBody: text,
586
701
  From: from,
587
702
  To: to,
588
- SessionKey: gatewaySessionKey,
703
+ SessionKey: sessionKey,
589
704
  AccountId: accountId,
590
705
  ChatType: "direct",
591
706
  ConversationLabel: `Mobile DM from ${senderName}`,
@@ -597,8 +712,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
597
712
  OriginatingTo: to,
598
713
  });
599
714
 
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 });
715
+ const textLimit = runtime.channel.text.resolveTextChunkLimit(
716
+ cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
717
+ );
718
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
719
+ cfg, channel: CHANNEL_ID, accountId,
720
+ });
602
721
 
603
722
  const { dispatcher, replyOptions, markDispatchIdle } =
604
723
  runtime.channel.reply.createReplyDispatcherWithTyping({
@@ -609,40 +728,58 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
609
728
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
610
729
  return;
611
730
  }
612
- const replyText = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
731
+ const replyText = runtime.channel.text.convertMarkdownTables(
732
+ payload.text ?? "", tableMode,
733
+ );
613
734
  const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
614
735
  const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
736
+ // The relay routes plugin→app by sessionKey in the JSON payload
737
+ const replySessionKey = appSessionKey ?? sessionKey;
738
+ const sessionE2E = relayState.e2eSessions.get(replySessionKey);
615
739
  for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
616
740
  if (!chunk) continue;
617
741
  const plainMsg = JSON.stringify({
618
742
  type: "message",
619
743
  role: "assistant",
620
744
  content: chunk,
621
- sessionKey: appSessionKey, // relay uses this to route to the right app
745
+ sessionKey: replySessionKey,
622
746
  });
623
- const e2e = appSessionKey ? sessionE2E.get(appSessionKey) : null;
624
- const outMsg = e2e?.ready ? await e2eEncrypt(e2e, plainMsg) : plainMsg;
747
+ // Encrypt with the per-session key if handshake is complete
748
+ const outMsg = sessionE2E?.ready
749
+ ? await e2eEncrypt(sessionE2E, plainMsg)
750
+ : plainMsg;
625
751
  relayState.ws.send(outMsg);
626
752
  }
627
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to session=${appSessionKey}`);
753
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
628
754
  },
629
755
  onError: (err: any, info: any) => {
630
756
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
631
757
  },
632
758
  });
633
759
 
634
- await runtime.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions });
760
+ await runtime.channel.reply.dispatchReplyFromConfig({
761
+ ctx: ctxPayload,
762
+ cfg,
763
+ dispatcher,
764
+ replyOptions,
765
+ });
635
766
  markDispatchIdle();
636
767
 
637
768
  const sessionCfg = cfg.session;
638
- const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, { agentId: route.agentId });
769
+ const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
770
+ agentId: route.agentId,
771
+ });
639
772
  await runtime.channel.session.updateLastRoute({
640
773
  storePath,
641
- sessionKey: gatewaySessionKey,
642
- deliveryContext: { channel: CHANNEL_ID, to, accountId },
774
+ sessionKey,
775
+ deliveryContext: {
776
+ channel: CHANNEL_ID,
777
+ to,
778
+ accountId,
779
+ },
643
780
  });
644
781
 
645
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound processed, gatewaySession=${gatewaySessionKey} appSession=${appSessionKey}`);
782
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
646
783
  } catch (e) {
647
784
  ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
648
785
  }
@@ -654,36 +791,4 @@ export default function register(api: any) {
654
791
  pluginRuntime = api.runtime;
655
792
  api.registerChannel({ plugin: channel });
656
793
  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
794
  }
@@ -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.4",
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
+ }