openclaw-mobile 1.0.0
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 +223 -0
- package/index.ts +837 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# openclaw-mobile
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin for the Mobile app. Routes messages through a Cloudflare Worker relay.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Mobile App ←WS→ [CF Worker / Durable Object] ←WS→ [OpenClaw Plugin] → Gateway Pipeline
|
|
9
|
+
```
|
|
10
|
+
|
|
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
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- OpenClaw Gateway installed and running (`npm install -g openclaw@latest`)
|
|
22
|
+
- A deployed Cloudflare Worker relay (see `../relay/`)
|
|
23
|
+
- Node.js 22+
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
From npm (when published):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
openclaw plugins install openclaw-mobile
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For local development:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
openclaw plugins install -l ./plugin
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### 1. Deploy the relay
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd relay
|
|
45
|
+
npm install
|
|
46
|
+
npx wrangler deploy
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Note the deployed URL (e.g. `wss://openclaw-relay.your-name.workers.dev`).
|
|
50
|
+
|
|
51
|
+
If you want relay authentication, set a secret:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx wrangler secret put RELAY_TOKEN
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. Install the plugin
|
|
58
|
+
|
|
59
|
+
From npm:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
openclaw plugins install openclaw-mobile
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For local development:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
openclaw plugins install -l ./plugin
|
|
69
|
+
cd plugin && npm install # installs qrcode-terminal for inline QR display
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> When installing from npm, `qrcode-terminal` is installed automatically as an optional dependency.
|
|
73
|
+
|
|
74
|
+
### 3. Configure
|
|
75
|
+
|
|
76
|
+
Add to `~/.openclaw/openclaw.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"channels": {
|
|
81
|
+
"openclaw-mobile": {
|
|
82
|
+
"accounts": {
|
|
83
|
+
"default": {
|
|
84
|
+
"enabled": true,
|
|
85
|
+
"relayUrl": "wss://openclaw-relay.your-name.workers.dev",
|
|
86
|
+
"relayToken": "your-secret-token",
|
|
87
|
+
"roomId": "default"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> **Tip:** Leave `roomId` as `"default"` — the plugin will auto-generate a unique UUID on first start and save it to config automatically.
|
|
96
|
+
|
|
97
|
+
### 4. Start the Gateway
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
openclaw gateway --port 18789
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
On startup, the plugin prints a pairing QR code to the terminal:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
┌──────────────────────────────────────────────────┐
|
|
107
|
+
│ OpenClaw Mobile — Scan to connect (account: default)
|
|
108
|
+
├──────────────────────────────────────────────────┤
|
|
109
|
+
│ ▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄ │
|
|
110
|
+
│ █ █ ██▄▄█ █ █ │
|
|
111
|
+
│ ... │
|
|
112
|
+
├──────────────────────────────────────────────────┤
|
|
113
|
+
│ openclaw://relay?url=wss%3A%2F%2F...&room=... │
|
|
114
|
+
└──────────────────────────────────────────────────┘
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 5. Pair the mobile app
|
|
118
|
+
|
|
119
|
+
Open the **OpenClaw Mobile** app, tap **Scan QR Code**, and point the camera at the QR code printed in the terminal. The app will automatically connect to the relay using the correct room ID.
|
|
120
|
+
|
|
121
|
+
### 6. Verify
|
|
122
|
+
|
|
123
|
+
Open the Control UI at http://127.0.0.1:18789/ and navigate to the **OpenClaw Mobile** channel page. You should see:
|
|
124
|
+
|
|
125
|
+
- **Running**: Yes
|
|
126
|
+
- **Configured**: Yes
|
|
127
|
+
- **Connected**: Yes
|
|
128
|
+
|
|
129
|
+
You can also check logs:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
All config lives under `channels.openclaw-mobile.accounts.<accountId>` in `~/.openclaw/openclaw.json`.
|
|
138
|
+
|
|
139
|
+
| Field | Type | Required | Default | Description |
|
|
140
|
+
|-------|------|----------|---------|-------------|
|
|
141
|
+
| `enabled` | boolean | No | `true` | Enable/disable this account |
|
|
142
|
+
| `relayUrl` | string | **Yes** | — | CF Worker relay WebSocket URL |
|
|
143
|
+
| `relayToken` | string | No | `""` | Shared secret for relay authentication (must match the Worker's `RELAY_TOKEN` secret) |
|
|
144
|
+
| `roomId` | string | No | `"default"` | Room ID for relay routing (isolates conversations) |
|
|
145
|
+
|
|
146
|
+
You can also edit these fields directly in the Control UI config form.
|
|
147
|
+
|
|
148
|
+
## Control UI
|
|
149
|
+
|
|
150
|
+
The plugin integrates with the OpenClaw Control UI:
|
|
151
|
+
|
|
152
|
+
- **Status panel** — shows Running, Configured, Connected, Last inbound, and error details
|
|
153
|
+
- **Config form** — editable fields for Relay URL, Relay Token, Room ID, and an Enable/Disable toggle
|
|
154
|
+
- **Enable/Disable** — the toggle in the UI writes `enabled: true/false` to the config and restarts the channel
|
|
155
|
+
|
|
156
|
+
## How the relay works
|
|
157
|
+
|
|
158
|
+
The relay is a Cloudflare Worker with a Durable Object (`RelayRoom`):
|
|
159
|
+
|
|
160
|
+
- Each "room" is a named DO instance that bridges two WebSocket roles: `plugin` (the Gateway) and `app` (the mobile client)
|
|
161
|
+
- Messages from one role are forwarded to all peers of the opposite role
|
|
162
|
+
- The DO uses `setWebSocketAutoResponse("ping", "pong")` for keepalive without waking from hibernation
|
|
163
|
+
- The plugin sends a `ping` every 30 seconds to prevent idle disconnection
|
|
164
|
+
- Optional `RELAY_TOKEN` secret gates access to the relay
|
|
165
|
+
|
|
166
|
+
## Message Protocol
|
|
167
|
+
|
|
168
|
+
### App → Plugin (via relay)
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"type": "message",
|
|
173
|
+
"content": "Hello",
|
|
174
|
+
"sessionKey": "relay-session",
|
|
175
|
+
"senderId": "mobile-user",
|
|
176
|
+
"senderName": "Mobile User"
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- `sessionKey` — the app's local session identifier; the plugin echoes it back in replies so the app can match them
|
|
181
|
+
- `senderId` / `senderName` — optional; defaults to `"mobile-user"` / `"Mobile User"`
|
|
182
|
+
|
|
183
|
+
### Plugin → App (via relay)
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"type": "message",
|
|
188
|
+
"role": "assistant",
|
|
189
|
+
"content": "AI reply text",
|
|
190
|
+
"sessionKey": "relay-session"
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
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.
|
|
195
|
+
|
|
196
|
+
## Troubleshooting
|
|
197
|
+
|
|
198
|
+
| Problem | Cause | Fix |
|
|
199
|
+
|---------|-------|-----|
|
|
200
|
+
| "Channel config schema unavailable" in Control UI | Gateway loaded before plugin was installed | Restart the Gateway: `openclaw gateway stop && openclaw gateway` |
|
|
201
|
+
| Running: No | `startAccount` returned early (old plugin version) | Update the plugin and restart Gateway |
|
|
202
|
+
| 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` |
|
|
203
|
+
| Relay keeps disconnecting | Ping keepalive not working or network issue | Check logs for "WebSocket error"; ensure relay Worker is deployed with Durable Objects enabled |
|
|
204
|
+
| "relayUrl not configured" in logs | Missing `relayUrl` in account config | Add `relayUrl` under `channels.openclaw-mobile.accounts.default` |
|
|
205
|
+
| Enable toggle in UI doesn't match JSON | Missing `setAccountEnabled` adapter (old plugin version) | Update the plugin and restart Gateway |
|
|
206
|
+
| `relayToken` mismatch | Token in config doesn't match Worker secret | Ensure `relayToken` matches the `RELAY_TOKEN` secret set on the Worker |
|
|
207
|
+
| App stuck loading, no reply shown | sessionKey mismatch between app and plugin | Ensure app sends `sessionKey` in message payload and plugin version ≥ current |
|
|
208
|
+
|
|
209
|
+
### Useful commands
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Check plugin is loaded
|
|
213
|
+
openclaw plugins list
|
|
214
|
+
|
|
215
|
+
# Check channel status
|
|
216
|
+
openclaw channels status --probe
|
|
217
|
+
|
|
218
|
+
# Live logs
|
|
219
|
+
tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
|
|
220
|
+
|
|
221
|
+
# Verify relay is reachable
|
|
222
|
+
curl https://openclaw-relay.your-name.workers.dev/health
|
|
223
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Mobile Channel Plugin
|
|
3
|
+
*
|
|
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
|
+
*
|
|
7
|
+
* On first start, if roomId is "default", a UUID is auto-generated and saved to config.
|
|
8
|
+
* A QR code is printed to the terminal so the mobile app can scan and connect.
|
|
9
|
+
*
|
|
10
|
+
* Config (in ~/.openclaw/openclaw.json):
|
|
11
|
+
* {
|
|
12
|
+
* "channels": {
|
|
13
|
+
* "openclaw-mobile": {
|
|
14
|
+
* "accounts": {
|
|
15
|
+
* "default": {
|
|
16
|
+
* "enabled": true,
|
|
17
|
+
* "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
|
|
18
|
+
* "relayToken": "your-secret",
|
|
19
|
+
* "roomId": "auto-generated-uuid"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
|
|
29
|
+
// ── Plugin runtime (set during register) ────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
let pluginRuntime: any = null;
|
|
32
|
+
|
|
33
|
+
// ── E2E Encryption (X25519 ECDH + AES-256-GCM) ──────────────────────────────
|
|
34
|
+
// Uses Web Crypto API. Node 18+ uses standalone "X25519" algorithm name,
|
|
35
|
+
// while browsers use { name: "ECDH", namedCurve: "X25519" }.
|
|
36
|
+
|
|
37
|
+
// Runtime detection — resolved once on first use
|
|
38
|
+
let _x25519Algo: { gen: any; imp: any; derive: string } | null = null;
|
|
39
|
+
async function getX25519Algo() {
|
|
40
|
+
if (_x25519Algo) return _x25519Algo;
|
|
41
|
+
try {
|
|
42
|
+
await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "X25519" } as any, true, ["deriveKey"]);
|
|
43
|
+
_x25519Algo = { gen: { name: "ECDH", namedCurve: "X25519" }, imp: { name: "ECDH", namedCurve: "X25519" }, derive: "ECDH" };
|
|
44
|
+
} catch {
|
|
45
|
+
_x25519Algo = { gen: { name: "X25519" }, imp: { name: "X25519" }, derive: "X25519" };
|
|
46
|
+
}
|
|
47
|
+
return _x25519Algo;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface E2EState {
|
|
51
|
+
localKeyPair: CryptoKeyPair | null;
|
|
52
|
+
sharedKey: CryptoKey | null;
|
|
53
|
+
ready: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeE2EState(): E2EState {
|
|
57
|
+
return { localKeyPair: null, sharedKey: null, ready: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function e2eInit(state: E2EState): Promise<string> {
|
|
61
|
+
const algo = await getX25519Algo();
|
|
62
|
+
state.localKeyPair = await crypto.subtle.generateKey(
|
|
63
|
+
algo.gen,
|
|
64
|
+
true,
|
|
65
|
+
["deriveKey"]
|
|
66
|
+
);
|
|
67
|
+
const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
|
|
68
|
+
const pubKeyB64 = bufToBase64Url(pubKeyRaw);
|
|
69
|
+
return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
|
|
73
|
+
if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
|
|
74
|
+
const algo = await getX25519Algo();
|
|
75
|
+
|
|
76
|
+
const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
|
|
77
|
+
const peerPublicKey = await crypto.subtle.importKey(
|
|
78
|
+
"raw",
|
|
79
|
+
peerPubKeyBytes.buffer as ArrayBuffer,
|
|
80
|
+
algo.imp,
|
|
81
|
+
false,
|
|
82
|
+
[]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const ecdhRawKey = await crypto.subtle.deriveKey(
|
|
86
|
+
{ name: algo.derive, public: peerPublicKey },
|
|
87
|
+
state.localKeyPair.privateKey,
|
|
88
|
+
{ name: "AES-GCM", length: 256 },
|
|
89
|
+
true,
|
|
90
|
+
["encrypt", "decrypt"]
|
|
91
|
+
);
|
|
92
|
+
const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
|
|
93
|
+
|
|
94
|
+
// HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
|
|
95
|
+
const hkdfKey = await crypto.subtle.importKey(
|
|
96
|
+
"raw", ecdhRawBytes as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]
|
|
97
|
+
);
|
|
98
|
+
state.sharedKey = await crypto.subtle.deriveKey(
|
|
99
|
+
{
|
|
100
|
+
name: "HKDF",
|
|
101
|
+
hash: "SHA-256",
|
|
102
|
+
salt: new Uint8Array(0),
|
|
103
|
+
info: new TextEncoder().encode("openclaw-e2e-v1"),
|
|
104
|
+
},
|
|
105
|
+
hkdfKey,
|
|
106
|
+
{ name: "AES-GCM", length: 256 },
|
|
107
|
+
false,
|
|
108
|
+
["encrypt", "decrypt"]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
state.ready = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
|
|
115
|
+
if (!state.sharedKey) throw new Error("[E2E] Not ready");
|
|
116
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
117
|
+
const ct = await crypto.subtle.encrypt(
|
|
118
|
+
{ name: "AES-GCM", iv: nonce },
|
|
119
|
+
state.sharedKey,
|
|
120
|
+
new TextEncoder().encode(plaintext)
|
|
121
|
+
);
|
|
122
|
+
return JSON.stringify({
|
|
123
|
+
type: "encrypted",
|
|
124
|
+
nonce: bufToBase64Url(nonce),
|
|
125
|
+
ct: bufToBase64Url(ct),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Promise<string> {
|
|
130
|
+
if (!state.sharedKey) throw new Error("[E2E] Not ready");
|
|
131
|
+
const nonce = base64UrlToBuf(nonceB64);
|
|
132
|
+
const ct = base64UrlToBuf(ctB64);
|
|
133
|
+
const plain = await crypto.subtle.decrypt(
|
|
134
|
+
{ name: "AES-GCM", iv: nonce.buffer as ArrayBuffer },
|
|
135
|
+
state.sharedKey,
|
|
136
|
+
ct.buffer as ArrayBuffer
|
|
137
|
+
);
|
|
138
|
+
return new TextDecoder().decode(plain);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function bufToBase64Url(buf: ArrayBuffer | Uint8Array): string {
|
|
142
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
143
|
+
let binary = "";
|
|
144
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
145
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function base64UrlToBuf(b64: string): Uint8Array {
|
|
149
|
+
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
150
|
+
const pad = (4 - padded.length % 4) % 4;
|
|
151
|
+
const binary = atob(padded + "=".repeat(pad));
|
|
152
|
+
const bytes = new Uint8Array(binary.length);
|
|
153
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Relay state (per account) ────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
interface RelayState {
|
|
160
|
+
ws: WebSocket | null;
|
|
161
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
162
|
+
pingTimer: ReturnType<typeof setInterval> | null;
|
|
163
|
+
statusSink: ((patch: Record<string, unknown>) => void) | null;
|
|
164
|
+
gatewayCtx: any;
|
|
165
|
+
/** Last sessionKey received from the app — echoed back in replies */
|
|
166
|
+
appSessionKey: string | null;
|
|
167
|
+
/** E2E encryption state for this account */
|
|
168
|
+
e2e: E2EState;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const relayStates = new Map<string, RelayState>();
|
|
172
|
+
|
|
173
|
+
const RECONNECT_DELAY = 5000;
|
|
174
|
+
const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
|
|
175
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
176
|
+
const CHANNEL_ID = "openclaw-mobile";
|
|
177
|
+
|
|
178
|
+
function getRelayState(accountId: string): RelayState {
|
|
179
|
+
let state = relayStates.get(accountId);
|
|
180
|
+
if (!state) {
|
|
181
|
+
state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, appSessionKey: null, e2e: makeE2EState() };
|
|
182
|
+
relayStates.set(accountId, state);
|
|
183
|
+
}
|
|
184
|
+
return state;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Account resolution ───────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
interface ResolvedAccount {
|
|
190
|
+
accountId: string;
|
|
191
|
+
enabled: boolean;
|
|
192
|
+
relayUrl: string;
|
|
193
|
+
relayToken: string;
|
|
194
|
+
roomId: string;
|
|
195
|
+
configured: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function listAccountIds(cfg: any): string[] {
|
|
199
|
+
return Object.keys(
|
|
200
|
+
cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
|
|
205
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
206
|
+
const acct =
|
|
207
|
+
cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
|
|
208
|
+
return {
|
|
209
|
+
accountId: id,
|
|
210
|
+
enabled: acct.enabled !== false,
|
|
211
|
+
relayUrl: acct.relayUrl ?? "",
|
|
212
|
+
relayToken: acct.relayToken ?? "",
|
|
213
|
+
roomId: acct.roomId ?? "default",
|
|
214
|
+
configured: Boolean(acct.relayUrl),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Channel config schema (JSON Schema for Control UI) ───────────────────────
|
|
219
|
+
|
|
220
|
+
const mobileConfigSchema = {
|
|
221
|
+
schema: {
|
|
222
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
223
|
+
type: "object" as const,
|
|
224
|
+
properties: {
|
|
225
|
+
accounts: {
|
|
226
|
+
type: "object" as const,
|
|
227
|
+
additionalProperties: {
|
|
228
|
+
type: "object" as const,
|
|
229
|
+
properties: {
|
|
230
|
+
enabled: { type: "boolean" as const },
|
|
231
|
+
relayUrl: {
|
|
232
|
+
type: "string" as const,
|
|
233
|
+
description: "CF Worker relay WebSocket URL",
|
|
234
|
+
},
|
|
235
|
+
relayToken: {
|
|
236
|
+
type: "string" as const,
|
|
237
|
+
description: "Shared secret for relay authentication",
|
|
238
|
+
},
|
|
239
|
+
roomId: {
|
|
240
|
+
type: "string" as const,
|
|
241
|
+
description: "Room ID for relay routing",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
},
|
|
250
|
+
uiHints: {
|
|
251
|
+
relayUrl: {
|
|
252
|
+
label: "Relay URL",
|
|
253
|
+
placeholder: "wss://openclaw-relay.xxx.workers.dev",
|
|
254
|
+
},
|
|
255
|
+
relayToken: {
|
|
256
|
+
label: "Relay Token",
|
|
257
|
+
sensitive: true,
|
|
258
|
+
},
|
|
259
|
+
roomId: {
|
|
260
|
+
label: "Room ID",
|
|
261
|
+
placeholder: "default",
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// ── QR Code pairing helper ───────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build the openclaw://relay URI that the mobile app scans to connect.
|
|
270
|
+
*/
|
|
271
|
+
function buildPairingUri(relayUrl: string, roomId: string, relayToken?: string): string {
|
|
272
|
+
const params = new URLSearchParams({ url: relayUrl, room: roomId });
|
|
273
|
+
if (relayToken) params.set("token", relayToken);
|
|
274
|
+
return `openclaw://relay?${params.toString()}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Print a QR code + pairing URI to stdout.
|
|
279
|
+
* Uses qrcode-terminal if available; falls back to plain text.
|
|
280
|
+
*/
|
|
281
|
+
async function printPairingQr(uri: string, accountId: string): Promise<void> {
|
|
282
|
+
const border = "─".repeat(50);
|
|
283
|
+
console.log(`\n┌${border}┐`);
|
|
284
|
+
console.log(`│ OpenClaw Mobile — Scan to connect (account: ${accountId.padEnd(Math.max(0, 50 - 38))} │`);
|
|
285
|
+
console.log(`├${border}┤`);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// Dynamically import qrcode-terminal (optional dependency)
|
|
289
|
+
const mod = await import("qrcode-terminal");
|
|
290
|
+
const qr: any = mod.default ?? mod;
|
|
291
|
+
await new Promise<void>((resolve) => {
|
|
292
|
+
qr.generate(uri, { small: true }, (qrStr: string) => {
|
|
293
|
+
const lines = qrStr.split("\n");
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
console.log(`│ ${line}`);
|
|
296
|
+
}
|
|
297
|
+
resolve();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
// qrcode-terminal not installed — print plain URI
|
|
302
|
+
console.log(`│`);
|
|
303
|
+
console.log(`│ (Install qrcode-terminal for inline QR display)`);
|
|
304
|
+
console.log(`│`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log(`├${border}┤`);
|
|
308
|
+
console.log(`│ ${uri.slice(0, 48).padEnd(48)} │`);
|
|
309
|
+
if (uri.length > 48) {
|
|
310
|
+
console.log(`│ ${uri.slice(48, 96).padEnd(48)} │`);
|
|
311
|
+
}
|
|
312
|
+
console.log(`└${border}┘\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Channel plugin ───────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
const channel = {
|
|
318
|
+
id: CHANNEL_ID,
|
|
319
|
+
meta: {
|
|
320
|
+
id: CHANNEL_ID,
|
|
321
|
+
label: "OpenClaw Mobile",
|
|
322
|
+
selectionLabel: "OpenClaw Mobile App",
|
|
323
|
+
blurb: "Chat via the OpenClaw Mobile app through a relay.",
|
|
324
|
+
detailLabel: "Mobile App",
|
|
325
|
+
aliases: ["mobile"],
|
|
326
|
+
},
|
|
327
|
+
capabilities: {
|
|
328
|
+
chatTypes: ["direct"],
|
|
329
|
+
},
|
|
330
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
331
|
+
configSchema: mobileConfigSchema,
|
|
332
|
+
config: {
|
|
333
|
+
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
|
334
|
+
resolveAccount: (cfg: any, accountId?: string) =>
|
|
335
|
+
resolveAccount(cfg, accountId),
|
|
336
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
337
|
+
setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
|
|
338
|
+
const key = accountId || DEFAULT_ACCOUNT_ID;
|
|
339
|
+
const base = cfg.channels?.[CHANNEL_ID] ?? {};
|
|
340
|
+
const accounts = base.accounts ?? {};
|
|
341
|
+
return {
|
|
342
|
+
...cfg,
|
|
343
|
+
channels: {
|
|
344
|
+
...cfg.channels,
|
|
345
|
+
[CHANNEL_ID]: {
|
|
346
|
+
...base,
|
|
347
|
+
accounts: {
|
|
348
|
+
...accounts,
|
|
349
|
+
[key]: {
|
|
350
|
+
...accounts[key],
|
|
351
|
+
enabled,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
isConfigured: (account: ResolvedAccount) => account.configured,
|
|
359
|
+
describeAccount: (account: ResolvedAccount) => ({
|
|
360
|
+
accountId: account.accountId,
|
|
361
|
+
enabled: account.enabled,
|
|
362
|
+
configured: account.configured,
|
|
363
|
+
relayUrl: account.relayUrl || "(not set)",
|
|
364
|
+
}),
|
|
365
|
+
},
|
|
366
|
+
outbound: {
|
|
367
|
+
deliveryMode: "direct" as const,
|
|
368
|
+
|
|
369
|
+
sendText: async ({ text, to, accountId, session }: any) => {
|
|
370
|
+
const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
371
|
+
const state = getRelayState(aid);
|
|
372
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
373
|
+
return { ok: false, error: "relay not connected" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const runtime = pluginRuntime;
|
|
377
|
+
let outText = text ?? "";
|
|
378
|
+
if (runtime) {
|
|
379
|
+
const cfg = runtime.config.loadConfig();
|
|
380
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
381
|
+
cfg,
|
|
382
|
+
channel: CHANNEL_ID,
|
|
383
|
+
accountId: aid,
|
|
384
|
+
});
|
|
385
|
+
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const plainMsg = JSON.stringify({
|
|
389
|
+
type: "message",
|
|
390
|
+
role: "assistant",
|
|
391
|
+
content: outText,
|
|
392
|
+
sessionKey: session?.key,
|
|
393
|
+
});
|
|
394
|
+
const outMsg = state.e2e.ready
|
|
395
|
+
? await e2eEncrypt(state.e2e, plainMsg)
|
|
396
|
+
: plainMsg;
|
|
397
|
+
state.ws.send(outMsg);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
channel: CHANNEL_ID,
|
|
401
|
+
to: to ?? "mobile-user",
|
|
402
|
+
messageId: `mobile-${Date.now()}`,
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
status: {
|
|
408
|
+
defaultRuntime: {
|
|
409
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
410
|
+
running: false,
|
|
411
|
+
connected: false,
|
|
412
|
+
lastConnectedAt: null,
|
|
413
|
+
lastDisconnect: null,
|
|
414
|
+
lastStartAt: null,
|
|
415
|
+
lastStopAt: null,
|
|
416
|
+
lastError: null,
|
|
417
|
+
lastInboundAt: null,
|
|
418
|
+
},
|
|
419
|
+
buildChannelSummary: ({ snapshot }: any) => ({
|
|
420
|
+
configured: snapshot.configured ?? false,
|
|
421
|
+
running: snapshot.running ?? false,
|
|
422
|
+
connected: snapshot.connected ?? false,
|
|
423
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
424
|
+
}),
|
|
425
|
+
buildAccountSnapshot: ({ account, runtime }: any) => ({
|
|
426
|
+
accountId: account.accountId,
|
|
427
|
+
enabled: account.enabled,
|
|
428
|
+
configured: account.configured,
|
|
429
|
+
relayUrl: account.relayUrl || "(not set)",
|
|
430
|
+
running: runtime?.running ?? false,
|
|
431
|
+
connected: runtime?.connected ?? false,
|
|
432
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
433
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
434
|
+
lastError: runtime?.lastError ?? null,
|
|
435
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
436
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
437
|
+
}),
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
gateway: {
|
|
441
|
+
startAccount: async (ctx: any) => {
|
|
442
|
+
let account: ResolvedAccount = ctx.account;
|
|
443
|
+
const accountId = account.accountId;
|
|
444
|
+
const state = getRelayState(accountId);
|
|
445
|
+
|
|
446
|
+
cleanupRelay(state);
|
|
447
|
+
|
|
448
|
+
state.gatewayCtx = ctx;
|
|
449
|
+
state.statusSink = (patch: Record<string, unknown>) =>
|
|
450
|
+
ctx.setStatus({ accountId, ...patch });
|
|
451
|
+
|
|
452
|
+
ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
|
|
453
|
+
|
|
454
|
+
if (!account.configured) {
|
|
455
|
+
const msg = "relayUrl not configured";
|
|
456
|
+
ctx.setStatus({ accountId, lastError: msg });
|
|
457
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Auto-generate a UUID room ID if still using the placeholder "default"
|
|
462
|
+
if (account.roomId === "default" || !account.roomId) {
|
|
463
|
+
const newRoomId = randomUUID();
|
|
464
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Auto-generating room ID: ${newRoomId}`);
|
|
465
|
+
try {
|
|
466
|
+
await ctx.updateAccountConfig?.({ accountId, patch: { roomId: newRoomId } });
|
|
467
|
+
account = { ...account, roomId: newRoomId };
|
|
468
|
+
} catch {
|
|
469
|
+
// updateAccountConfig not available in this Gateway version — continue with generated ID
|
|
470
|
+
account = { ...account, roomId: newRoomId };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Print pairing QR code to terminal
|
|
475
|
+
const pairingUri = buildPairingUri(account.relayUrl, account.roomId, account.relayToken || undefined);
|
|
476
|
+
printPairingQr(pairingUri, accountId).catch(() => {});
|
|
477
|
+
|
|
478
|
+
connectRelay(ctx, account);
|
|
479
|
+
|
|
480
|
+
const signal: AbortSignal | undefined = ctx.abortSignal;
|
|
481
|
+
if (signal) {
|
|
482
|
+
await new Promise<void>((resolve) => {
|
|
483
|
+
if (signal.aborted) { resolve(); return; }
|
|
484
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
485
|
+
});
|
|
486
|
+
cleanupRelay(state);
|
|
487
|
+
ctx.setStatus({ accountId, running: false, connected: false });
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
stopAccount: async (ctx: any) => {
|
|
491
|
+
const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
492
|
+
const state = getRelayState(accountId);
|
|
493
|
+
cleanupRelay(state);
|
|
494
|
+
|
|
495
|
+
ctx.setStatus?.({
|
|
496
|
+
accountId,
|
|
497
|
+
running: false,
|
|
498
|
+
connected: false,
|
|
499
|
+
lastStopAt: new Date().toISOString(),
|
|
500
|
+
});
|
|
501
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// ── Relay bridge ─────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
function cleanupRelay(state: RelayState) {
|
|
509
|
+
if (state.pingTimer) {
|
|
510
|
+
clearInterval(state.pingTimer);
|
|
511
|
+
state.pingTimer = null;
|
|
512
|
+
}
|
|
513
|
+
if (state.reconnectTimer) {
|
|
514
|
+
clearTimeout(state.reconnectTimer);
|
|
515
|
+
state.reconnectTimer = null;
|
|
516
|
+
}
|
|
517
|
+
if (state.ws) {
|
|
518
|
+
try { state.ws.close(); } catch {}
|
|
519
|
+
state.ws = null;
|
|
520
|
+
}
|
|
521
|
+
// Reset E2E state on disconnect — new handshake needed on reconnect
|
|
522
|
+
state.e2e = makeE2EState();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
526
|
+
const accountId = account.accountId;
|
|
527
|
+
const state = getRelayState(accountId);
|
|
528
|
+
|
|
529
|
+
const base = account.relayUrl.replace(/\/$/, "");
|
|
530
|
+
const params = new URLSearchParams({
|
|
531
|
+
role: "plugin",
|
|
532
|
+
room: account.roomId,
|
|
533
|
+
});
|
|
534
|
+
if (account.relayToken) params.set("token", account.relayToken);
|
|
535
|
+
|
|
536
|
+
const url = `${base}/ws?${params.toString()}`;
|
|
537
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Connecting to relay: ${url}`);
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
state.ws = new WebSocket(url);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
const msg = `WebSocket create failed: ${err}`;
|
|
543
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
544
|
+
state.statusSink?.({ lastError: msg });
|
|
545
|
+
scheduleReconnect(ctx, account);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
state.ws.addEventListener("open", () => {
|
|
550
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
|
|
551
|
+
if (state.reconnectTimer) {
|
|
552
|
+
clearTimeout(state.reconnectTimer);
|
|
553
|
+
state.reconnectTimer = null;
|
|
554
|
+
}
|
|
555
|
+
if (state.pingTimer) clearInterval(state.pingTimer);
|
|
556
|
+
state.pingTimer = setInterval(() => {
|
|
557
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
558
|
+
state.ws.send("ping");
|
|
559
|
+
}
|
|
560
|
+
}, PING_INTERVAL);
|
|
561
|
+
|
|
562
|
+
state.statusSink?.({
|
|
563
|
+
connected: true,
|
|
564
|
+
lastConnectedAt: new Date().toISOString(),
|
|
565
|
+
lastError: null,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// Initiate E2E handshake
|
|
569
|
+
state.e2e = makeE2EState();
|
|
570
|
+
e2eInit(state.e2e).then((handshakeMsg) => {
|
|
571
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
572
|
+
state.ws.send(handshakeMsg);
|
|
573
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake sent`);
|
|
574
|
+
}
|
|
575
|
+
}).catch((err) => {
|
|
576
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Init failed: ${err}`);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
state.ws.addEventListener("message", (event: MessageEvent) => {
|
|
581
|
+
handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
|
|
582
|
+
ctx.log?.error?.(
|
|
583
|
+
`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
state.ws.addEventListener("close", () => {
|
|
589
|
+
ctx.log?.info?.(
|
|
590
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
|
|
591
|
+
);
|
|
592
|
+
state.ws = null;
|
|
593
|
+
if (state.pingTimer) {
|
|
594
|
+
clearInterval(state.pingTimer);
|
|
595
|
+
state.pingTimer = null;
|
|
596
|
+
}
|
|
597
|
+
state.statusSink?.({
|
|
598
|
+
connected: false,
|
|
599
|
+
lastDisconnect: new Date().toISOString(),
|
|
600
|
+
});
|
|
601
|
+
scheduleReconnect(ctx, account);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
state.ws.addEventListener("error", () => {
|
|
605
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
|
|
606
|
+
state.statusSink?.({ lastError: "WebSocket error" });
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
611
|
+
const state = getRelayState(account.accountId);
|
|
612
|
+
if (state.reconnectTimer) return;
|
|
613
|
+
state.reconnectTimer = setTimeout(() => {
|
|
614
|
+
state.reconnectTimer = null;
|
|
615
|
+
connectRelay(ctx, account);
|
|
616
|
+
}, RECONNECT_DELAY);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
620
|
+
// Skip ping/pong
|
|
621
|
+
if (raw === "ping" || raw === "pong") return;
|
|
622
|
+
|
|
623
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
|
|
624
|
+
|
|
625
|
+
const msg = JSON.parse(raw);
|
|
626
|
+
|
|
627
|
+
// App just joined the room — (re-)send our handshake so it can complete E2E
|
|
628
|
+
if (msg.type === "peer_joined") {
|
|
629
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Peer joined (role=${msg.role}), re-sending handshake`);
|
|
630
|
+
state.e2e = makeE2EState();
|
|
631
|
+
const handshakeMsg = await e2eInit(state.e2e);
|
|
632
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
633
|
+
state.ws.send(handshakeMsg);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// E2E handshake from app
|
|
639
|
+
if (msg.type === "handshake") {
|
|
640
|
+
const peerPubKey = msg.pubkey as string | undefined;
|
|
641
|
+
if (!peerPubKey) {
|
|
642
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing pubkey`);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
await e2eHandleHandshake(state.e2e, peerPubKey);
|
|
646
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake complete — channel encrypted`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// E2E encrypted message — decrypt then process
|
|
651
|
+
if (msg.type === "encrypted") {
|
|
652
|
+
if (!state.e2e.ready) {
|
|
653
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Received encrypted msg but not ready, dropping`);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const plaintext = await e2eDecrypt(state.e2e, msg.nonce, msg.ct);
|
|
657
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted: ${plaintext.slice(0, 200)}`);
|
|
658
|
+
const innerMsg = JSON.parse(plaintext);
|
|
659
|
+
await handleInbound(ctx, accountId, innerMsg);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Plaintext message (fallback during handshake or if E2E disabled)
|
|
664
|
+
await handleInbound(ctx, accountId, msg);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
668
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleInbound: type=${msg.type} content=${String(msg.content ?? "").slice(0, 100)}`);
|
|
669
|
+
|
|
670
|
+
if (msg.type !== "message" || !msg.content) {
|
|
671
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Skipping non-message: type=${msg.type}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const state = getRelayState(accountId);
|
|
676
|
+
state.statusSink?.({ lastInboundAt: new Date().toISOString() });
|
|
677
|
+
|
|
678
|
+
// Remember the sessionKey the app sent so we can echo it back in the reply
|
|
679
|
+
if (msg.sessionKey) {
|
|
680
|
+
state.appSessionKey = String(msg.sessionKey);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const runtime = pluginRuntime;
|
|
684
|
+
if (!runtime) {
|
|
685
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const senderId = msg.senderId ?? "mobile-user";
|
|
690
|
+
const senderName = msg.senderName ?? "Mobile User";
|
|
691
|
+
const text = String(msg.content);
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
const cfg = runtime.config.loadConfig();
|
|
695
|
+
|
|
696
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
697
|
+
cfg,
|
|
698
|
+
channel: CHANNEL_ID,
|
|
699
|
+
accountId,
|
|
700
|
+
peer: { kind: "direct", id: senderId },
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// If the app specified a sessionKey (UUID-based), derive an isolated
|
|
704
|
+
// Gateway session so each mobile conversation has its own context.
|
|
705
|
+
// Format: "agent:main:mobile-<uuid>"
|
|
706
|
+
// No appSessionKey → use the route's mainSessionKey (default conversation).
|
|
707
|
+
let sessionKey: string;
|
|
708
|
+
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
709
|
+
if (appSessionKey) {
|
|
710
|
+
// Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
|
|
711
|
+
const mainKey = route.mainSessionKey as string;
|
|
712
|
+
const colonIdx = mainKey.lastIndexOf(':');
|
|
713
|
+
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
|
|
714
|
+
sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
|
|
715
|
+
} else {
|
|
716
|
+
sessionKey = route.mainSessionKey;
|
|
717
|
+
}
|
|
718
|
+
const from = `${CHANNEL_ID}:${senderId}`;
|
|
719
|
+
const to = `user:${senderId}`;
|
|
720
|
+
|
|
721
|
+
runtime.channel.activity.record({
|
|
722
|
+
channel: CHANNEL_ID,
|
|
723
|
+
accountId,
|
|
724
|
+
direction: "inbound",
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
runtime.system.enqueueSystemEvent(
|
|
728
|
+
`Mobile message from ${senderName}: ${text.slice(0, 160)}`,
|
|
729
|
+
{ sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
733
|
+
channel: "OpenClaw Mobile",
|
|
734
|
+
from: `${senderName} (mobile)`,
|
|
735
|
+
body: text,
|
|
736
|
+
chatType: "direct",
|
|
737
|
+
sender: { name: senderName, id: senderId },
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
741
|
+
Body: body,
|
|
742
|
+
BodyForAgent: text,
|
|
743
|
+
RawBody: text,
|
|
744
|
+
CommandBody: text,
|
|
745
|
+
From: from,
|
|
746
|
+
To: to,
|
|
747
|
+
SessionKey: sessionKey,
|
|
748
|
+
AccountId: accountId,
|
|
749
|
+
ChatType: "direct",
|
|
750
|
+
ConversationLabel: `Mobile DM from ${senderName}`,
|
|
751
|
+
SenderName: senderName,
|
|
752
|
+
SenderId: senderId,
|
|
753
|
+
Provider: CHANNEL_ID,
|
|
754
|
+
Surface: CHANNEL_ID,
|
|
755
|
+
OriginatingChannel: CHANNEL_ID,
|
|
756
|
+
OriginatingTo: to,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
760
|
+
cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
|
|
761
|
+
);
|
|
762
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
763
|
+
cfg, channel: CHANNEL_ID, accountId,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
767
|
+
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
768
|
+
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
769
|
+
deliver: async (payload: any) => {
|
|
770
|
+
const relayState = getRelayState(accountId);
|
|
771
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
|
|
772
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
776
|
+
payload.text ?? "", tableMode,
|
|
777
|
+
);
|
|
778
|
+
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
779
|
+
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
780
|
+
// Echo back the sessionKey the app sent (so the app can match the reply)
|
|
781
|
+
const replySessionKey = relayState.appSessionKey ?? sessionKey;
|
|
782
|
+
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
783
|
+
if (!chunk) continue;
|
|
784
|
+
const plainMsg = JSON.stringify({
|
|
785
|
+
type: "message",
|
|
786
|
+
role: "assistant",
|
|
787
|
+
content: chunk,
|
|
788
|
+
sessionKey: replySessionKey,
|
|
789
|
+
});
|
|
790
|
+
// Encrypt if E2E handshake is complete
|
|
791
|
+
const outMsg = relayState.e2e.ready
|
|
792
|
+
? await e2eEncrypt(relayState.e2e, plainMsg)
|
|
793
|
+
: plainMsg;
|
|
794
|
+
relayState.ws.send(outMsg);
|
|
795
|
+
}
|
|
796
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
|
797
|
+
},
|
|
798
|
+
onError: (err: any, info: any) => {
|
|
799
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
804
|
+
ctx: ctxPayload,
|
|
805
|
+
cfg,
|
|
806
|
+
dispatcher,
|
|
807
|
+
replyOptions,
|
|
808
|
+
});
|
|
809
|
+
markDispatchIdle();
|
|
810
|
+
|
|
811
|
+
const sessionCfg = cfg.session;
|
|
812
|
+
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
813
|
+
agentId: route.agentId,
|
|
814
|
+
});
|
|
815
|
+
await runtime.channel.session.updateLastRoute({
|
|
816
|
+
storePath,
|
|
817
|
+
sessionKey,
|
|
818
|
+
deliveryContext: {
|
|
819
|
+
channel: CHANNEL_ID,
|
|
820
|
+
to,
|
|
821
|
+
accountId,
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
|
|
826
|
+
} catch (e) {
|
|
827
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
|
832
|
+
|
|
833
|
+
export default function register(api: any) {
|
|
834
|
+
pluginRuntime = api.runtime;
|
|
835
|
+
api.registerChannel({ plugin: channel });
|
|
836
|
+
api.logger?.info?.("[openclaw-mobile] Plugin registered");
|
|
837
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-mobile",
|
|
3
|
+
"name": "OpenClaw Mobile",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Mobile app channel for OpenClaw — chat via the OpenClaw Mobile app through a Cloudflare Worker relay.",
|
|
6
|
+
"channels": [
|
|
7
|
+
"openclaw-mobile"
|
|
8
|
+
],
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {
|
|
13
|
+
"relayUrl": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "CF Worker relay WebSocket URL (e.g. wss://openclaw-relay.xxx.workers.dev)"
|
|
16
|
+
},
|
|
17
|
+
"relayToken": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Shared secret for relay authentication"
|
|
20
|
+
},
|
|
21
|
+
"roomId": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Room ID for relay routing",
|
|
24
|
+
"default": "default"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"uiHints": {
|
|
29
|
+
"relayUrl": {
|
|
30
|
+
"label": "Relay URL",
|
|
31
|
+
"placeholder": "wss://openclaw-relay.xxx.workers.dev"
|
|
32
|
+
},
|
|
33
|
+
"relayToken": {
|
|
34
|
+
"label": "Relay Token",
|
|
35
|
+
"sensitive": true
|
|
36
|
+
},
|
|
37
|
+
"roomId": {
|
|
38
|
+
"label": "Room ID",
|
|
39
|
+
"placeholder": "default"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-mobile",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"openclaw": {
|
|
13
|
+
"plugin": "./openclaw.plugin.json",
|
|
14
|
+
"extensions": ["./index.ts"]
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["openclaw", "plugin", "channel", "mobile"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"optionalDependencies": {
|
|
19
|
+
"qrcode-terminal": "^0.12.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"openclaw": "*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"openclaw": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|