openclaw-mobile 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -73
- package/index.ts +254 -165
- 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;
|
|
@@ -173,25 +189,27 @@ interface ResolvedAccount {
|
|
|
173
189
|
enabled: boolean;
|
|
174
190
|
relayUrl: string;
|
|
175
191
|
relayToken: string;
|
|
176
|
-
|
|
192
|
+
roomId: string;
|
|
177
193
|
configured: boolean;
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
function listAccountIds(cfg: any): string[] {
|
|
181
|
-
return Object.keys(
|
|
197
|
+
return Object.keys(
|
|
198
|
+
cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
|
|
199
|
+
);
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
|
|
185
203
|
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
186
|
-
const acct =
|
|
187
|
-
|
|
204
|
+
const acct =
|
|
205
|
+
cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
|
|
188
206
|
return {
|
|
189
207
|
accountId: id,
|
|
190
208
|
enabled: acct.enabled !== false,
|
|
191
|
-
relayUrl,
|
|
209
|
+
relayUrl: acct.relayUrl ?? "",
|
|
192
210
|
relayToken: acct.relayToken ?? "",
|
|
193
|
-
|
|
194
|
-
configured:
|
|
211
|
+
roomId: acct.roomId ?? "default",
|
|
212
|
+
configured: Boolean(acct.relayUrl),
|
|
195
213
|
};
|
|
196
214
|
}
|
|
197
215
|
|
|
@@ -216,9 +234,9 @@ const mobileConfigSchema = {
|
|
|
216
234
|
type: "string" as const,
|
|
217
235
|
description: "Shared secret for relay authentication",
|
|
218
236
|
},
|
|
219
|
-
|
|
237
|
+
roomId: {
|
|
220
238
|
type: "string" as const,
|
|
221
|
-
description: "
|
|
239
|
+
description: "Room ID for relay routing",
|
|
222
240
|
},
|
|
223
241
|
},
|
|
224
242
|
additionalProperties: false,
|
|
@@ -230,14 +248,14 @@ const mobileConfigSchema = {
|
|
|
230
248
|
uiHints: {
|
|
231
249
|
relayUrl: {
|
|
232
250
|
label: "Relay URL",
|
|
233
|
-
placeholder:
|
|
251
|
+
placeholder: "wss://openclaw-relay.xxx.workers.dev",
|
|
234
252
|
},
|
|
235
253
|
relayToken: {
|
|
236
254
|
label: "Relay Token",
|
|
237
255
|
sensitive: true,
|
|
238
256
|
},
|
|
239
|
-
|
|
240
|
-
label: "Room",
|
|
257
|
+
roomId: {
|
|
258
|
+
label: "Room ID",
|
|
241
259
|
placeholder: "default",
|
|
242
260
|
},
|
|
243
261
|
},
|
|
@@ -262,7 +280,8 @@ const channel = {
|
|
|
262
280
|
configSchema: mobileConfigSchema,
|
|
263
281
|
config: {
|
|
264
282
|
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
|
265
|
-
resolveAccount: (cfg: any, accountId?: string) =>
|
|
283
|
+
resolveAccount: (cfg: any, accountId?: string) =>
|
|
284
|
+
resolveAccount(cfg, accountId),
|
|
266
285
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
267
286
|
setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
|
|
268
287
|
const key = accountId || DEFAULT_ACCOUNT_ID;
|
|
@@ -274,7 +293,13 @@ const channel = {
|
|
|
274
293
|
...cfg.channels,
|
|
275
294
|
[CHANNEL_ID]: {
|
|
276
295
|
...base,
|
|
277
|
-
accounts: {
|
|
296
|
+
accounts: {
|
|
297
|
+
...accounts,
|
|
298
|
+
[key]: {
|
|
299
|
+
...accounts[key],
|
|
300
|
+
enabled,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
278
303
|
},
|
|
279
304
|
},
|
|
280
305
|
};
|
|
@@ -301,17 +326,32 @@ const channel = {
|
|
|
301
326
|
let outText = text ?? "";
|
|
302
327
|
if (runtime) {
|
|
303
328
|
const cfg = runtime.config.loadConfig();
|
|
304
|
-
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
329
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
330
|
+
cfg,
|
|
331
|
+
channel: CHANNEL_ID,
|
|
332
|
+
accountId: aid,
|
|
333
|
+
});
|
|
305
334
|
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
306
335
|
}
|
|
307
336
|
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
337
|
+
const replySessionKey = session?.key ?? null;
|
|
338
|
+
const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
|
|
339
|
+
const plainMsg = JSON.stringify({
|
|
340
|
+
type: "message",
|
|
341
|
+
role: "assistant",
|
|
342
|
+
content: outText,
|
|
343
|
+
sessionKey: replySessionKey,
|
|
344
|
+
});
|
|
345
|
+
const outMsg = sessionE2E?.ready
|
|
346
|
+
? await e2eEncrypt(sessionE2E, plainMsg)
|
|
347
|
+
: plainMsg;
|
|
312
348
|
state.ws.send(outMsg);
|
|
313
349
|
|
|
314
|
-
return {
|
|
350
|
+
return {
|
|
351
|
+
channel: CHANNEL_ID,
|
|
352
|
+
to: to ?? "mobile-user",
|
|
353
|
+
messageId: `mobile-${Date.now()}`,
|
|
354
|
+
};
|
|
315
355
|
},
|
|
316
356
|
},
|
|
317
357
|
|
|
@@ -338,7 +378,6 @@ const channel = {
|
|
|
338
378
|
enabled: account.enabled,
|
|
339
379
|
configured: account.configured,
|
|
340
380
|
relayUrl: account.relayUrl || "(not set)",
|
|
341
|
-
room: account.room || "default",
|
|
342
381
|
running: runtime?.running ?? false,
|
|
343
382
|
connected: runtime?.connected ?? false,
|
|
344
383
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
@@ -358,7 +397,8 @@ const channel = {
|
|
|
358
397
|
cleanupRelay(state);
|
|
359
398
|
|
|
360
399
|
state.gatewayCtx = ctx;
|
|
361
|
-
state.statusSink = (patch: Record<string, unknown>) =>
|
|
400
|
+
state.statusSink = (patch: Record<string, unknown>) =>
|
|
401
|
+
ctx.setStatus({ accountId, ...patch });
|
|
362
402
|
|
|
363
403
|
ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
|
|
364
404
|
|
|
@@ -369,43 +409,6 @@ const channel = {
|
|
|
369
409
|
return;
|
|
370
410
|
}
|
|
371
411
|
|
|
372
|
-
// Write default values back to config so Control UI shows them
|
|
373
|
-
const runtime = pluginRuntime;
|
|
374
|
-
if (runtime) {
|
|
375
|
-
try {
|
|
376
|
-
const currentCfg = runtime.config.loadConfig();
|
|
377
|
-
const channelCfg = currentCfg.channels?.[CHANNEL_ID] ?? {};
|
|
378
|
-
const accounts = (channelCfg as any).accounts ?? {};
|
|
379
|
-
const existing = accounts[accountId] ?? {};
|
|
380
|
-
const needsWrite =
|
|
381
|
-
!existing.relayUrl ||
|
|
382
|
-
!existing.room;
|
|
383
|
-
if (needsWrite) {
|
|
384
|
-
const updatedCfg = {
|
|
385
|
-
...currentCfg,
|
|
386
|
-
channels: {
|
|
387
|
-
...currentCfg.channels,
|
|
388
|
-
[CHANNEL_ID]: {
|
|
389
|
-
...channelCfg,
|
|
390
|
-
accounts: {
|
|
391
|
-
...accounts,
|
|
392
|
-
[accountId]: {
|
|
393
|
-
...existing,
|
|
394
|
-
relayUrl: existing.relayUrl || account.relayUrl,
|
|
395
|
-
room: existing.room || account.room,
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
};
|
|
401
|
-
await runtime.config.writeConfigFile(updatedCfg);
|
|
402
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Wrote default config values`);
|
|
403
|
-
}
|
|
404
|
-
} catch (err) {
|
|
405
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Could not write default config: ${err}`);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
412
|
connectRelay(ctx, account);
|
|
410
413
|
|
|
411
414
|
const signal: AbortSignal | undefined = ctx.abortSignal;
|
|
@@ -420,8 +423,15 @@ const channel = {
|
|
|
420
423
|
},
|
|
421
424
|
stopAccount: async (ctx: any) => {
|
|
422
425
|
const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
423
|
-
|
|
424
|
-
|
|
426
|
+
const state = getRelayState(accountId);
|
|
427
|
+
cleanupRelay(state);
|
|
428
|
+
|
|
429
|
+
ctx.setStatus?.({
|
|
430
|
+
accountId,
|
|
431
|
+
running: false,
|
|
432
|
+
connected: false,
|
|
433
|
+
lastStopAt: new Date().toISOString(),
|
|
434
|
+
});
|
|
425
435
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
|
|
426
436
|
},
|
|
427
437
|
},
|
|
@@ -430,11 +440,20 @@ const channel = {
|
|
|
430
440
|
// ── Relay bridge ─────────────────────────────────────────────────────────────
|
|
431
441
|
|
|
432
442
|
function cleanupRelay(state: RelayState) {
|
|
433
|
-
if (state.pingTimer) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
443
|
+
if (state.pingTimer) {
|
|
444
|
+
clearInterval(state.pingTimer);
|
|
445
|
+
state.pingTimer = null;
|
|
446
|
+
}
|
|
447
|
+
if (state.reconnectTimer) {
|
|
448
|
+
clearTimeout(state.reconnectTimer);
|
|
449
|
+
state.reconnectTimer = null;
|
|
450
|
+
}
|
|
451
|
+
if (state.ws) {
|
|
452
|
+
try { state.ws.close(); } catch {}
|
|
453
|
+
state.ws = null;
|
|
454
|
+
}
|
|
455
|
+
// Clear all per-session E2E states — new handshakes needed on reconnect
|
|
456
|
+
state.e2eSessions.clear();
|
|
438
457
|
}
|
|
439
458
|
|
|
440
459
|
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
@@ -442,7 +461,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
442
461
|
const state = getRelayState(accountId);
|
|
443
462
|
|
|
444
463
|
const base = account.relayUrl.replace(/\/$/, "");
|
|
445
|
-
const params = new URLSearchParams({
|
|
464
|
+
const params = new URLSearchParams({
|
|
465
|
+
role: "plugin",
|
|
466
|
+
room: account.roomId,
|
|
467
|
+
});
|
|
446
468
|
if (account.relayToken) params.set("token", account.relayToken);
|
|
447
469
|
|
|
448
470
|
const url = `${base}/ws?${params.toString()}`;
|
|
@@ -460,25 +482,48 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
460
482
|
|
|
461
483
|
state.ws.addEventListener("open", () => {
|
|
462
484
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
|
|
463
|
-
if (state.reconnectTimer) {
|
|
485
|
+
if (state.reconnectTimer) {
|
|
486
|
+
clearTimeout(state.reconnectTimer);
|
|
487
|
+
state.reconnectTimer = null;
|
|
488
|
+
}
|
|
464
489
|
if (state.pingTimer) clearInterval(state.pingTimer);
|
|
465
490
|
state.pingTimer = setInterval(() => {
|
|
466
|
-
if (state.ws?.readyState === WebSocket.OPEN)
|
|
491
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
492
|
+
state.ws.send("ping");
|
|
493
|
+
}
|
|
467
494
|
}, PING_INTERVAL);
|
|
468
|
-
|
|
495
|
+
|
|
496
|
+
state.statusSink?.({
|
|
497
|
+
connected: true,
|
|
498
|
+
lastConnectedAt: new Date().toISOString(),
|
|
499
|
+
lastError: null,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// E2E handshakes are initiated per-session when `peer_joined` arrives
|
|
503
|
+
// (each app connection carries its own sessionKey UUID).
|
|
469
504
|
});
|
|
470
505
|
|
|
471
506
|
state.ws.addEventListener("message", (event: MessageEvent) => {
|
|
472
507
|
handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
|
|
473
|
-
ctx.log?.error?.(
|
|
508
|
+
ctx.log?.error?.(
|
|
509
|
+
`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
|
|
510
|
+
);
|
|
474
511
|
});
|
|
475
512
|
});
|
|
476
513
|
|
|
477
514
|
state.ws.addEventListener("close", () => {
|
|
478
|
-
ctx.log?.info?.(
|
|
515
|
+
ctx.log?.info?.(
|
|
516
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
|
|
517
|
+
);
|
|
479
518
|
state.ws = null;
|
|
480
|
-
if (state.pingTimer) {
|
|
481
|
-
|
|
519
|
+
if (state.pingTimer) {
|
|
520
|
+
clearInterval(state.pingTimer);
|
|
521
|
+
state.pingTimer = null;
|
|
522
|
+
}
|
|
523
|
+
state.statusSink?.({
|
|
524
|
+
connected: false,
|
|
525
|
+
lastDisconnect: new Date().toISOString(),
|
|
526
|
+
});
|
|
482
527
|
scheduleReconnect(ctx, account);
|
|
483
528
|
});
|
|
484
529
|
|
|
@@ -498,22 +543,29 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
|
498
543
|
}
|
|
499
544
|
|
|
500
545
|
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
546
|
+
// Skip ping/pong
|
|
501
547
|
if (raw === "ping" || raw === "pong") return;
|
|
502
548
|
|
|
503
549
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
|
|
504
550
|
|
|
505
551
|
const msg = JSON.parse(raw);
|
|
506
552
|
|
|
507
|
-
//
|
|
553
|
+
// App session joined — initiate per-session E2E handshake
|
|
508
554
|
if (msg.type === "peer_joined") {
|
|
509
555
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
556
|
+
if (!sessionKey) {
|
|
557
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
561
|
+
const sessionE2E = makeE2EState();
|
|
562
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
563
|
+
const handshakeMsg = await e2eInit(sessionE2E);
|
|
564
|
+
// Attach sessionKey so the relay can route the handshake back to the right app
|
|
565
|
+
const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
|
|
566
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
567
|
+
state.ws.send(handshakeWithSession);
|
|
568
|
+
}
|
|
517
569
|
return;
|
|
518
570
|
}
|
|
519
571
|
|
|
@@ -521,34 +573,44 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
521
573
|
if (msg.type === "handshake") {
|
|
522
574
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
523
575
|
const peerPubKey = msg.pubkey as string | undefined;
|
|
524
|
-
if (!peerPubKey) {
|
|
525
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
|
|
576
|
+
if (!sessionKey || !peerPubKey) {
|
|
577
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
|
|
526
578
|
return;
|
|
527
579
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
581
|
+
if (!sessionE2E) {
|
|
582
|
+
// App initiated handshake without a prior peer_joined (e.g. plugin reconnected)
|
|
583
|
+
sessionE2E = makeE2EState();
|
|
584
|
+
await e2eInit(sessionE2E);
|
|
585
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
586
|
+
}
|
|
587
|
+
await e2eHandleHandshake(sessionE2E, peerPubKey);
|
|
588
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
|
|
531
589
|
return;
|
|
532
590
|
}
|
|
533
591
|
|
|
534
|
-
//
|
|
592
|
+
// E2E encrypted message — decrypt using the per-session key
|
|
535
593
|
if (msg.type === "encrypted") {
|
|
536
594
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
595
|
+
if (!sessionKey) {
|
|
596
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const sessionE2E = state.e2eSessions.get(sessionKey);
|
|
600
|
+
if (!sessionE2E?.ready) {
|
|
601
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
|
|
540
602
|
return;
|
|
541
603
|
}
|
|
542
|
-
const plaintext = await e2eDecrypt(
|
|
543
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E]
|
|
604
|
+
const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
|
|
605
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
|
|
544
606
|
const innerMsg = JSON.parse(plaintext);
|
|
545
|
-
// Preserve sessionKey
|
|
546
|
-
if (
|
|
607
|
+
// Preserve sessionKey through decryption so handleInbound can use it
|
|
608
|
+
if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
|
|
547
609
|
await handleInbound(ctx, accountId, innerMsg);
|
|
548
610
|
return;
|
|
549
611
|
}
|
|
550
612
|
|
|
551
|
-
// Plaintext message
|
|
613
|
+
// Plaintext message (no E2E or during handshake)
|
|
552
614
|
await handleInbound(ctx, accountId, msg);
|
|
553
615
|
}
|
|
554
616
|
|
|
@@ -565,15 +627,13 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
565
627
|
|
|
566
628
|
const runtime = pluginRuntime;
|
|
567
629
|
if (!runtime) {
|
|
568
|
-
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available`);
|
|
630
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
|
|
569
631
|
return;
|
|
570
632
|
}
|
|
571
633
|
|
|
572
634
|
const senderId = msg.senderId ?? "mobile-user";
|
|
573
635
|
const senderName = msg.senderName ?? "Mobile User";
|
|
574
636
|
const text = String(msg.content);
|
|
575
|
-
// sessionKey from the relay-stamped message — used to route the reply back
|
|
576
|
-
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
577
637
|
|
|
578
638
|
try {
|
|
579
639
|
const cfg = runtime.config.loadConfig();
|
|
@@ -585,26 +645,33 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
585
645
|
peer: { kind: "direct", id: senderId },
|
|
586
646
|
});
|
|
587
647
|
|
|
588
|
-
//
|
|
589
|
-
// conversation has its own context.
|
|
590
|
-
|
|
648
|
+
// If the app specified a sessionKey (UUID-based), derive an isolated
|
|
649
|
+
// Gateway session so each mobile conversation has its own context.
|
|
650
|
+
// Format: "agent:main:mobile-<uuid>"
|
|
651
|
+
// No appSessionKey → use the route's mainSessionKey (default conversation).
|
|
652
|
+
let sessionKey: string;
|
|
653
|
+
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
591
654
|
if (appSessionKey) {
|
|
655
|
+
// Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
|
|
592
656
|
const mainKey = route.mainSessionKey as string;
|
|
593
|
-
const colonIdx = mainKey.lastIndexOf(
|
|
594
|
-
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) :
|
|
595
|
-
|
|
657
|
+
const colonIdx = mainKey.lastIndexOf(':');
|
|
658
|
+
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
|
|
659
|
+
sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
|
|
596
660
|
} else {
|
|
597
|
-
|
|
661
|
+
sessionKey = route.mainSessionKey;
|
|
598
662
|
}
|
|
599
|
-
|
|
600
663
|
const from = `${CHANNEL_ID}:${senderId}`;
|
|
601
664
|
const to = `user:${senderId}`;
|
|
602
665
|
|
|
603
|
-
runtime.channel.activity.record({
|
|
666
|
+
runtime.channel.activity.record({
|
|
667
|
+
channel: CHANNEL_ID,
|
|
668
|
+
accountId,
|
|
669
|
+
direction: "inbound",
|
|
670
|
+
});
|
|
604
671
|
|
|
605
672
|
runtime.system.enqueueSystemEvent(
|
|
606
673
|
`Mobile message from ${senderName}: ${text.slice(0, 160)}`,
|
|
607
|
-
{ sessionKey
|
|
674
|
+
{ sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
|
|
608
675
|
);
|
|
609
676
|
|
|
610
677
|
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
@@ -622,7 +689,7 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
622
689
|
CommandBody: text,
|
|
623
690
|
From: from,
|
|
624
691
|
To: to,
|
|
625
|
-
SessionKey:
|
|
692
|
+
SessionKey: sessionKey,
|
|
626
693
|
AccountId: accountId,
|
|
627
694
|
ChatType: "direct",
|
|
628
695
|
ConversationLabel: `Mobile DM from ${senderName}`,
|
|
@@ -634,8 +701,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
634
701
|
OriginatingTo: to,
|
|
635
702
|
});
|
|
636
703
|
|
|
637
|
-
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
638
|
-
|
|
704
|
+
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
705
|
+
cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
|
|
706
|
+
);
|
|
707
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
708
|
+
cfg, channel: CHANNEL_ID, accountId,
|
|
709
|
+
});
|
|
639
710
|
|
|
640
711
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
641
712
|
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
@@ -646,40 +717,58 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
646
717
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
647
718
|
return;
|
|
648
719
|
}
|
|
649
|
-
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
720
|
+
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
721
|
+
payload.text ?? "", tableMode,
|
|
722
|
+
);
|
|
650
723
|
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
651
724
|
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
725
|
+
// The relay routes plugin→app by sessionKey in the JSON payload
|
|
726
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
727
|
+
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
652
728
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
653
729
|
if (!chunk) continue;
|
|
654
730
|
const plainMsg = JSON.stringify({
|
|
655
731
|
type: "message",
|
|
656
732
|
role: "assistant",
|
|
657
733
|
content: chunk,
|
|
658
|
-
sessionKey:
|
|
734
|
+
sessionKey: replySessionKey,
|
|
659
735
|
});
|
|
660
|
-
|
|
661
|
-
const outMsg =
|
|
736
|
+
// Encrypt with the per-session key if handshake is complete
|
|
737
|
+
const outMsg = sessionE2E?.ready
|
|
738
|
+
? await e2eEncrypt(sessionE2E, plainMsg)
|
|
739
|
+
: plainMsg;
|
|
662
740
|
relayState.ws.send(outMsg);
|
|
663
741
|
}
|
|
664
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to
|
|
742
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
|
665
743
|
},
|
|
666
744
|
onError: (err: any, info: any) => {
|
|
667
745
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
|
|
668
746
|
},
|
|
669
747
|
});
|
|
670
748
|
|
|
671
|
-
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
749
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
750
|
+
ctx: ctxPayload,
|
|
751
|
+
cfg,
|
|
752
|
+
dispatcher,
|
|
753
|
+
replyOptions,
|
|
754
|
+
});
|
|
672
755
|
markDispatchIdle();
|
|
673
756
|
|
|
674
757
|
const sessionCfg = cfg.session;
|
|
675
|
-
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
758
|
+
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
759
|
+
agentId: route.agentId,
|
|
760
|
+
});
|
|
676
761
|
await runtime.channel.session.updateLastRoute({
|
|
677
762
|
storePath,
|
|
678
|
-
sessionKey
|
|
679
|
-
deliveryContext: {
|
|
763
|
+
sessionKey,
|
|
764
|
+
deliveryContext: {
|
|
765
|
+
channel: CHANNEL_ID,
|
|
766
|
+
to,
|
|
767
|
+
accountId,
|
|
768
|
+
},
|
|
680
769
|
});
|
|
681
770
|
|
|
682
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound processed
|
|
771
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
|
|
683
772
|
} catch (e) {
|
|
684
773
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
|
|
685
774
|
}
|
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.3",
|
|
4
|
+
"description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"type": "module",
|
|
25
7
|
"openclaw": {
|
|
26
|
-
|
|
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
|
+
}
|