openclaw-mobile 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -73
- package/index.ts +254 -160
- 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
|
|
|
@@ -383,8 +423,15 @@ const channel = {
|
|
|
383
423
|
},
|
|
384
424
|
stopAccount: async (ctx: any) => {
|
|
385
425
|
const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
386
|
-
|
|
387
|
-
|
|
426
|
+
const state = getRelayState(accountId);
|
|
427
|
+
cleanupRelay(state);
|
|
428
|
+
|
|
429
|
+
ctx.setStatus?.({
|
|
430
|
+
accountId,
|
|
431
|
+
running: false,
|
|
432
|
+
connected: false,
|
|
433
|
+
lastStopAt: new Date().toISOString(),
|
|
434
|
+
});
|
|
388
435
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
|
|
389
436
|
},
|
|
390
437
|
},
|
|
@@ -393,11 +440,20 @@ const channel = {
|
|
|
393
440
|
// ── Relay bridge ─────────────────────────────────────────────────────────────
|
|
394
441
|
|
|
395
442
|
function cleanupRelay(state: RelayState) {
|
|
396
|
-
if (state.pingTimer) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
443
|
+
if (state.pingTimer) {
|
|
444
|
+
clearInterval(state.pingTimer);
|
|
445
|
+
state.pingTimer = null;
|
|
446
|
+
}
|
|
447
|
+
if (state.reconnectTimer) {
|
|
448
|
+
clearTimeout(state.reconnectTimer);
|
|
449
|
+
state.reconnectTimer = null;
|
|
450
|
+
}
|
|
451
|
+
if (state.ws) {
|
|
452
|
+
try { state.ws.close(); } catch {}
|
|
453
|
+
state.ws = null;
|
|
454
|
+
}
|
|
455
|
+
// Clear all per-session E2E states — new handshakes needed on reconnect
|
|
456
|
+
state.e2eSessions.clear();
|
|
401
457
|
}
|
|
402
458
|
|
|
403
459
|
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
@@ -405,7 +461,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
405
461
|
const state = getRelayState(accountId);
|
|
406
462
|
|
|
407
463
|
const base = account.relayUrl.replace(/\/$/, "");
|
|
408
|
-
const params = new URLSearchParams({
|
|
464
|
+
const params = new URLSearchParams({
|
|
465
|
+
role: "plugin",
|
|
466
|
+
room: account.roomId,
|
|
467
|
+
});
|
|
409
468
|
if (account.relayToken) params.set("token", account.relayToken);
|
|
410
469
|
|
|
411
470
|
const url = `${base}/ws?${params.toString()}`;
|
|
@@ -423,25 +482,48 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
423
482
|
|
|
424
483
|
state.ws.addEventListener("open", () => {
|
|
425
484
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
|
|
426
|
-
if (state.reconnectTimer) {
|
|
485
|
+
if (state.reconnectTimer) {
|
|
486
|
+
clearTimeout(state.reconnectTimer);
|
|
487
|
+
state.reconnectTimer = null;
|
|
488
|
+
}
|
|
427
489
|
if (state.pingTimer) clearInterval(state.pingTimer);
|
|
428
490
|
state.pingTimer = setInterval(() => {
|
|
429
|
-
if (state.ws?.readyState === WebSocket.OPEN)
|
|
491
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
492
|
+
state.ws.send("ping");
|
|
493
|
+
}
|
|
430
494
|
}, PING_INTERVAL);
|
|
431
|
-
|
|
495
|
+
|
|
496
|
+
state.statusSink?.({
|
|
497
|
+
connected: true,
|
|
498
|
+
lastConnectedAt: new Date().toISOString(),
|
|
499
|
+
lastError: null,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// E2E handshakes are initiated per-session when `peer_joined` arrives
|
|
503
|
+
// (each app connection carries its own sessionKey UUID).
|
|
432
504
|
});
|
|
433
505
|
|
|
434
506
|
state.ws.addEventListener("message", (event: MessageEvent) => {
|
|
435
507
|
handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
|
|
436
|
-
ctx.log?.error?.(
|
|
508
|
+
ctx.log?.error?.(
|
|
509
|
+
`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
|
|
510
|
+
);
|
|
437
511
|
});
|
|
438
512
|
});
|
|
439
513
|
|
|
440
514
|
state.ws.addEventListener("close", () => {
|
|
441
|
-
ctx.log?.info?.(
|
|
515
|
+
ctx.log?.info?.(
|
|
516
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
|
|
517
|
+
);
|
|
442
518
|
state.ws = null;
|
|
443
|
-
if (state.pingTimer) {
|
|
444
|
-
|
|
519
|
+
if (state.pingTimer) {
|
|
520
|
+
clearInterval(state.pingTimer);
|
|
521
|
+
state.pingTimer = null;
|
|
522
|
+
}
|
|
523
|
+
state.statusSink?.({
|
|
524
|
+
connected: false,
|
|
525
|
+
lastDisconnect: new Date().toISOString(),
|
|
526
|
+
});
|
|
445
527
|
scheduleReconnect(ctx, account);
|
|
446
528
|
});
|
|
447
529
|
|
|
@@ -461,22 +543,29 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
|
461
543
|
}
|
|
462
544
|
|
|
463
545
|
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
546
|
+
// Skip ping/pong
|
|
464
547
|
if (raw === "ping" || raw === "pong") return;
|
|
465
548
|
|
|
466
549
|
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
|
|
467
550
|
|
|
468
551
|
const msg = JSON.parse(raw);
|
|
469
552
|
|
|
470
|
-
//
|
|
553
|
+
// App session joined — initiate per-session E2E handshake
|
|
471
554
|
if (msg.type === "peer_joined") {
|
|
472
555
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
556
|
+
if (!sessionKey) {
|
|
557
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
561
|
+
const sessionE2E = makeE2EState();
|
|
562
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
563
|
+
const handshakeMsg = await e2eInit(sessionE2E);
|
|
564
|
+
// Attach sessionKey so the relay can route the handshake back to the right app
|
|
565
|
+
const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
|
|
566
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
567
|
+
state.ws.send(handshakeWithSession);
|
|
568
|
+
}
|
|
480
569
|
return;
|
|
481
570
|
}
|
|
482
571
|
|
|
@@ -484,34 +573,44 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
484
573
|
if (msg.type === "handshake") {
|
|
485
574
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
486
575
|
const peerPubKey = msg.pubkey as string | undefined;
|
|
487
|
-
if (!peerPubKey) {
|
|
488
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
|
|
576
|
+
if (!sessionKey || !peerPubKey) {
|
|
577
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
|
|
489
578
|
return;
|
|
490
579
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
580
|
+
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
581
|
+
if (!sessionE2E) {
|
|
582
|
+
// App initiated handshake without a prior peer_joined (e.g. plugin reconnected)
|
|
583
|
+
sessionE2E = makeE2EState();
|
|
584
|
+
await e2eInit(sessionE2E);
|
|
585
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
586
|
+
}
|
|
587
|
+
await e2eHandleHandshake(sessionE2E, peerPubKey);
|
|
588
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
|
|
494
589
|
return;
|
|
495
590
|
}
|
|
496
591
|
|
|
497
|
-
//
|
|
592
|
+
// E2E encrypted message — decrypt using the per-session key
|
|
498
593
|
if (msg.type === "encrypted") {
|
|
499
594
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
595
|
+
if (!sessionKey) {
|
|
596
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const sessionE2E = state.e2eSessions.get(sessionKey);
|
|
600
|
+
if (!sessionE2E?.ready) {
|
|
601
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
|
|
503
602
|
return;
|
|
504
603
|
}
|
|
505
|
-
const plaintext = await e2eDecrypt(
|
|
506
|
-
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)}`);
|
|
507
606
|
const innerMsg = JSON.parse(plaintext);
|
|
508
|
-
// Preserve sessionKey
|
|
509
|
-
if (
|
|
607
|
+
// Preserve sessionKey through decryption so handleInbound can use it
|
|
608
|
+
if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
|
|
510
609
|
await handleInbound(ctx, accountId, innerMsg);
|
|
511
610
|
return;
|
|
512
611
|
}
|
|
513
612
|
|
|
514
|
-
// Plaintext message
|
|
613
|
+
// Plaintext message (no E2E or during handshake)
|
|
515
614
|
await handleInbound(ctx, accountId, msg);
|
|
516
615
|
}
|
|
517
616
|
|
|
@@ -528,15 +627,13 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
528
627
|
|
|
529
628
|
const runtime = pluginRuntime;
|
|
530
629
|
if (!runtime) {
|
|
531
|
-
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available`);
|
|
630
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
|
|
532
631
|
return;
|
|
533
632
|
}
|
|
534
633
|
|
|
535
634
|
const senderId = msg.senderId ?? "mobile-user";
|
|
536
635
|
const senderName = msg.senderName ?? "Mobile User";
|
|
537
636
|
const text = String(msg.content);
|
|
538
|
-
// sessionKey from the relay-stamped message — used to route the reply back
|
|
539
|
-
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
540
637
|
|
|
541
638
|
try {
|
|
542
639
|
const cfg = runtime.config.loadConfig();
|
|
@@ -548,26 +645,33 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
548
645
|
peer: { kind: "direct", id: senderId },
|
|
549
646
|
});
|
|
550
647
|
|
|
551
|
-
//
|
|
552
|
-
// conversation has its own context.
|
|
553
|
-
|
|
648
|
+
// If the app specified a sessionKey (UUID-based), derive an isolated
|
|
649
|
+
// Gateway session so each mobile conversation has its own context.
|
|
650
|
+
// Format: "agent:main:mobile-<uuid>"
|
|
651
|
+
// No appSessionKey → use the route's mainSessionKey (default conversation).
|
|
652
|
+
let sessionKey: string;
|
|
653
|
+
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
554
654
|
if (appSessionKey) {
|
|
655
|
+
// Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
|
|
555
656
|
const mainKey = route.mainSessionKey as string;
|
|
556
|
-
const colonIdx = mainKey.lastIndexOf(
|
|
557
|
-
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) :
|
|
558
|
-
|
|
657
|
+
const colonIdx = mainKey.lastIndexOf(':');
|
|
658
|
+
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
|
|
659
|
+
sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
|
|
559
660
|
} else {
|
|
560
|
-
|
|
661
|
+
sessionKey = route.mainSessionKey;
|
|
561
662
|
}
|
|
562
|
-
|
|
563
663
|
const from = `${CHANNEL_ID}:${senderId}`;
|
|
564
664
|
const to = `user:${senderId}`;
|
|
565
665
|
|
|
566
|
-
runtime.channel.activity.record({
|
|
666
|
+
runtime.channel.activity.record({
|
|
667
|
+
channel: CHANNEL_ID,
|
|
668
|
+
accountId,
|
|
669
|
+
direction: "inbound",
|
|
670
|
+
});
|
|
567
671
|
|
|
568
672
|
runtime.system.enqueueSystemEvent(
|
|
569
673
|
`Mobile message from ${senderName}: ${text.slice(0, 160)}`,
|
|
570
|
-
{ sessionKey
|
|
674
|
+
{ sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
|
|
571
675
|
);
|
|
572
676
|
|
|
573
677
|
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
@@ -585,7 +689,7 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
585
689
|
CommandBody: text,
|
|
586
690
|
From: from,
|
|
587
691
|
To: to,
|
|
588
|
-
SessionKey:
|
|
692
|
+
SessionKey: sessionKey,
|
|
589
693
|
AccountId: accountId,
|
|
590
694
|
ChatType: "direct",
|
|
591
695
|
ConversationLabel: `Mobile DM from ${senderName}`,
|
|
@@ -597,8 +701,12 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
597
701
|
OriginatingTo: to,
|
|
598
702
|
});
|
|
599
703
|
|
|
600
|
-
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
601
|
-
|
|
704
|
+
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
705
|
+
cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
|
|
706
|
+
);
|
|
707
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
708
|
+
cfg, channel: CHANNEL_ID, accountId,
|
|
709
|
+
});
|
|
602
710
|
|
|
603
711
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
604
712
|
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
@@ -609,40 +717,58 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
609
717
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
610
718
|
return;
|
|
611
719
|
}
|
|
612
|
-
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
720
|
+
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
721
|
+
payload.text ?? "", tableMode,
|
|
722
|
+
);
|
|
613
723
|
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
614
724
|
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
725
|
+
// The relay routes plugin→app by sessionKey in the JSON payload
|
|
726
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
727
|
+
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
615
728
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
616
729
|
if (!chunk) continue;
|
|
617
730
|
const plainMsg = JSON.stringify({
|
|
618
731
|
type: "message",
|
|
619
732
|
role: "assistant",
|
|
620
733
|
content: chunk,
|
|
621
|
-
sessionKey:
|
|
734
|
+
sessionKey: replySessionKey,
|
|
622
735
|
});
|
|
623
|
-
|
|
624
|
-
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;
|
|
625
740
|
relayState.ws.send(outMsg);
|
|
626
741
|
}
|
|
627
|
-
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}`);
|
|
628
743
|
},
|
|
629
744
|
onError: (err: any, info: any) => {
|
|
630
745
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
|
|
631
746
|
},
|
|
632
747
|
});
|
|
633
748
|
|
|
634
|
-
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
749
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
750
|
+
ctx: ctxPayload,
|
|
751
|
+
cfg,
|
|
752
|
+
dispatcher,
|
|
753
|
+
replyOptions,
|
|
754
|
+
});
|
|
635
755
|
markDispatchIdle();
|
|
636
756
|
|
|
637
757
|
const sessionCfg = cfg.session;
|
|
638
|
-
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
758
|
+
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
759
|
+
agentId: route.agentId,
|
|
760
|
+
});
|
|
639
761
|
await runtime.channel.session.updateLastRoute({
|
|
640
762
|
storePath,
|
|
641
|
-
sessionKey
|
|
642
|
-
deliveryContext: {
|
|
763
|
+
sessionKey,
|
|
764
|
+
deliveryContext: {
|
|
765
|
+
channel: CHANNEL_ID,
|
|
766
|
+
to,
|
|
767
|
+
accountId,
|
|
768
|
+
},
|
|
643
769
|
});
|
|
644
770
|
|
|
645
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound processed
|
|
771
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
|
|
646
772
|
} catch (e) {
|
|
647
773
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
|
|
648
774
|
}
|
|
@@ -654,36 +780,4 @@ export default function register(api: any) {
|
|
|
654
780
|
pluginRuntime = api.runtime;
|
|
655
781
|
api.registerChannel({ plugin: channel });
|
|
656
782
|
api.logger?.info?.("[openclaw-mobile] Plugin registered");
|
|
657
|
-
|
|
658
|
-
// On first install (no channel config yet), write the default account so
|
|
659
|
-
// Control UI shows it immediately without requiring a gateway restart.
|
|
660
|
-
try {
|
|
661
|
-
const cfg = api.runtime.config.loadConfig();
|
|
662
|
-
const channelCfg = cfg.channels?.[CHANNEL_ID] ?? {};
|
|
663
|
-
const accounts = (channelCfg as any).accounts ?? {};
|
|
664
|
-
if (!accounts[DEFAULT_ACCOUNT_ID]) {
|
|
665
|
-
const updatedCfg = {
|
|
666
|
-
...cfg,
|
|
667
|
-
channels: {
|
|
668
|
-
...cfg.channels,
|
|
669
|
-
[CHANNEL_ID]: {
|
|
670
|
-
...channelCfg,
|
|
671
|
-
accounts: {
|
|
672
|
-
...accounts,
|
|
673
|
-
[DEFAULT_ACCOUNT_ID]: {
|
|
674
|
-
enabled: true,
|
|
675
|
-
relayUrl: DEFAULT_RELAY_URL,
|
|
676
|
-
relayToken: "",
|
|
677
|
-
room: "default",
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
},
|
|
681
|
-
},
|
|
682
|
-
};
|
|
683
|
-
api.runtime.config.writeConfigFile(updatedCfg);
|
|
684
|
-
api.logger?.info?.(`[${CHANNEL_ID}] Wrote default account config on first install`);
|
|
685
|
-
}
|
|
686
|
-
} catch (err) {
|
|
687
|
-
api.logger?.warn?.(`[${CHANNEL_ID}] Could not write default account config: ${err}`);
|
|
688
|
-
}
|
|
689
783
|
}
|
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
|
+
}
|