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 +77 -73
- package/index.ts +264 -159
- package/openclaw.plugin.json +9 -8
- package/package.json +28 -26
- package/tsconfig.json +44 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# openclaw
|
|
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.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
-
6.
|
|
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
|
-
|
|
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
|
|
30
|
+
openclaw plugins install @openclaw/mobile
|
|
38
31
|
```
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
For local development:
|
|
41
34
|
|
|
42
35
|
```bash
|
|
43
|
-
openclaw
|
|
36
|
+
openclaw plugins install -l ./plugin
|
|
44
37
|
```
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
### 3. Connect the mobile app
|
|
39
|
+
## Quick Start
|
|
49
40
|
|
|
50
|
-
|
|
41
|
+
### 1. Deploy the relay
|
|
51
42
|
|
|
52
|
-
```
|
|
53
|
-
|
|
43
|
+
```bash
|
|
44
|
+
cd relay
|
|
45
|
+
npm install
|
|
46
|
+
npx wrangler deploy
|
|
54
47
|
```
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
Note the deployed URL (e.g. `wss://openclaw-relay.your-name.workers.dev`).
|
|
57
50
|
|
|
58
|
-
|
|
51
|
+
If you want relay authentication, set a secret:
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
```bash
|
|
54
|
+
npx wrangler secret put RELAY_TOKEN
|
|
55
|
+
```
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
- **Configured**: Yes
|
|
64
|
-
- **Connected**: Yes
|
|
57
|
+
### 2. Install the plugin
|
|
65
58
|
|
|
66
|
-
|
|
59
|
+
```bash
|
|
60
|
+
openclaw plugins install -l ./plugin
|
|
61
|
+
```
|
|
67
62
|
|
|
68
|
-
|
|
63
|
+
### 3. Configure
|
|
69
64
|
|
|
70
|
-
|
|
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
|
-
"
|
|
79
|
-
"
|
|
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
|
-
|
|
84
|
+
### 4. Restart the Gateway
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
91
|
+
### 5. Verify
|
|
96
92
|
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
npx wrangler deploy
|
|
103
|
-
```
|
|
95
|
+
- **Running**: Yes
|
|
96
|
+
- **Configured**: Yes
|
|
97
|
+
- **Connected**: Yes
|
|
104
98
|
|
|
105
|
-
|
|
99
|
+
You can also check logs:
|
|
106
100
|
|
|
107
101
|
```bash
|
|
108
|
-
|
|
102
|
+
tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
|
|
109
103
|
```
|
|
110
104
|
|
|
111
|
-
|
|
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
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
-
|
|
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
|
-
|
|
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": "
|
|
160
|
+
"sessionKey": "relay-session"
|
|
156
161
|
}
|
|
157
162
|
```
|
|
158
163
|
|
|
159
|
-
The
|
|
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 | `
|
|
171
|
-
| Connected: No | Relay URL wrong or Worker not deployed |
|
|
172
|
-
|
|
|
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.
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
95
|
-
|
|
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(
|
|
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
|
-
|
|
196
|
+
roomId: string;
|
|
177
197
|
configured: boolean;
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
function listAccountIds(cfg: any): string[] {
|
|
181
|
-
|
|
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 =
|
|
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
|
-
|
|
194
|
-
configured: true, // always
|
|
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
|
-
|
|
247
|
+
roomId: {
|
|
220
248
|
type: "string" as const,
|
|
221
|
-
description: "
|
|
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
|
-
|
|
240
|
-
label: "Room",
|
|
241
|
-
placeholder:
|
|
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) =>
|
|
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: {
|
|
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
|
|
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({
|
|
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
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
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 {
|
|
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>) =>
|
|
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
|
-
|
|
387
|
-
|
|
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) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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({
|
|
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) {
|
|
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)
|
|
502
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
503
|
+
state.ws.send("ping");
|
|
504
|
+
}
|
|
430
505
|
}, PING_INTERVAL);
|
|
431
|
-
|
|
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?.(
|
|
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?.(
|
|
526
|
+
ctx.log?.info?.(
|
|
527
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
|
|
528
|
+
);
|
|
442
529
|
state.ws = null;
|
|
443
|
-
if (state.pingTimer) {
|
|
444
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
506
|
-
|
|
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
|
|
509
|
-
if (
|
|
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
|
-
//
|
|
552
|
-
// conversation has its own context.
|
|
553
|
-
|
|
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
|
-
|
|
668
|
+
const colonIdx = mainKey.lastIndexOf(':');
|
|
669
|
+
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
|
|
670
|
+
sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
|
|
559
671
|
} else {
|
|
560
|
-
|
|
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({
|
|
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
|
|
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:
|
|
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(
|
|
601
|
-
|
|
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(
|
|
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:
|
|
745
|
+
sessionKey: replySessionKey,
|
|
622
746
|
});
|
|
623
|
-
|
|
624
|
-
const outMsg =
|
|
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
|
|
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({
|
|
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, {
|
|
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
|
|
642
|
-
deliveryContext: {
|
|
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
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -12,30 +12,31 @@
|
|
|
12
12
|
"properties": {
|
|
13
13
|
"relayUrl": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "CF Worker relay WebSocket URL.
|
|
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
|
|
19
|
+
"description": "Shared secret for relay authentication"
|
|
20
20
|
},
|
|
21
|
-
"
|
|
21
|
+
"roomId": {
|
|
22
22
|
"type": "string",
|
|
23
|
-
"description": "
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
}
|