openclaw-app 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 +193 -0
- package/index.ts +866 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +31 -0
- package/tsconfig.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
```bash
|
|
60
|
+
openclaw plugins install -l ./plugin
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 3. Configure
|
|
64
|
+
|
|
65
|
+
Add to `~/.openclaw/openclaw.json`:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"channels": {
|
|
70
|
+
"openclaw-mobile": {
|
|
71
|
+
"accounts": {
|
|
72
|
+
"default": {
|
|
73
|
+
"enabled": true,
|
|
74
|
+
"relayUrl": "wss://openclaw-relay.your-name.workers.dev",
|
|
75
|
+
"relayToken": "your-secret-token",
|
|
76
|
+
"roomId": "default"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 4. Restart the Gateway
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
openclaw gateway stop
|
|
88
|
+
openclaw gateway --port 18789
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 5. Verify
|
|
92
|
+
|
|
93
|
+
Open the Control UI at http://127.0.0.1:18789/ and navigate to the **OpenClaw Mobile** channel page. You should see:
|
|
94
|
+
|
|
95
|
+
- **Running**: Yes
|
|
96
|
+
- **Configured**: Yes
|
|
97
|
+
- **Connected**: Yes
|
|
98
|
+
|
|
99
|
+
You can also check logs:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
|
|
103
|
+
```
|
|
104
|
+
|
|
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.
|
|
117
|
+
|
|
118
|
+
## Control UI
|
|
119
|
+
|
|
120
|
+
The plugin integrates with the OpenClaw Control UI:
|
|
121
|
+
|
|
122
|
+
- **Status panel** — shows Running, Configured, Connected, Last inbound, and error details
|
|
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
|
|
125
|
+
|
|
126
|
+
## How the relay works
|
|
127
|
+
|
|
128
|
+
The relay is a Cloudflare Worker with a Durable Object (`RelayRoom`):
|
|
129
|
+
|
|
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
|
|
135
|
+
|
|
136
|
+
## Message Protocol
|
|
137
|
+
|
|
138
|
+
### App → Plugin (via relay)
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"type": "message",
|
|
143
|
+
"content": "Hello",
|
|
144
|
+
"sessionKey": "relay-session",
|
|
145
|
+
"senderId": "mobile-user",
|
|
146
|
+
"senderName": "Mobile User"
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
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"`
|
|
152
|
+
|
|
153
|
+
### Plugin → App (via relay)
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"type": "message",
|
|
158
|
+
"role": "assistant",
|
|
159
|
+
"content": "AI reply text",
|
|
160
|
+
"sessionKey": "relay-session"
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
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.
|
|
165
|
+
|
|
166
|
+
## Troubleshooting
|
|
167
|
+
|
|
168
|
+
| Problem | Cause | Fix |
|
|
169
|
+
|---------|-------|-----|
|
|
170
|
+
| "Channel config schema unavailable" in Control UI | Gateway loaded before plugin was installed | Restart the Gateway: `openclaw gateway stop && openclaw gateway` |
|
|
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 |
|
|
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 |
|
|
178
|
+
|
|
179
|
+
### Useful commands
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Check plugin is loaded
|
|
183
|
+
openclaw plugins list
|
|
184
|
+
|
|
185
|
+
# Check channel status
|
|
186
|
+
openclaw channels status --probe
|
|
187
|
+
|
|
188
|
+
# Live logs
|
|
189
|
+
tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
|
|
190
|
+
|
|
191
|
+
# Verify relay is reachable
|
|
192
|
+
curl https://openclaw-relay.your-name.workers.dev/health
|
|
193
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
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
|
+
* No external dependencies — uses Node.js built-in WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* Config (in ~/.openclaw/openclaw.json):
|
|
9
|
+
* {
|
|
10
|
+
* "channels": {
|
|
11
|
+
* "openclaw-mobile": {
|
|
12
|
+
* "accounts": {
|
|
13
|
+
* "default": {
|
|
14
|
+
* "enabled": true,
|
|
15
|
+
* "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
|
|
16
|
+
* "relayToken": "your-secret",
|
|
17
|
+
* "roomId": "default"
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ── Plugin runtime (set during register) ────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
let pluginRuntime: any = null;
|
|
28
|
+
|
|
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" }.
|
|
32
|
+
|
|
33
|
+
// Runtime detection — resolved once on first use
|
|
34
|
+
let _x25519Algo: { gen: any; imp: any; derive: string } | null = null;
|
|
35
|
+
async function getX25519Algo() {
|
|
36
|
+
if (_x25519Algo) return _x25519Algo;
|
|
37
|
+
try {
|
|
38
|
+
await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "X25519" } as any, true, ["deriveKey"]);
|
|
39
|
+
_x25519Algo = { gen: { name: "ECDH", namedCurve: "X25519" }, imp: { name: "ECDH", namedCurve: "X25519" }, derive: "ECDH" };
|
|
40
|
+
} catch {
|
|
41
|
+
_x25519Algo = { gen: { name: "X25519" }, imp: { name: "X25519" }, derive: "X25519" };
|
|
42
|
+
}
|
|
43
|
+
return _x25519Algo;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface E2EState {
|
|
47
|
+
localKeyPair: CryptoKeyPair | null;
|
|
48
|
+
sharedKey: CryptoKey | null;
|
|
49
|
+
ready: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeE2EState(): E2EState {
|
|
53
|
+
return { localKeyPair: null, sharedKey: null, ready: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function e2eInit(state: E2EState): Promise<string> {
|
|
57
|
+
const algo = await getX25519Algo();
|
|
58
|
+
state.localKeyPair = await crypto.subtle.generateKey(
|
|
59
|
+
algo.gen,
|
|
60
|
+
true,
|
|
61
|
+
["deriveKey"]
|
|
62
|
+
);
|
|
63
|
+
const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
|
|
64
|
+
const pubKeyB64 = bufToBase64Url(pubKeyRaw);
|
|
65
|
+
return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
|
|
69
|
+
if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
|
|
70
|
+
const algo = await getX25519Algo();
|
|
71
|
+
|
|
72
|
+
const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
|
|
73
|
+
const peerPublicKey = await crypto.subtle.importKey(
|
|
74
|
+
"raw",
|
|
75
|
+
peerPubKeyBytes.buffer as ArrayBuffer,
|
|
76
|
+
algo.imp,
|
|
77
|
+
false,
|
|
78
|
+
[]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const ecdhRawKey = await crypto.subtle.deriveKey(
|
|
82
|
+
{ name: algo.derive, public: peerPublicKey },
|
|
83
|
+
state.localKeyPair.privateKey,
|
|
84
|
+
{ name: "AES-GCM", length: 256 },
|
|
85
|
+
true,
|
|
86
|
+
["encrypt", "decrypt"]
|
|
87
|
+
);
|
|
88
|
+
const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
|
|
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
|
+
);
|
|
94
|
+
state.sharedKey = await crypto.subtle.deriveKey(
|
|
95
|
+
{
|
|
96
|
+
name: "HKDF",
|
|
97
|
+
hash: "SHA-256",
|
|
98
|
+
salt: new Uint8Array(0),
|
|
99
|
+
info: new TextEncoder().encode("openclaw-e2e-v1"),
|
|
100
|
+
},
|
|
101
|
+
hkdfKey,
|
|
102
|
+
{ name: "AES-GCM", length: 256 },
|
|
103
|
+
false,
|
|
104
|
+
["encrypt", "decrypt"]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
state.ready = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
|
|
111
|
+
if (!state.sharedKey) throw new Error("[E2E] Not ready");
|
|
112
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
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
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Promise<string> {
|
|
126
|
+
if (!state.sharedKey) throw new Error("[E2E] Not ready");
|
|
127
|
+
const nonce = base64UrlToBuf(nonceB64);
|
|
128
|
+
const ct = base64UrlToBuf(ctB64);
|
|
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
|
+
);
|
|
134
|
+
return new TextDecoder().decode(plain);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bufToBase64Url(buf: ArrayBuffer | Uint8Array): string {
|
|
138
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
139
|
+
let binary = "";
|
|
140
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
141
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function base64UrlToBuf(b64: string): Uint8Array {
|
|
145
|
+
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
146
|
+
const pad = (4 - padded.length % 4) % 4;
|
|
147
|
+
const binary = atob(padded + "=".repeat(pad));
|
|
148
|
+
const bytes = new Uint8Array(binary.length);
|
|
149
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
150
|
+
return bytes;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Relay state (per account) ────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
interface RelayState {
|
|
156
|
+
ws: WebSocket | null;
|
|
157
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
158
|
+
pingTimer: ReturnType<typeof setInterval> | null;
|
|
159
|
+
statusSink: ((patch: Record<string, unknown>) => void) | null;
|
|
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>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const relayStates = new Map<string, RelayState>();
|
|
170
|
+
|
|
171
|
+
const RECONNECT_DELAY = 5000;
|
|
172
|
+
const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
|
|
173
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
174
|
+
const CHANNEL_ID = "openclaw-mobile";
|
|
175
|
+
|
|
176
|
+
function getRelayState(accountId: string): RelayState {
|
|
177
|
+
let state = relayStates.get(accountId);
|
|
178
|
+
if (!state) {
|
|
179
|
+
state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, e2eSessions: new Map() };
|
|
180
|
+
relayStates.set(accountId, state);
|
|
181
|
+
}
|
|
182
|
+
return state;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Account resolution ───────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** Default relay deployed at https://github.com/openclaw/openclaw-mobile */
|
|
188
|
+
const DEFAULT_RELAY_URL = "wss://openclaw-relay.eldermoo8718.workers.dev";
|
|
189
|
+
const DEFAULT_ROOM_ID = "default";
|
|
190
|
+
|
|
191
|
+
interface ResolvedAccount {
|
|
192
|
+
accountId: string;
|
|
193
|
+
enabled: boolean;
|
|
194
|
+
relayUrl: string;
|
|
195
|
+
relayToken: string;
|
|
196
|
+
roomId: string;
|
|
197
|
+
configured: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function listAccountIds(cfg: any): string[] {
|
|
201
|
+
const ids = Object.keys(
|
|
202
|
+
cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
|
|
203
|
+
);
|
|
204
|
+
// Always expose at least the default account so the Control UI
|
|
205
|
+
// shows a pre-filled entry without the user having to click "Add Entry".
|
|
206
|
+
if (!ids.includes(DEFAULT_ACCOUNT_ID)) ids.unshift(DEFAULT_ACCOUNT_ID);
|
|
207
|
+
return ids;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
|
|
211
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
212
|
+
const acct =
|
|
213
|
+
cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
|
|
214
|
+
const relayUrl = acct.relayUrl || DEFAULT_RELAY_URL;
|
|
215
|
+
const roomId = acct.roomId || DEFAULT_ROOM_ID;
|
|
216
|
+
return {
|
|
217
|
+
accountId: id,
|
|
218
|
+
enabled: acct.enabled !== false,
|
|
219
|
+
relayUrl,
|
|
220
|
+
relayToken: acct.relayToken ?? "",
|
|
221
|
+
roomId,
|
|
222
|
+
configured: true, // always ready out of the box with the default relay
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Channel config schema (JSON Schema for Control UI) ───────────────────────
|
|
227
|
+
|
|
228
|
+
const mobileConfigSchema = {
|
|
229
|
+
schema: {
|
|
230
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
231
|
+
type: "object" as const,
|
|
232
|
+
properties: {
|
|
233
|
+
accounts: {
|
|
234
|
+
type: "object" as const,
|
|
235
|
+
additionalProperties: {
|
|
236
|
+
type: "object" as const,
|
|
237
|
+
properties: {
|
|
238
|
+
enabled: { type: "boolean" as const },
|
|
239
|
+
relayUrl: {
|
|
240
|
+
type: "string" as const,
|
|
241
|
+
description: "CF Worker relay WebSocket URL",
|
|
242
|
+
},
|
|
243
|
+
relayToken: {
|
|
244
|
+
type: "string" as const,
|
|
245
|
+
description: "Shared secret for relay authentication",
|
|
246
|
+
},
|
|
247
|
+
roomId: {
|
|
248
|
+
type: "string" as const,
|
|
249
|
+
description: "Room ID for relay routing",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
additionalProperties: false,
|
|
257
|
+
},
|
|
258
|
+
uiHints: {
|
|
259
|
+
relayUrl: {
|
|
260
|
+
label: "Relay URL",
|
|
261
|
+
placeholder: DEFAULT_RELAY_URL,
|
|
262
|
+
},
|
|
263
|
+
relayToken: {
|
|
264
|
+
label: "Relay Token",
|
|
265
|
+
sensitive: true,
|
|
266
|
+
},
|
|
267
|
+
roomId: {
|
|
268
|
+
label: "Room ID",
|
|
269
|
+
placeholder: DEFAULT_ROOM_ID,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// ── Channel plugin ───────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const channel = {
|
|
277
|
+
id: CHANNEL_ID,
|
|
278
|
+
meta: {
|
|
279
|
+
id: CHANNEL_ID,
|
|
280
|
+
label: "OpenClaw Mobile",
|
|
281
|
+
selectionLabel: "OpenClaw Mobile App",
|
|
282
|
+
blurb: "Chat via the OpenClaw Mobile app through a relay.",
|
|
283
|
+
detailLabel: "Mobile App",
|
|
284
|
+
aliases: ["mobile"],
|
|
285
|
+
},
|
|
286
|
+
capabilities: {
|
|
287
|
+
chatTypes: ["direct"],
|
|
288
|
+
},
|
|
289
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
290
|
+
configSchema: mobileConfigSchema,
|
|
291
|
+
config: {
|
|
292
|
+
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
|
293
|
+
resolveAccount: (cfg: any, accountId?: string) =>
|
|
294
|
+
resolveAccount(cfg, accountId),
|
|
295
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
296
|
+
setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
|
|
297
|
+
const key = accountId || DEFAULT_ACCOUNT_ID;
|
|
298
|
+
const base = cfg.channels?.[CHANNEL_ID] ?? {};
|
|
299
|
+
const accounts = base.accounts ?? {};
|
|
300
|
+
return {
|
|
301
|
+
...cfg,
|
|
302
|
+
channels: {
|
|
303
|
+
...cfg.channels,
|
|
304
|
+
[CHANNEL_ID]: {
|
|
305
|
+
...base,
|
|
306
|
+
accounts: {
|
|
307
|
+
...accounts,
|
|
308
|
+
[key]: {
|
|
309
|
+
...accounts[key],
|
|
310
|
+
enabled,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
isConfigured: (account: ResolvedAccount) => account.configured,
|
|
318
|
+
describeAccount: (account: ResolvedAccount) => ({
|
|
319
|
+
accountId: account.accountId,
|
|
320
|
+
enabled: account.enabled,
|
|
321
|
+
configured: account.configured,
|
|
322
|
+
relayUrl: account.relayUrl,
|
|
323
|
+
roomId: account.roomId,
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
outbound: {
|
|
327
|
+
deliveryMode: "direct" as const,
|
|
328
|
+
|
|
329
|
+
sendText: async ({ text, to, accountId, session }: any) => {
|
|
330
|
+
const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
331
|
+
const state = getRelayState(aid);
|
|
332
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
333
|
+
return { ok: false, error: "relay not connected" };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const runtime = pluginRuntime;
|
|
337
|
+
let outText = text ?? "";
|
|
338
|
+
if (runtime) {
|
|
339
|
+
const cfg = runtime.config.loadConfig();
|
|
340
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
341
|
+
cfg,
|
|
342
|
+
channel: CHANNEL_ID,
|
|
343
|
+
accountId: aid,
|
|
344
|
+
});
|
|
345
|
+
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const replySessionKey = session?.key ?? null;
|
|
349
|
+
const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
|
|
350
|
+
const plainMsg = JSON.stringify({
|
|
351
|
+
type: "message",
|
|
352
|
+
role: "assistant",
|
|
353
|
+
content: outText,
|
|
354
|
+
sessionKey: replySessionKey,
|
|
355
|
+
});
|
|
356
|
+
let outMsg: string;
|
|
357
|
+
if (sessionE2E?.ready) {
|
|
358
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
359
|
+
encrypted.sessionKey = replySessionKey;
|
|
360
|
+
outMsg = JSON.stringify(encrypted);
|
|
361
|
+
} else {
|
|
362
|
+
outMsg = plainMsg;
|
|
363
|
+
}
|
|
364
|
+
state.ws.send(outMsg);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
channel: CHANNEL_ID,
|
|
368
|
+
to: to ?? "mobile-user",
|
|
369
|
+
messageId: `mobile-${Date.now()}`,
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
status: {
|
|
375
|
+
defaultRuntime: {
|
|
376
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
377
|
+
running: false,
|
|
378
|
+
connected: false,
|
|
379
|
+
lastConnectedAt: null,
|
|
380
|
+
lastDisconnect: null,
|
|
381
|
+
lastStartAt: null,
|
|
382
|
+
lastStopAt: null,
|
|
383
|
+
lastError: null,
|
|
384
|
+
lastInboundAt: null,
|
|
385
|
+
},
|
|
386
|
+
buildChannelSummary: ({ snapshot }: any) => ({
|
|
387
|
+
configured: snapshot.configured ?? false,
|
|
388
|
+
running: snapshot.running ?? false,
|
|
389
|
+
connected: snapshot.connected ?? false,
|
|
390
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
391
|
+
}),
|
|
392
|
+
buildAccountSnapshot: ({ account, runtime }: any) => ({
|
|
393
|
+
accountId: account.accountId,
|
|
394
|
+
enabled: account.enabled,
|
|
395
|
+
configured: account.configured,
|
|
396
|
+
relayUrl: account.relayUrl || "(not set)",
|
|
397
|
+
running: runtime?.running ?? false,
|
|
398
|
+
connected: runtime?.connected ?? false,
|
|
399
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
400
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
401
|
+
lastError: runtime?.lastError ?? null,
|
|
402
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
403
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
404
|
+
}),
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
gateway: {
|
|
408
|
+
startAccount: async (ctx: any) => {
|
|
409
|
+
const account: ResolvedAccount = ctx.account;
|
|
410
|
+
const accountId = account.accountId;
|
|
411
|
+
const state = getRelayState(accountId);
|
|
412
|
+
|
|
413
|
+
cleanupRelay(state);
|
|
414
|
+
|
|
415
|
+
state.gatewayCtx = ctx;
|
|
416
|
+
state.statusSink = (patch: Record<string, unknown>) =>
|
|
417
|
+
ctx.setStatus({ accountId, ...patch });
|
|
418
|
+
|
|
419
|
+
ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
|
|
420
|
+
|
|
421
|
+
if (!account.configured) {
|
|
422
|
+
const msg = "relayUrl not configured";
|
|
423
|
+
ctx.setStatus({ accountId, lastError: msg });
|
|
424
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
connectRelay(ctx, account);
|
|
429
|
+
|
|
430
|
+
const signal: AbortSignal | undefined = ctx.abortSignal;
|
|
431
|
+
if (signal) {
|
|
432
|
+
await new Promise<void>((resolve) => {
|
|
433
|
+
if (signal.aborted) { resolve(); return; }
|
|
434
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
435
|
+
});
|
|
436
|
+
cleanupRelay(state);
|
|
437
|
+
ctx.setStatus({ accountId, running: false, connected: false });
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
stopAccount: async (ctx: any) => {
|
|
441
|
+
const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
442
|
+
const state = getRelayState(accountId);
|
|
443
|
+
cleanupRelay(state);
|
|
444
|
+
|
|
445
|
+
ctx.setStatus?.({
|
|
446
|
+
accountId,
|
|
447
|
+
running: false,
|
|
448
|
+
connected: false,
|
|
449
|
+
lastStopAt: new Date().toISOString(),
|
|
450
|
+
});
|
|
451
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// ── Relay bridge ─────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
function cleanupRelay(state: RelayState) {
|
|
459
|
+
if (state.pingTimer) {
|
|
460
|
+
clearInterval(state.pingTimer);
|
|
461
|
+
state.pingTimer = null;
|
|
462
|
+
}
|
|
463
|
+
if (state.reconnectTimer) {
|
|
464
|
+
clearTimeout(state.reconnectTimer);
|
|
465
|
+
state.reconnectTimer = null;
|
|
466
|
+
}
|
|
467
|
+
if (state.ws) {
|
|
468
|
+
try { state.ws.close(); } catch {}
|
|
469
|
+
state.ws = null;
|
|
470
|
+
}
|
|
471
|
+
// Clear all per-session E2E states — new handshakes needed on reconnect
|
|
472
|
+
state.e2eSessions.clear();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
476
|
+
const accountId = account.accountId;
|
|
477
|
+
const state = getRelayState(accountId);
|
|
478
|
+
|
|
479
|
+
const base = account.relayUrl.replace(/\/$/, "");
|
|
480
|
+
const params = new URLSearchParams({
|
|
481
|
+
role: "plugin",
|
|
482
|
+
room: account.roomId,
|
|
483
|
+
});
|
|
484
|
+
if (account.relayToken) params.set("token", account.relayToken);
|
|
485
|
+
|
|
486
|
+
const url = `${base}/ws?${params.toString()}`;
|
|
487
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Connecting to relay: ${url}`);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
state.ws = new WebSocket(url);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
const msg = `WebSocket create failed: ${err}`;
|
|
493
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
494
|
+
state.statusSink?.({ lastError: msg });
|
|
495
|
+
scheduleReconnect(ctx, account);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
state.ws.addEventListener("open", () => {
|
|
500
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
|
|
501
|
+
if (state.reconnectTimer) {
|
|
502
|
+
clearTimeout(state.reconnectTimer);
|
|
503
|
+
state.reconnectTimer = null;
|
|
504
|
+
}
|
|
505
|
+
if (state.pingTimer) clearInterval(state.pingTimer);
|
|
506
|
+
state.pingTimer = setInterval(() => {
|
|
507
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
508
|
+
state.ws.send("ping");
|
|
509
|
+
}
|
|
510
|
+
}, PING_INTERVAL);
|
|
511
|
+
|
|
512
|
+
state.statusSink?.({
|
|
513
|
+
connected: true,
|
|
514
|
+
lastConnectedAt: new Date().toISOString(),
|
|
515
|
+
lastError: null,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// E2E handshakes are initiated per-session when `peer_joined` arrives
|
|
519
|
+
// (each app connection carries its own sessionKey UUID).
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
state.ws.addEventListener("message", (event: MessageEvent) => {
|
|
523
|
+
handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
|
|
524
|
+
ctx.log?.error?.(
|
|
525
|
+
`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
state.ws.addEventListener("close", () => {
|
|
531
|
+
ctx.log?.info?.(
|
|
532
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
|
|
533
|
+
);
|
|
534
|
+
state.ws = null;
|
|
535
|
+
if (state.pingTimer) {
|
|
536
|
+
clearInterval(state.pingTimer);
|
|
537
|
+
state.pingTimer = null;
|
|
538
|
+
}
|
|
539
|
+
state.statusSink?.({
|
|
540
|
+
connected: false,
|
|
541
|
+
lastDisconnect: new Date().toISOString(),
|
|
542
|
+
});
|
|
543
|
+
scheduleReconnect(ctx, account);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
state.ws.addEventListener("error", () => {
|
|
547
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
|
|
548
|
+
state.statusSink?.({ lastError: "WebSocket error" });
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
553
|
+
const state = getRelayState(account.accountId);
|
|
554
|
+
if (state.reconnectTimer) return;
|
|
555
|
+
state.reconnectTimer = setTimeout(() => {
|
|
556
|
+
state.reconnectTimer = null;
|
|
557
|
+
connectRelay(ctx, account);
|
|
558
|
+
}, RECONNECT_DELAY);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
562
|
+
// Skip ping/pong
|
|
563
|
+
if (raw === "ping" || raw === "pong") return;
|
|
564
|
+
|
|
565
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
|
|
566
|
+
|
|
567
|
+
const msg = JSON.parse(raw);
|
|
568
|
+
|
|
569
|
+
// App session joined — initiate per-session E2E handshake
|
|
570
|
+
// 프로토콜: plugin이 먼저 handshake를 보내고, app이 자신의 pubkey로 응답.
|
|
571
|
+
// peer_joined가 중복으로 올 수 있으므로 이미 진행 중인 세션은 재시작하지 않음.
|
|
572
|
+
if (msg.type === "peer_joined") {
|
|
573
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
574
|
+
if (!sessionKey) {
|
|
575
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// 이미 해당 session E2E가 진행 중이면 재시작하지 않음 (중복 peer_joined 방지)
|
|
579
|
+
if (state.e2eSessions.has(sessionKey)) {
|
|
580
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already exists, skipping duplicate peer_joined`);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
584
|
+
const sessionE2E = makeE2EState();
|
|
585
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
586
|
+
const handshakeMsg = await e2eInit(sessionE2E);
|
|
587
|
+
// sessionKey를 포함시켜 relay가 올바른 app으로 라우팅하도록 함
|
|
588
|
+
const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
|
|
589
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
590
|
+
state.ws.send(handshakeWithSession);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// App의 handshake 응답 수신 — ECDH 완성
|
|
596
|
+
if (msg.type === "handshake") {
|
|
597
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
598
|
+
const peerPubKey = msg.pubkey as string | undefined;
|
|
599
|
+
if (!sessionKey || !peerPubKey) {
|
|
600
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
604
|
+
if (!sessionE2E) {
|
|
605
|
+
// peer_joined 없이 app이 먼저 handshake를 보낸 경우 (예: plugin 재연결)
|
|
606
|
+
// 새 세션을 만들고 plugin의 pubkey를 먼저 전송한 후 ECDH 완성
|
|
607
|
+
sessionE2E = makeE2EState();
|
|
608
|
+
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
609
|
+
const handshakeMsg = await e2eInit(sessionE2E);
|
|
610
|
+
const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
|
|
611
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
612
|
+
state.ws.send(handshakeWithSession);
|
|
613
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} — sent handshake (reactive, no prior peer_joined)`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (sessionE2E.ready) {
|
|
617
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already ready, ignoring duplicate handshake`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (!sessionE2E.localKeyPair) {
|
|
621
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} has no local keypair yet, dropping handshake`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
await e2eHandleHandshake(sessionE2E, peerPubKey);
|
|
625
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// E2E encrypted message — decrypt using the per-session key
|
|
630
|
+
if (msg.type === "encrypted") {
|
|
631
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
632
|
+
if (!sessionKey) {
|
|
633
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const sessionE2E = state.e2eSessions.get(sessionKey);
|
|
637
|
+
if (!sessionE2E?.ready) {
|
|
638
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
|
|
642
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
|
|
643
|
+
const innerMsg = JSON.parse(plaintext);
|
|
644
|
+
// Preserve sessionKey through decryption so handleInbound can use it
|
|
645
|
+
if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
|
|
646
|
+
await handleInbound(ctx, accountId, innerMsg);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Plaintext message (no E2E or during handshake)
|
|
651
|
+
await handleInbound(ctx, accountId, msg);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
655
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleInbound: type=${msg.type} content=${String(msg.content ?? "").slice(0, 100)}`);
|
|
656
|
+
|
|
657
|
+
if (msg.type !== "message" || !msg.content) {
|
|
658
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Skipping non-message: type=${msg.type}`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const state = getRelayState(accountId);
|
|
663
|
+
state.statusSink?.({ lastInboundAt: new Date().toISOString() });
|
|
664
|
+
|
|
665
|
+
const runtime = pluginRuntime;
|
|
666
|
+
if (!runtime) {
|
|
667
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const senderId = msg.senderId ?? "mobile-user";
|
|
672
|
+
const senderName = msg.senderName ?? "Mobile User";
|
|
673
|
+
const text = String(msg.content);
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const cfg = runtime.config.loadConfig();
|
|
677
|
+
|
|
678
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
679
|
+
cfg,
|
|
680
|
+
channel: CHANNEL_ID,
|
|
681
|
+
accountId,
|
|
682
|
+
peer: { kind: "direct", id: senderId },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// If the app specified a sessionKey (UUID-based), derive an isolated
|
|
686
|
+
// Gateway session so each mobile conversation has its own context.
|
|
687
|
+
// Format: "agent:main:mobile-<uuid>"
|
|
688
|
+
// No appSessionKey → use the route's mainSessionKey (default conversation).
|
|
689
|
+
let sessionKey: string;
|
|
690
|
+
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
691
|
+
if (appSessionKey) {
|
|
692
|
+
// Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
|
|
693
|
+
const mainKey = route.mainSessionKey as string;
|
|
694
|
+
const colonIdx = mainKey.lastIndexOf(':');
|
|
695
|
+
const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
|
|
696
|
+
sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
|
|
697
|
+
} else {
|
|
698
|
+
sessionKey = route.mainSessionKey;
|
|
699
|
+
}
|
|
700
|
+
const from = `${CHANNEL_ID}:${senderId}`;
|
|
701
|
+
const to = `user:${senderId}`;
|
|
702
|
+
|
|
703
|
+
runtime.channel.activity.record({
|
|
704
|
+
channel: CHANNEL_ID,
|
|
705
|
+
accountId,
|
|
706
|
+
direction: "inbound",
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
runtime.system.enqueueSystemEvent(
|
|
710
|
+
`Mobile message from ${senderName}: ${text.slice(0, 160)}`,
|
|
711
|
+
{ sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
715
|
+
channel: "OpenClaw Mobile",
|
|
716
|
+
from: `${senderName} (mobile)`,
|
|
717
|
+
body: text,
|
|
718
|
+
chatType: "direct",
|
|
719
|
+
sender: { name: senderName, id: senderId },
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
723
|
+
Body: body,
|
|
724
|
+
BodyForAgent: text,
|
|
725
|
+
RawBody: text,
|
|
726
|
+
CommandBody: text,
|
|
727
|
+
From: from,
|
|
728
|
+
To: to,
|
|
729
|
+
SessionKey: sessionKey,
|
|
730
|
+
AccountId: accountId,
|
|
731
|
+
ChatType: "direct",
|
|
732
|
+
ConversationLabel: `Mobile DM from ${senderName}`,
|
|
733
|
+
SenderName: senderName,
|
|
734
|
+
SenderId: senderId,
|
|
735
|
+
Provider: CHANNEL_ID,
|
|
736
|
+
Surface: CHANNEL_ID,
|
|
737
|
+
OriginatingChannel: CHANNEL_ID,
|
|
738
|
+
OriginatingTo: to,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const textLimit = runtime.channel.text.resolveTextChunkLimit(
|
|
742
|
+
cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
|
|
743
|
+
);
|
|
744
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
745
|
+
cfg, channel: CHANNEL_ID, accountId,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
749
|
+
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
750
|
+
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
751
|
+
deliver: async (payload: any) => {
|
|
752
|
+
const relayState = getRelayState(accountId);
|
|
753
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
|
|
754
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
758
|
+
payload.text ?? "", tableMode,
|
|
759
|
+
);
|
|
760
|
+
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
761
|
+
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
762
|
+
// The relay routes plugin→app by sessionKey in the JSON payload
|
|
763
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
764
|
+
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
765
|
+
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
766
|
+
if (!chunk) continue;
|
|
767
|
+
const plainMsg = JSON.stringify({
|
|
768
|
+
type: "message",
|
|
769
|
+
role: "assistant",
|
|
770
|
+
content: chunk,
|
|
771
|
+
sessionKey: replySessionKey,
|
|
772
|
+
});
|
|
773
|
+
// Encrypt with the per-session key if handshake is complete.
|
|
774
|
+
// The outer envelope must carry sessionKey so the relay can route
|
|
775
|
+
// the message to the correct app connection.
|
|
776
|
+
let outMsg: string;
|
|
777
|
+
if (sessionE2E?.ready) {
|
|
778
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
779
|
+
encrypted.sessionKey = replySessionKey;
|
|
780
|
+
outMsg = JSON.stringify(encrypted);
|
|
781
|
+
} else {
|
|
782
|
+
outMsg = plainMsg;
|
|
783
|
+
}
|
|
784
|
+
relayState.ws.send(outMsg);
|
|
785
|
+
}
|
|
786
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
|
787
|
+
},
|
|
788
|
+
onError: (err: any, info: any) => {
|
|
789
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
794
|
+
ctx: ctxPayload,
|
|
795
|
+
cfg,
|
|
796
|
+
dispatcher,
|
|
797
|
+
replyOptions,
|
|
798
|
+
});
|
|
799
|
+
markDispatchIdle();
|
|
800
|
+
|
|
801
|
+
const sessionCfg = cfg.session;
|
|
802
|
+
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
803
|
+
agentId: route.agentId,
|
|
804
|
+
});
|
|
805
|
+
await runtime.channel.session.updateLastRoute({
|
|
806
|
+
storePath,
|
|
807
|
+
sessionKey,
|
|
808
|
+
deliveryContext: {
|
|
809
|
+
channel: CHANNEL_ID,
|
|
810
|
+
to,
|
|
811
|
+
accountId,
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
|
|
816
|
+
} catch (e) {
|
|
817
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
export default function register(api: any) {
|
|
824
|
+
pluginRuntime = api.runtime;
|
|
825
|
+
api.registerChannel({ plugin: channel });
|
|
826
|
+
|
|
827
|
+
// On gateway start, ensure the default account config exists so the
|
|
828
|
+
// Control UI shows pre-filled values without any manual JSON editing.
|
|
829
|
+
api.on("gateway_start", async () => {
|
|
830
|
+
try {
|
|
831
|
+
const runtime = pluginRuntime;
|
|
832
|
+
if (!runtime) return;
|
|
833
|
+
|
|
834
|
+
const cfg = runtime.config.loadConfig();
|
|
835
|
+
const existing = cfg.channels?.[CHANNEL_ID]?.accounts?.[DEFAULT_ACCOUNT_ID];
|
|
836
|
+
|
|
837
|
+
// Only write defaults if the account entry is completely absent
|
|
838
|
+
if (existing !== undefined) return;
|
|
839
|
+
|
|
840
|
+
const patched = {
|
|
841
|
+
...cfg,
|
|
842
|
+
channels: {
|
|
843
|
+
...cfg.channels,
|
|
844
|
+
[CHANNEL_ID]: {
|
|
845
|
+
...(cfg.channels?.[CHANNEL_ID] ?? {}),
|
|
846
|
+
accounts: {
|
|
847
|
+
...(cfg.channels?.[CHANNEL_ID]?.accounts ?? {}),
|
|
848
|
+
[DEFAULT_ACCOUNT_ID]: {
|
|
849
|
+
enabled: true,
|
|
850
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
851
|
+
roomId: DEFAULT_ROOM_ID,
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
await runtime.config.writeConfigFile(patched);
|
|
859
|
+
api.logger?.info?.(`[openclaw-mobile] Wrote default account config (relayUrl=${DEFAULT_RELAY_URL}, roomId=${DEFAULT_ROOM_ID})`);
|
|
860
|
+
} catch (err) {
|
|
861
|
+
api.logger?.warn?.(`[openclaw-mobile] Could not write default config: ${err}`);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
api.logger?.info?.("[openclaw-mobile] Plugin registered");
|
|
866
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-app",
|
|
3
|
+
"name": "OpenClaw App",
|
|
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,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-app",
|
|
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
|
+
"openclaw": {
|
|
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
|
+
}
|
|
30
|
+
}
|
|
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
|
+
}
|