openclaw-app 1.1.9 → 1.2.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 +3 -3
- package/index.js +1377 -0
- package/index.ts +13 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/index.js
ADDED
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* OpenClaw App Channel Plugin
|
|
5
|
+
*
|
|
6
|
+
* Registers "openclaw-app" channel that bridges Gateway <-> CF Worker relay <-> Mobile App.
|
|
7
|
+
* Plugin runs inside the Gateway process, connects outbound to the relay.
|
|
8
|
+
* No external dependencies — uses Node.js built-in WebSocket.
|
|
9
|
+
*
|
|
10
|
+
* Config (in ~/.openclaw/openclaw.json):
|
|
11
|
+
* {
|
|
12
|
+
* "channels": {
|
|
13
|
+
* "openclaw-app": {
|
|
14
|
+
* "accounts": {
|
|
15
|
+
* "default": {
|
|
16
|
+
* "enabled": true,
|
|
17
|
+
* "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
|
|
18
|
+
* "relayToken": "your-secret",
|
|
19
|
+
* "roomId": "default"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
// ── Plugin runtime (set during register) ────────────────────────────────────
|
|
27
|
+
let pluginRuntime = null;
|
|
28
|
+
// ── E2E Encryption (X25519 ECDH + AES-256-GCM) ──────────────────────────────
|
|
29
|
+
// Uses Web Crypto API. Node 18+ uses standalone "X25519" algorithm name,
|
|
30
|
+
// while browsers use { name: "ECDH", namedCurve: "X25519" }.
|
|
31
|
+
// Runtime detection — resolved once on first use
|
|
32
|
+
let _x25519Algo = null;
|
|
33
|
+
async function getX25519Algo() {
|
|
34
|
+
if (_x25519Algo)
|
|
35
|
+
return _x25519Algo;
|
|
36
|
+
try {
|
|
37
|
+
await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "X25519" }, true, ["deriveKey"]);
|
|
38
|
+
_x25519Algo = { gen: { name: "ECDH", namedCurve: "X25519" }, imp: { name: "ECDH", namedCurve: "X25519" }, derive: "ECDH" };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
_x25519Algo = { gen: { name: "X25519" }, imp: { name: "X25519" }, derive: "X25519" };
|
|
42
|
+
}
|
|
43
|
+
return _x25519Algo;
|
|
44
|
+
}
|
|
45
|
+
function makeE2EState() {
|
|
46
|
+
return { localKeyPair: null, sharedKey: null, ready: false };
|
|
47
|
+
}
|
|
48
|
+
async function e2eInit(state) {
|
|
49
|
+
const algo = await getX25519Algo();
|
|
50
|
+
state.localKeyPair = await crypto.subtle.generateKey(algo.gen, true, ["deriveKey", "deriveBits"]);
|
|
51
|
+
const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
|
|
52
|
+
return bufToBase64Url(pubKeyRaw);
|
|
53
|
+
}
|
|
54
|
+
function getCryptoStorePath() {
|
|
55
|
+
const workDir = pluginRuntime?.config?.workspaceDir || process.cwd();
|
|
56
|
+
return path.join(workDir, "openclaw_mobile_keys.json");
|
|
57
|
+
}
|
|
58
|
+
let _persistedStore = null;
|
|
59
|
+
function loadE2EStore() {
|
|
60
|
+
if (_persistedStore)
|
|
61
|
+
return _persistedStore;
|
|
62
|
+
try {
|
|
63
|
+
const p = getCryptoStorePath();
|
|
64
|
+
if (fs.existsSync(p)) {
|
|
65
|
+
_persistedStore = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
_persistedStore = {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
_persistedStore = {};
|
|
73
|
+
}
|
|
74
|
+
return _persistedStore;
|
|
75
|
+
}
|
|
76
|
+
function saveE2EStore() {
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(getCryptoStorePath(), JSON.stringify(_persistedStore, null, 2), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error("[openclaw-app] Failed to save mobile keys", e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function getOrInitAccountE2E(accountId) {
|
|
85
|
+
const store = loadE2EStore();
|
|
86
|
+
if (!store[accountId]) {
|
|
87
|
+
const algo = await getX25519Algo();
|
|
88
|
+
const kp = await crypto.subtle.generateKey(algo.gen, true, ["deriveKey", "deriveBits"]);
|
|
89
|
+
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
|
|
90
|
+
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
|
|
91
|
+
store[accountId] = {
|
|
92
|
+
pluginPrivB64: bufToBase64Url(privRaw),
|
|
93
|
+
pluginPubB64: bufToBase64Url(pubRaw),
|
|
94
|
+
sharedSecrets: {},
|
|
95
|
+
activeDevicePubKey: null
|
|
96
|
+
};
|
|
97
|
+
saveE2EStore();
|
|
98
|
+
}
|
|
99
|
+
return store[accountId];
|
|
100
|
+
}
|
|
101
|
+
async function loadE2EStateFromPersisted(accountId, devicePubKeyB64) {
|
|
102
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
103
|
+
const sharedSecretB64 = accountE2E.sharedSecrets[devicePubKeyB64];
|
|
104
|
+
if (!sharedSecretB64)
|
|
105
|
+
return null;
|
|
106
|
+
const state = makeE2EState();
|
|
107
|
+
const rawShared = base64UrlToBuf(sharedSecretB64);
|
|
108
|
+
state.sharedKey = await crypto.subtle.importKey("raw", rawShared, { name: "AES-GCM" }, false, // Not extractable anymore since it's already in memory/disk
|
|
109
|
+
["encrypt", "decrypt"]);
|
|
110
|
+
state.ready = true;
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
async function e2eHandleHandshake(accountId, peerPubKeyB64) {
|
|
114
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
115
|
+
const algo = await getX25519Algo();
|
|
116
|
+
const privBytes = base64UrlToBuf(accountE2E.pluginPrivB64);
|
|
117
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", privBytes, algo.imp, false, ["deriveKey", "deriveBits"]);
|
|
118
|
+
const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
|
|
119
|
+
const peerPublicKey = await crypto.subtle.importKey("raw", peerPubKeyBytes, algo.imp, false, []);
|
|
120
|
+
// Dart cryptography's sharedSecretKey returns the raw X-coordinate (32 bytes).
|
|
121
|
+
// Using deriveKey into AES-GCM directly may truncate or alter the raw bits depending on the engine.
|
|
122
|
+
const ecdhRawBytes = await crypto.subtle.deriveBits({ name: algo.derive, public: peerPublicKey }, privateKey, 256);
|
|
123
|
+
// HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
|
|
124
|
+
const hkdfKey = await crypto.subtle.importKey("raw", ecdhRawBytes, { name: "HKDF" }, false, ["deriveKey"]);
|
|
125
|
+
const sharedKey = await crypto.subtle.deriveKey({
|
|
126
|
+
name: "HKDF",
|
|
127
|
+
hash: "SHA-256",
|
|
128
|
+
salt: new Uint8Array(0),
|
|
129
|
+
info: new TextEncoder().encode("openclaw-e2e-v1"),
|
|
130
|
+
}, hkdfKey, { name: "AES-GCM", length: 256 }, true, // EXPORTABLE so we can save it!
|
|
131
|
+
["encrypt", "decrypt"]);
|
|
132
|
+
const rawShared = await crypto.subtle.exportKey("raw", sharedKey);
|
|
133
|
+
accountE2E.sharedSecrets[peerPubKeyB64] = bufToBase64Url(rawShared);
|
|
134
|
+
accountE2E.activeDevicePubKey = peerPubKeyB64;
|
|
135
|
+
saveE2EStore();
|
|
136
|
+
const state = makeE2EState();
|
|
137
|
+
state.sharedKey = sharedKey;
|
|
138
|
+
state.ready = true;
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
async function e2eEncrypt(state, plaintext) {
|
|
142
|
+
if (!state.sharedKey)
|
|
143
|
+
throw new Error("[E2E] Not ready");
|
|
144
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
145
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, state.sharedKey, new TextEncoder().encode(plaintext));
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
type: "encrypted",
|
|
148
|
+
nonce: bufToBase64Url(nonce),
|
|
149
|
+
ct: bufToBase64Url(ct),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function e2eDecrypt(state, nonceB64, ctB64) {
|
|
153
|
+
if (!state.sharedKey)
|
|
154
|
+
throw new Error("[E2E] Not ready");
|
|
155
|
+
const nonce = base64UrlToBuf(nonceB64);
|
|
156
|
+
const ct = base64UrlToBuf(ctB64);
|
|
157
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, state.sharedKey, ct);
|
|
158
|
+
return new TextDecoder().decode(plain);
|
|
159
|
+
}
|
|
160
|
+
function bufToBase64Url(buf) {
|
|
161
|
+
const buffer = Buffer.from(buf);
|
|
162
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
163
|
+
}
|
|
164
|
+
function base64UrlToBuf(b64) {
|
|
165
|
+
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
166
|
+
const buffer = Buffer.from(padded, "base64");
|
|
167
|
+
const cleanBytes = new Uint8Array(buffer.length);
|
|
168
|
+
cleanBytes.set(buffer);
|
|
169
|
+
return cleanBytes;
|
|
170
|
+
}
|
|
171
|
+
const HANDSHAKE_AUTH_VERSION = "v1";
|
|
172
|
+
const HANDSHAKE_AUTH_CONTEXT = "openclaw-hs-auth-v1";
|
|
173
|
+
const HANDSHAKE_MAX_SKEW_MS = 2 * 60 * 1000;
|
|
174
|
+
function canonicalHandshakePayload(version, role, sessionKey, pubkey, ts) {
|
|
175
|
+
return [HANDSHAKE_AUTH_CONTEXT, version, role, sessionKey, String(ts), pubkey].join("|");
|
|
176
|
+
}
|
|
177
|
+
async function buildHandshakeMac(token, role, sessionKey, pubkey, ts, version = HANDSHAKE_AUTH_VERSION) {
|
|
178
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(token), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
179
|
+
const payload = new TextEncoder().encode(canonicalHandshakePayload(version, role, sessionKey, pubkey, ts));
|
|
180
|
+
const mac = await crypto.subtle.sign("HMAC", key, payload);
|
|
181
|
+
return bufToBase64Url(mac);
|
|
182
|
+
}
|
|
183
|
+
function constantTimeEquals(a, b) {
|
|
184
|
+
const aa = new TextEncoder().encode(a);
|
|
185
|
+
const bb = new TextEncoder().encode(b);
|
|
186
|
+
if (aa.length !== bb.length)
|
|
187
|
+
return false;
|
|
188
|
+
let diff = 0;
|
|
189
|
+
for (let i = 0; i < aa.length; i++)
|
|
190
|
+
diff |= aa[i] ^ bb[i];
|
|
191
|
+
return diff === 0;
|
|
192
|
+
}
|
|
193
|
+
function parseHandshakeTs(raw) {
|
|
194
|
+
if (typeof raw === "number" && Number.isFinite(raw))
|
|
195
|
+
return Math.trunc(raw);
|
|
196
|
+
if (typeof raw === "string") {
|
|
197
|
+
const n = Number(raw);
|
|
198
|
+
if (Number.isFinite(n))
|
|
199
|
+
return Math.trunc(n);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
function isHandshakeTsFresh(ts) {
|
|
204
|
+
return Math.abs(Date.now() - ts) <= HANDSHAKE_MAX_SKEW_MS;
|
|
205
|
+
}
|
|
206
|
+
async function verifyHandshakeMac(token, role, sessionKey, pubkey, ts, mac, version) {
|
|
207
|
+
const expected = await buildHandshakeMac(token, role, sessionKey, pubkey, ts, version);
|
|
208
|
+
return constantTimeEquals(mac, expected);
|
|
209
|
+
}
|
|
210
|
+
const relayStates = new Map();
|
|
211
|
+
const RECONNECT_DELAY = 5000;
|
|
212
|
+
const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
|
|
213
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
214
|
+
const CHANNEL_ID = "openclaw-app";
|
|
215
|
+
/** Fixed chatSessionKey that routes cron messages to the App inbox UI */
|
|
216
|
+
const INBOX_CHAT_SESSION_KEY = "inbox";
|
|
217
|
+
function getRelayState(accountId) {
|
|
218
|
+
let state = relayStates.get(accountId);
|
|
219
|
+
if (!state) {
|
|
220
|
+
state = {
|
|
221
|
+
ws: null,
|
|
222
|
+
reconnectTimer: null,
|
|
223
|
+
pingTimer: null,
|
|
224
|
+
statusSink: null,
|
|
225
|
+
gatewayCtx: null,
|
|
226
|
+
relayToken: "",
|
|
227
|
+
};
|
|
228
|
+
relayStates.set(accountId, state);
|
|
229
|
+
}
|
|
230
|
+
return state;
|
|
231
|
+
}
|
|
232
|
+
// ── Account resolution ───────────────────────────────────────────────────────
|
|
233
|
+
/** Default relay deployed at https://github.com/openclaw/openclaw-app */
|
|
234
|
+
const DEFAULT_RELAY_URL = "wss://openclaw-relay.eldermoo8718.workers.dev";
|
|
235
|
+
const DEFAULT_ROOM_ID = "default";
|
|
236
|
+
function listAccountIds(cfg) {
|
|
237
|
+
const ids = Object.keys(cfg.channels?.[CHANNEL_ID]?.accounts ?? {});
|
|
238
|
+
// Always expose at least the default account so the Control UI
|
|
239
|
+
// shows a pre-filled entry without the user having to click "Add Entry".
|
|
240
|
+
if (!ids.includes(DEFAULT_ACCOUNT_ID))
|
|
241
|
+
ids.unshift(DEFAULT_ACCOUNT_ID);
|
|
242
|
+
return ids;
|
|
243
|
+
}
|
|
244
|
+
function resolveAccount(cfg, accountId) {
|
|
245
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
246
|
+
const acct = cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
|
|
247
|
+
const relayUrl = acct.relayUrl || DEFAULT_RELAY_URL;
|
|
248
|
+
const roomId = acct.roomId || DEFAULT_ROOM_ID;
|
|
249
|
+
return {
|
|
250
|
+
accountId: id,
|
|
251
|
+
enabled: acct.enabled !== false,
|
|
252
|
+
relayUrl,
|
|
253
|
+
relayToken: acct.relayToken ?? "",
|
|
254
|
+
roomId,
|
|
255
|
+
configured: true, // always ready out of the box with the default relay
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// ── Channel config schema (JSON Schema for Control UI) ───────────────────────
|
|
259
|
+
const mobileConfigSchema = {
|
|
260
|
+
schema: {
|
|
261
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
262
|
+
type: "object",
|
|
263
|
+
properties: {
|
|
264
|
+
accounts: {
|
|
265
|
+
type: "object",
|
|
266
|
+
additionalProperties: {
|
|
267
|
+
type: "object",
|
|
268
|
+
properties: {
|
|
269
|
+
enabled: { type: "boolean" },
|
|
270
|
+
relayUrl: {
|
|
271
|
+
type: "string",
|
|
272
|
+
description: "CF Worker relay WebSocket URL",
|
|
273
|
+
},
|
|
274
|
+
relayToken: {
|
|
275
|
+
type: "string",
|
|
276
|
+
description: "Shared secret for relay authentication",
|
|
277
|
+
},
|
|
278
|
+
roomId: {
|
|
279
|
+
type: "string",
|
|
280
|
+
description: "Room ID for relay routing",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
additionalProperties: false,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
},
|
|
289
|
+
uiHints: {
|
|
290
|
+
relayUrl: {
|
|
291
|
+
label: "Relay URL",
|
|
292
|
+
placeholder: DEFAULT_RELAY_URL,
|
|
293
|
+
},
|
|
294
|
+
relayToken: {
|
|
295
|
+
label: "Relay Token",
|
|
296
|
+
sensitive: true,
|
|
297
|
+
},
|
|
298
|
+
roomId: {
|
|
299
|
+
label: "Room ID",
|
|
300
|
+
placeholder: DEFAULT_ROOM_ID,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
// ── Channel plugin ───────────────────────────────────────────────────────────
|
|
305
|
+
const channel = {
|
|
306
|
+
id: CHANNEL_ID,
|
|
307
|
+
meta: {
|
|
308
|
+
id: CHANNEL_ID,
|
|
309
|
+
label: "OpenClaw App",
|
|
310
|
+
selectionLabel: "OpenClaw App App",
|
|
311
|
+
blurb: "Chat via the OpenClaw App app through a relay.",
|
|
312
|
+
detailLabel: "Mobile App",
|
|
313
|
+
aliases: ["mobile"],
|
|
314
|
+
},
|
|
315
|
+
capabilities: {
|
|
316
|
+
chatTypes: ["direct"],
|
|
317
|
+
},
|
|
318
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
319
|
+
configSchema: mobileConfigSchema,
|
|
320
|
+
config: {
|
|
321
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
322
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
|
|
323
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
324
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
325
|
+
const key = accountId || DEFAULT_ACCOUNT_ID;
|
|
326
|
+
const base = cfg.channels?.[CHANNEL_ID] ?? {};
|
|
327
|
+
const accounts = base.accounts ?? {};
|
|
328
|
+
return {
|
|
329
|
+
...cfg,
|
|
330
|
+
channels: {
|
|
331
|
+
...cfg.channels,
|
|
332
|
+
[CHANNEL_ID]: {
|
|
333
|
+
...base,
|
|
334
|
+
accounts: {
|
|
335
|
+
...accounts,
|
|
336
|
+
[key]: {
|
|
337
|
+
...accounts[key],
|
|
338
|
+
enabled,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
isConfigured: (account) => account.configured,
|
|
346
|
+
describeAccount: (account) => ({
|
|
347
|
+
accountId: account.accountId,
|
|
348
|
+
enabled: account.enabled,
|
|
349
|
+
configured: account.configured,
|
|
350
|
+
relayUrl: account.relayUrl,
|
|
351
|
+
roomId: account.roomId,
|
|
352
|
+
}),
|
|
353
|
+
// OpenClaw's resolveOutboundTarget() falls back to this when no explicit
|
|
354
|
+
// `to` is provided in delivery config. Our channel always targets the
|
|
355
|
+
// single connected mobile user.
|
|
356
|
+
resolveDefaultTo: () => "openclaw-app-user",
|
|
357
|
+
},
|
|
358
|
+
outbound: {
|
|
359
|
+
deliveryMode: "direct",
|
|
360
|
+
// OpenClaw's ChannelOutboundAdapter.resolveTarget
|
|
361
|
+
// Called by resolveOutboundTarget() in src/infra/outbound/targets.ts
|
|
362
|
+
// to validate/normalize the delivery target address.
|
|
363
|
+
resolveTarget: ({ to }) => {
|
|
364
|
+
const target = to?.trim() || "openclaw-app-user";
|
|
365
|
+
return { ok: true, to: target };
|
|
366
|
+
},
|
|
367
|
+
// OpenClaw's ChannelOutboundAdapter.sendText
|
|
368
|
+
// Called by createPluginHandler() in src/infra/outbound/deliver.ts
|
|
369
|
+
// ctx: ChannelOutboundContext = { cfg, to, text, accountId, ... }
|
|
370
|
+
sendText: async (ctx) => {
|
|
371
|
+
const text = ctx.text ?? "";
|
|
372
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
373
|
+
const state = getRelayState(accountId);
|
|
374
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
375
|
+
throw new Error("relay not connected");
|
|
376
|
+
}
|
|
377
|
+
const runtime = pluginRuntime;
|
|
378
|
+
let outText = text;
|
|
379
|
+
if (runtime) {
|
|
380
|
+
const cfg = runtime.config.loadConfig();
|
|
381
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
382
|
+
cfg,
|
|
383
|
+
channel: CHANNEL_ID,
|
|
384
|
+
accountId,
|
|
385
|
+
});
|
|
386
|
+
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
387
|
+
}
|
|
388
|
+
// Prefer the App's current active session for delivery
|
|
389
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
390
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
391
|
+
throw new Error("no active E2E session available");
|
|
392
|
+
}
|
|
393
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
394
|
+
if (!sessionE2E?.ready) {
|
|
395
|
+
throw new Error("persistent E2E session not ready");
|
|
396
|
+
}
|
|
397
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
398
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
399
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
400
|
+
const plainMsg = JSON.stringify({
|
|
401
|
+
type: "message",
|
|
402
|
+
role: "assistant",
|
|
403
|
+
content: outText,
|
|
404
|
+
sessionKey: replySessionKey,
|
|
405
|
+
chatSessionKey: chatKey,
|
|
406
|
+
messageId,
|
|
407
|
+
});
|
|
408
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
409
|
+
encrypted.sessionKey = replySessionKey;
|
|
410
|
+
encrypted.chatSessionKey = chatKey;
|
|
411
|
+
encrypted.messageId = messageId;
|
|
412
|
+
const outMsg = JSON.stringify(encrypted);
|
|
413
|
+
const logger = pluginRuntime?.logger ?? console;
|
|
414
|
+
logger.info?.(`[${CHANNEL_ID}] sendText: targetSessionKey=${replySessionKey} chatKey=${chatKey} wsState=${state.ws?.readyState} msgLen=${outMsg.length}`);
|
|
415
|
+
state.ws.send(outMsg);
|
|
416
|
+
return {
|
|
417
|
+
channel: CHANNEL_ID,
|
|
418
|
+
messageId,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
// OpenClaw's ChannelOutboundAdapter.sendMedia (required alongside sendText)
|
|
422
|
+
sendMedia: async (ctx) => {
|
|
423
|
+
// Media not supported over E2E relay — send caption text only
|
|
424
|
+
const text = ctx.text ?? "";
|
|
425
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
426
|
+
const state = getRelayState(accountId);
|
|
427
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
428
|
+
throw new Error("relay not connected");
|
|
429
|
+
}
|
|
430
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
431
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
432
|
+
throw new Error("no active E2E session available");
|
|
433
|
+
}
|
|
434
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
435
|
+
if (!sessionE2E?.ready) {
|
|
436
|
+
throw new Error("persistent E2E session not ready");
|
|
437
|
+
}
|
|
438
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
439
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
440
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
441
|
+
const plainMsg = JSON.stringify({
|
|
442
|
+
type: "message",
|
|
443
|
+
role: "assistant",
|
|
444
|
+
content: text || "[media]",
|
|
445
|
+
sessionKey: replySessionKey,
|
|
446
|
+
chatSessionKey: chatKey,
|
|
447
|
+
messageId,
|
|
448
|
+
});
|
|
449
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
450
|
+
encrypted.sessionKey = replySessionKey;
|
|
451
|
+
encrypted.chatSessionKey = chatKey;
|
|
452
|
+
encrypted.messageId = messageId;
|
|
453
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
454
|
+
return {
|
|
455
|
+
channel: CHANNEL_ID,
|
|
456
|
+
messageId: `mobile-media-${Date.now()}`,
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
status: {
|
|
461
|
+
defaultRuntime: {
|
|
462
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
463
|
+
running: false,
|
|
464
|
+
connected: false,
|
|
465
|
+
lastConnectedAt: null,
|
|
466
|
+
lastDisconnect: null,
|
|
467
|
+
lastStartAt: null,
|
|
468
|
+
lastStopAt: null,
|
|
469
|
+
lastError: null,
|
|
470
|
+
lastInboundAt: null,
|
|
471
|
+
},
|
|
472
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
473
|
+
configured: snapshot.configured ?? false,
|
|
474
|
+
running: snapshot.running ?? false,
|
|
475
|
+
connected: snapshot.connected ?? false,
|
|
476
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
477
|
+
}),
|
|
478
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
479
|
+
accountId: account.accountId,
|
|
480
|
+
enabled: account.enabled,
|
|
481
|
+
configured: account.configured,
|
|
482
|
+
relayUrl: account.relayUrl || "(not set)",
|
|
483
|
+
running: runtime?.running ?? false,
|
|
484
|
+
connected: runtime?.connected ?? false,
|
|
485
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
486
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
487
|
+
lastError: runtime?.lastError ?? null,
|
|
488
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
489
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
490
|
+
}),
|
|
491
|
+
},
|
|
492
|
+
gateway: {
|
|
493
|
+
startAccount: async (ctx) => {
|
|
494
|
+
const account = ctx.account;
|
|
495
|
+
const accountId = account.accountId;
|
|
496
|
+
const state = getRelayState(accountId);
|
|
497
|
+
cleanupRelay(state);
|
|
498
|
+
state.gatewayCtx = ctx;
|
|
499
|
+
state.statusSink = (patch) => ctx.setStatus({ accountId, ...patch });
|
|
500
|
+
ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
|
|
501
|
+
if (!account.configured) {
|
|
502
|
+
const msg = "relayUrl not configured";
|
|
503
|
+
ctx.setStatus({ accountId, lastError: msg });
|
|
504
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
connectRelay(ctx, account);
|
|
508
|
+
const signal = ctx.abortSignal;
|
|
509
|
+
if (signal) {
|
|
510
|
+
await new Promise((resolve) => {
|
|
511
|
+
if (signal.aborted) {
|
|
512
|
+
resolve();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
516
|
+
});
|
|
517
|
+
cleanupRelay(state);
|
|
518
|
+
ctx.setStatus({ accountId, running: false, connected: false });
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
stopAccount: async (ctx) => {
|
|
522
|
+
const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
523
|
+
const state = getRelayState(accountId);
|
|
524
|
+
cleanupRelay(state);
|
|
525
|
+
ctx.setStatus?.({
|
|
526
|
+
accountId,
|
|
527
|
+
running: false,
|
|
528
|
+
connected: false,
|
|
529
|
+
lastStopAt: new Date().toISOString(),
|
|
530
|
+
});
|
|
531
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
// ── Relay bridge ─────────────────────────────────────────────────────────────
|
|
536
|
+
function cleanupRelay(state) {
|
|
537
|
+
if (state.pingTimer) {
|
|
538
|
+
clearInterval(state.pingTimer);
|
|
539
|
+
state.pingTimer = null;
|
|
540
|
+
}
|
|
541
|
+
if (state.reconnectTimer) {
|
|
542
|
+
clearTimeout(state.reconnectTimer);
|
|
543
|
+
state.reconnectTimer = null;
|
|
544
|
+
}
|
|
545
|
+
if (state.ws) {
|
|
546
|
+
try {
|
|
547
|
+
state.ws.close();
|
|
548
|
+
}
|
|
549
|
+
catch { }
|
|
550
|
+
state.ws = null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function connectRelay(ctx, account) {
|
|
554
|
+
const accountId = account.accountId;
|
|
555
|
+
const state = getRelayState(accountId);
|
|
556
|
+
state.relayToken = account.relayToken ?? "";
|
|
557
|
+
const base = account.relayUrl.replace(/\/$/, "");
|
|
558
|
+
const params = new URLSearchParams({
|
|
559
|
+
role: "plugin",
|
|
560
|
+
room: account.roomId,
|
|
561
|
+
});
|
|
562
|
+
if (account.relayToken)
|
|
563
|
+
params.set("token", account.relayToken);
|
|
564
|
+
const url = `${base}/ws?${params.toString()}`;
|
|
565
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Connecting to relay: ${url}`);
|
|
566
|
+
try {
|
|
567
|
+
state.ws = new WebSocket(url);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const msg = `WebSocket create failed: ${err}`;
|
|
571
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
|
|
572
|
+
state.statusSink?.({ lastError: msg });
|
|
573
|
+
scheduleReconnect(ctx, account);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
state.ws.addEventListener("open", () => {
|
|
577
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
|
|
578
|
+
if (state.reconnectTimer) {
|
|
579
|
+
clearTimeout(state.reconnectTimer);
|
|
580
|
+
state.reconnectTimer = null;
|
|
581
|
+
}
|
|
582
|
+
if (state.pingTimer)
|
|
583
|
+
clearInterval(state.pingTimer);
|
|
584
|
+
state.pingTimer = setInterval(() => {
|
|
585
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
586
|
+
state.ws.send("ping");
|
|
587
|
+
}
|
|
588
|
+
}, PING_INTERVAL);
|
|
589
|
+
state.statusSink?.({
|
|
590
|
+
connected: true,
|
|
591
|
+
lastConnectedAt: new Date().toISOString(),
|
|
592
|
+
lastError: null,
|
|
593
|
+
});
|
|
594
|
+
// E2E handshakes are initiated per-session when `peer_joined` arrives
|
|
595
|
+
// (each app connection carries its own sessionKey UUID).
|
|
596
|
+
});
|
|
597
|
+
state.ws.addEventListener("message", (event) => {
|
|
598
|
+
handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
|
|
599
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
state.ws.addEventListener("close", (event) => {
|
|
603
|
+
if (state.ws !== null && state.ws !== event.target)
|
|
604
|
+
return; // Ignore stale connections
|
|
605
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting... (code: ${event.code}, reason: ${event.reason})`);
|
|
606
|
+
state.ws = null;
|
|
607
|
+
if (state.pingTimer) {
|
|
608
|
+
clearInterval(state.pingTimer);
|
|
609
|
+
state.pingTimer = null;
|
|
610
|
+
}
|
|
611
|
+
state.statusSink?.({
|
|
612
|
+
connected: false,
|
|
613
|
+
lastDisconnect: new Date().toISOString(),
|
|
614
|
+
});
|
|
615
|
+
scheduleReconnect(ctx, account);
|
|
616
|
+
});
|
|
617
|
+
state.ws.addEventListener("error", (event) => {
|
|
618
|
+
const errMsg = event?.message || String(event);
|
|
619
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error: ${errMsg}`);
|
|
620
|
+
state.statusSink?.({ lastError: `WebSocket error: ${errMsg}` });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function scheduleReconnect(ctx, account) {
|
|
624
|
+
const state = getRelayState(account.accountId);
|
|
625
|
+
if (state.reconnectTimer)
|
|
626
|
+
return;
|
|
627
|
+
state.reconnectTimer = setTimeout(() => {
|
|
628
|
+
state.reconnectTimer = null;
|
|
629
|
+
connectRelay(ctx, account);
|
|
630
|
+
}, RECONNECT_DELAY);
|
|
631
|
+
}
|
|
632
|
+
async function handleRelayMessage(ctx, accountId, state, raw) {
|
|
633
|
+
// Skip ping/pong
|
|
634
|
+
if (raw === "ping" || raw === "pong")
|
|
635
|
+
return;
|
|
636
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
|
|
637
|
+
const msg = JSON.parse(raw);
|
|
638
|
+
// App's handshake request (V2: App initiates with its persistent PubKey)
|
|
639
|
+
if (msg.type === "handshake") {
|
|
640
|
+
const peerPubKey = msg.pubkey;
|
|
641
|
+
const sessionKey = msg.sessionKey;
|
|
642
|
+
const msgTs = msg.ts;
|
|
643
|
+
if (!peerPubKey) {
|
|
644
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] handshake missing pubkey, ignoring`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (state.relayToken) {
|
|
648
|
+
const peerMac = msg.mac;
|
|
649
|
+
const peerTs = msg.ts;
|
|
650
|
+
const version = msg.v;
|
|
651
|
+
if (!peerMac || peerTs == null) {
|
|
652
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing auth fields`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (version !== HANDSHAKE_AUTH_VERSION) {
|
|
656
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Unsupported handshake version: ${version}`);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!isHandshakeTsFresh(peerTs)) {
|
|
660
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// V2: Derive persistent shared secret and store it
|
|
665
|
+
await e2eHandleHandshake(accountId, peerPubKey);
|
|
666
|
+
if (sessionKey) {
|
|
667
|
+
state.lastActiveSessionKey = sessionKey;
|
|
668
|
+
}
|
|
669
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake handled for app pubkey ${peerPubKey.slice(0, 8)}...`);
|
|
670
|
+
// Reply with our persistent pubkey (Plugin PubKey) so App knows it
|
|
671
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
672
|
+
const replyPayload = {
|
|
673
|
+
type: "handshake",
|
|
674
|
+
sessionKey: sessionKey || "",
|
|
675
|
+
pubkey: accountE2E.pluginPubB64,
|
|
676
|
+
};
|
|
677
|
+
if (state.relayToken) {
|
|
678
|
+
const ts = Date.now();
|
|
679
|
+
const mac = await buildHandshakeMac(state.relayToken, "plugin", sessionKey || "", accountE2E.pluginPubB64, ts, HANDSHAKE_AUTH_VERSION);
|
|
680
|
+
replyPayload.v = HANDSHAKE_AUTH_VERSION;
|
|
681
|
+
replyPayload.ts = ts;
|
|
682
|
+
replyPayload.mac = mac;
|
|
683
|
+
}
|
|
684
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
685
|
+
state.ws.send(JSON.stringify(replyPayload));
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// Process E2E encrypted messages from the App
|
|
690
|
+
if (msg.type === "encrypted") {
|
|
691
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
692
|
+
// V2.1: Use the explicit explicit device pubkey embedded in the envelope, otherwise
|
|
693
|
+
// fallback to the connection activeDevicePubKey handling (V2/legacy)
|
|
694
|
+
const devicePubKey = msg.pubkey || accountE2E.activeDevicePubKey;
|
|
695
|
+
if (!devicePubKey) {
|
|
696
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] No active device key, dropping encrypted message`);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, devicePubKey);
|
|
700
|
+
if (!sessionE2E?.ready) {
|
|
701
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Failed to load SharedSecret for active device, dropping`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
|
|
706
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted message: ${plaintext.slice(0, 200)}`);
|
|
707
|
+
const innerMsg = JSON.parse(plaintext);
|
|
708
|
+
await handleInbound(ctx, accountId, innerMsg);
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decryption failed: ${e}`);
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Drop any plaintext message that sneaks through (only handshake/encrypted should be processed)
|
|
716
|
+
if (msg.type === "message" || msg.type === "delta" || msg.type === "final" || msg.type === "abort") {
|
|
717
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} message for security`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Silently ignore relay system events that don't need action on the plugin side
|
|
721
|
+
if (msg.type === "peer_joined" || msg.type === "peer_left") {
|
|
722
|
+
ctx.log?.debug?.(`[${CHANNEL_ID}] [${accountId}] Relay system event ignored: ${msg.type}`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// Fallback for any other system message
|
|
726
|
+
await handleInbound(ctx, accountId, msg);
|
|
727
|
+
}
|
|
728
|
+
async function handleRpc(ctx, accountId, msg) {
|
|
729
|
+
if (msg.type !== "rpc")
|
|
730
|
+
return false;
|
|
731
|
+
const reqId = msg.id;
|
|
732
|
+
const method = msg.method;
|
|
733
|
+
const params = msg.params ?? {};
|
|
734
|
+
const replySessionKey = msg.sessionKey;
|
|
735
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleRpc: method=${method} id=${reqId}`);
|
|
736
|
+
const runtime = pluginRuntime;
|
|
737
|
+
if (!runtime) {
|
|
738
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] RPC: no runtime`);
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
const relayState = getRelayState(accountId);
|
|
742
|
+
const sendRpcReply = async (data, error) => {
|
|
743
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN)
|
|
744
|
+
return;
|
|
745
|
+
if (!replySessionKey)
|
|
746
|
+
return;
|
|
747
|
+
try {
|
|
748
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
749
|
+
if (!accountE2E.activeDevicePubKey)
|
|
750
|
+
return;
|
|
751
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
752
|
+
if (!sessionE2E?.ready)
|
|
753
|
+
return;
|
|
754
|
+
const msgId = crypto.randomUUID();
|
|
755
|
+
const inner = JSON.stringify({ type: "rpc-response", id: reqId, data, error: error ?? null, sessionKey: replySessionKey, messageId: msgId });
|
|
756
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, inner));
|
|
757
|
+
encrypted.sessionKey = replySessionKey;
|
|
758
|
+
encrypted.messageId = msgId;
|
|
759
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] sendRpcReply fail: ${e}`);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
try {
|
|
766
|
+
if (method === "runtime.inspect") {
|
|
767
|
+
// Debug: return top-level keys of runtime object
|
|
768
|
+
const keys = Object.keys(runtime ?? {});
|
|
769
|
+
const cfgKeys = runtime?.config ? Object.keys(runtime.config) : [];
|
|
770
|
+
const cfg = runtime?.config?.loadConfig?.() ?? {};
|
|
771
|
+
const cfgTopKeys = Object.keys(cfg);
|
|
772
|
+
// Try to find agents in config
|
|
773
|
+
const agentsCfg = cfg.agents ?? cfg.agent ?? {};
|
|
774
|
+
const agentsListKeys = Object.keys(agentsCfg);
|
|
775
|
+
const agentsList = agentsCfg.list ?? agentsCfg.agents ?? agentsCfg ?? [];
|
|
776
|
+
await sendRpcReply({
|
|
777
|
+
runtimeKeys: keys,
|
|
778
|
+
configKeys: cfgKeys,
|
|
779
|
+
cfgTopKeys,
|
|
780
|
+
agentsCfgKeys: agentsListKeys,
|
|
781
|
+
agentsListType: Array.isArray(agentsList) ? `array[${agentsList.length}]` : typeof agentsList,
|
|
782
|
+
agentsSample: Array.isArray(agentsList) ? agentsList.slice(0, 3) : agentsList,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
else if (method === "runtime.inspect.deep") {
|
|
786
|
+
// Deep inspection to discover skills API surface
|
|
787
|
+
const allRuntimeKeys = runtime ? Object.keys(runtime) : [];
|
|
788
|
+
// Walk every top-level key and record its type + sub-keys (if object/function)
|
|
789
|
+
const runtimeShape = {};
|
|
790
|
+
for (const k of allRuntimeKeys) {
|
|
791
|
+
try {
|
|
792
|
+
const v = runtime[k];
|
|
793
|
+
const t = typeof v;
|
|
794
|
+
if (t === 'function') {
|
|
795
|
+
runtimeShape[k] = 'function';
|
|
796
|
+
}
|
|
797
|
+
else if (v && t === 'object') {
|
|
798
|
+
runtimeShape[k] = Object.keys(v).slice(0, 20);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
runtimeShape[k] = t;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch (_) {
|
|
805
|
+
runtimeShape[k] = 'error';
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// Specifically probe skills-related paths
|
|
809
|
+
const skillsProbe = {};
|
|
810
|
+
const candidates = ['skills', 'skillManager', 'skillLoader', 'skillRegistry', 'pluginSkills'];
|
|
811
|
+
for (const c of candidates) {
|
|
812
|
+
if (runtime?.[c]) {
|
|
813
|
+
skillsProbe[c] = typeof runtime[c] === 'object'
|
|
814
|
+
? Object.keys(runtime[c])
|
|
815
|
+
: typeof runtime[c];
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Also probe config.skills
|
|
819
|
+
let cfgSkills = null;
|
|
820
|
+
try {
|
|
821
|
+
const cfg = runtime.config.loadConfig();
|
|
822
|
+
cfgSkills = {
|
|
823
|
+
topKeys: Object.keys(cfg?.skills ?? {}),
|
|
824
|
+
entriesKeys: Object.keys(cfg?.skills?.entries ?? {}),
|
|
825
|
+
entriesSample: Object.entries(cfg?.skills?.entries ?? {}).slice(0, 5)
|
|
826
|
+
.map(([k, v]) => ({ name: k, enabled: v?.enabled })),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
catch (_) { }
|
|
830
|
+
await sendRpcReply({ runtimeShape, skillsProbe, cfgSkills });
|
|
831
|
+
}
|
|
832
|
+
else if (method === "agents.list") {
|
|
833
|
+
const cfg = runtime.config.loadConfig();
|
|
834
|
+
// cfg.agents structure: { defaults: {...}, list: [{id, name, workspace, ...}], ... }
|
|
835
|
+
const agentsCfg = cfg.agents ?? {};
|
|
836
|
+
let raw = [];
|
|
837
|
+
if (Array.isArray(agentsCfg.list)) {
|
|
838
|
+
raw = agentsCfg.list;
|
|
839
|
+
}
|
|
840
|
+
else if (Array.isArray(agentsCfg.agents)) {
|
|
841
|
+
raw = agentsCfg.agents;
|
|
842
|
+
}
|
|
843
|
+
else if (Array.isArray(agentsCfg)) {
|
|
844
|
+
raw = agentsCfg;
|
|
845
|
+
}
|
|
846
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] agents raw count: ${raw.length}`);
|
|
847
|
+
const list = raw.map((a) => ({
|
|
848
|
+
id: a.id ?? a.agentId ?? '',
|
|
849
|
+
name: a.name ?? a.id ?? '',
|
|
850
|
+
emoji: a.emoji ?? null,
|
|
851
|
+
workspace: a.workspace ?? null,
|
|
852
|
+
createdAt: a.createdAt ?? null,
|
|
853
|
+
})).filter((a) => a.id);
|
|
854
|
+
await sendRpcReply({ agents: list });
|
|
855
|
+
}
|
|
856
|
+
else if (method === "sessions.list") {
|
|
857
|
+
// Sessions are stored locally in the mobile app's Drift DB.
|
|
858
|
+
// The Gateway/CLI has no sessions API we can call reliably.
|
|
859
|
+
// Return empty — session_provider.dart falls back to local DB which is the source of truth.
|
|
860
|
+
await sendRpcReply({ sessions: [] });
|
|
861
|
+
}
|
|
862
|
+
else if (method === "agents.create") {
|
|
863
|
+
// Use CLI: openclaw agents add <name> --workspace <path> [--model <model>]
|
|
864
|
+
const name = params.name;
|
|
865
|
+
const workspace = params.workspace ?? `~/.openclaw/agents/${name}`;
|
|
866
|
+
const model = params.model;
|
|
867
|
+
if (!name) {
|
|
868
|
+
await sendRpcReply(null, "agents.create: missing required param 'name'");
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
let cmd = `openclaw agents add ${name} --workspace ${workspace}`;
|
|
872
|
+
if (model)
|
|
873
|
+
cmd += ` --model ${model}`;
|
|
874
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] agents.create cmd: ${cmd}`);
|
|
875
|
+
const result = await runtime.system.runCommandWithTimeout(cmd, 15000);
|
|
876
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] agents.create result: ${JSON.stringify(result)}`);
|
|
877
|
+
if (result.exitCode !== 0) {
|
|
878
|
+
await sendRpcReply(null, `Command failed (exit ${result.exitCode}): ${result.stderr ?? result.stdout ?? ''}`);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
await sendRpcReply({ name, workspace, output: result.stdout ?? '' });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
else if (method === "tools.list") {
|
|
886
|
+
// Use `openclaw skills list --json` CLI — most reliable source for loaded skills.
|
|
887
|
+
// Falls back to fs scan of ~/.openclaw/skills if CLI is unavailable.
|
|
888
|
+
let tools = [];
|
|
889
|
+
try {
|
|
890
|
+
// Try object form first; fall back to string form (same as agents.create)
|
|
891
|
+
let cliResult;
|
|
892
|
+
try {
|
|
893
|
+
cliResult = await runtime.system.runCommandWithTimeout({ cmd: 'openclaw', args: ['skills', 'list', '--json'] }, 10000);
|
|
894
|
+
}
|
|
895
|
+
catch (_) {
|
|
896
|
+
cliResult = await runtime.system.runCommandWithTimeout('openclaw skills list --json', 10000);
|
|
897
|
+
}
|
|
898
|
+
if (cliResult && cliResult.exitCode === 0 && cliResult.stdout?.trim()) {
|
|
899
|
+
const parsed = JSON.parse(cliResult.stdout.trim());
|
|
900
|
+
const raw = Array.isArray(parsed) ? parsed
|
|
901
|
+
: Array.isArray(parsed?.skills) ? parsed.skills
|
|
902
|
+
: parsed?.data ?? [];
|
|
903
|
+
tools = raw
|
|
904
|
+
.filter((s) => s['user-invocable'] !== false && s['user-invocable'] !== 'false')
|
|
905
|
+
.map((s) => ({
|
|
906
|
+
name: s.name ?? s.id ?? '',
|
|
907
|
+
description: s.description ?? s.desc ?? '',
|
|
908
|
+
category: s.category ?? '',
|
|
909
|
+
userInvocable: true,
|
|
910
|
+
}))
|
|
911
|
+
.filter((t) => t.name);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch (_) { }
|
|
915
|
+
// Fallback: scan ~/.openclaw for all skills/ subdirs, deduplicated
|
|
916
|
+
if (tools.length === 0) {
|
|
917
|
+
try {
|
|
918
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
919
|
+
const fs = globalThis.require?.('fs') ?? require('fs');
|
|
920
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
921
|
+
const path = globalThis.require?.('path') ?? require('path');
|
|
922
|
+
const cfg = runtime.config.loadConfig();
|
|
923
|
+
const proc = globalThis.process;
|
|
924
|
+
const home = proc?.env?.HOME ?? proc?.env?.USERPROFILE ?? '~';
|
|
925
|
+
const ocRoot = `${home}/.openclaw`;
|
|
926
|
+
// Collect all skills/ directories under ~/.openclaw recursively (depth 2)
|
|
927
|
+
const skillDirs = [];
|
|
928
|
+
const addSkillsDir = (dir) => {
|
|
929
|
+
try {
|
|
930
|
+
if (fs.existsSync(dir))
|
|
931
|
+
skillDirs.push(dir);
|
|
932
|
+
}
|
|
933
|
+
catch (_) { }
|
|
934
|
+
};
|
|
935
|
+
// ~/.openclaw/skills
|
|
936
|
+
addSkillsDir(`${ocRoot}/skills`);
|
|
937
|
+
// ~/.openclaw/*/skills (workspace dirs one level deep)
|
|
938
|
+
try {
|
|
939
|
+
for (const entry of fs.readdirSync(ocRoot)) {
|
|
940
|
+
const sub = path.join(ocRoot, entry, 'skills');
|
|
941
|
+
addSkillsDir(sub);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch (_) { }
|
|
945
|
+
// Extra dirs from config
|
|
946
|
+
for (const d of (cfg?.skills?.load?.extraDirs ?? []))
|
|
947
|
+
addSkillsDir(d);
|
|
948
|
+
const seen = new Set();
|
|
949
|
+
for (const dir of skillDirs) {
|
|
950
|
+
let entries;
|
|
951
|
+
try {
|
|
952
|
+
entries = fs.readdirSync(dir);
|
|
953
|
+
}
|
|
954
|
+
catch (_) {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
for (const entry of entries) {
|
|
958
|
+
const skillFile = path.join(dir, entry, 'SKILL.md');
|
|
959
|
+
let content;
|
|
960
|
+
try {
|
|
961
|
+
content = fs.readFileSync(skillFile, 'utf8');
|
|
962
|
+
}
|
|
963
|
+
catch (_) {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const fm = _parseSkillFrontmatter(content);
|
|
967
|
+
const name = fm.name ?? entry;
|
|
968
|
+
if (!name || seen.has(name))
|
|
969
|
+
continue;
|
|
970
|
+
seen.add(name);
|
|
971
|
+
if (fm['user-invocable'] === false || fm['user-invocable'] === 'false')
|
|
972
|
+
continue;
|
|
973
|
+
if (cfg?.skills?.entries?.[name]?.enabled === false)
|
|
974
|
+
continue;
|
|
975
|
+
tools.push({ name, description: fm.description ?? '', category: '', userInvocable: true });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch (_) { }
|
|
980
|
+
}
|
|
981
|
+
await sendRpcReply({ tools });
|
|
982
|
+
}
|
|
983
|
+
else if (method === "inbox.test") {
|
|
984
|
+
// Debug/test: send a test message to the App inbox
|
|
985
|
+
const testText = params.text || "🔔 Inbox test — if you see this, the pipeline works!";
|
|
986
|
+
const sent = await _sendToInbox(accountId, testText, { logger: ctx.log });
|
|
987
|
+
await sendRpcReply({ sent, message: sent ? "delivered" : "no active E2E session" });
|
|
988
|
+
}
|
|
989
|
+
else if (method === "tools.invoke") {
|
|
990
|
+
// Invoke a skill by injecting a /skill <name> command into the chat session
|
|
991
|
+
const toolName = params.name;
|
|
992
|
+
const input = (params.params?.input ?? params.input ?? '');
|
|
993
|
+
if (!toolName) {
|
|
994
|
+
await sendRpcReply(null, "tools.invoke: missing required param 'name'");
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
try {
|
|
998
|
+
// Send /skill <name> [input] as a chat message to the current session
|
|
999
|
+
const cmd = input ? `/skill ${toolName} ${input}` : `/skill ${toolName}`;
|
|
1000
|
+
const wireSessionKey = replySessionKey ?? 'rpc';
|
|
1001
|
+
await handleInbound(ctx, accountId, {
|
|
1002
|
+
type: 'message',
|
|
1003
|
+
content: cmd,
|
|
1004
|
+
sessionKey: wireSessionKey,
|
|
1005
|
+
chatSessionKey: wireSessionKey,
|
|
1006
|
+
});
|
|
1007
|
+
await sendRpcReply({ dispatched: true, command: cmd });
|
|
1008
|
+
}
|
|
1009
|
+
catch (e) {
|
|
1010
|
+
await sendRpcReply(null, `tools.invoke '${toolName}' error: ${e}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
else if (method === "chat.abort") {
|
|
1015
|
+
const abortSessionKey = params.sessionKey || replySessionKey;
|
|
1016
|
+
const runId = params.runId;
|
|
1017
|
+
if (!abortSessionKey) {
|
|
1018
|
+
await sendRpcReply(null, "chat.abort: missing required param 'sessionKey'");
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
try {
|
|
1022
|
+
// Signal the reply engine to abort any active generation for this session/runId
|
|
1023
|
+
if (typeof runtime.channel.reply.abortDispatch === "function") {
|
|
1024
|
+
await runtime.channel.reply.abortDispatch(abortSessionKey, runId);
|
|
1025
|
+
await sendRpcReply({ aborted: true, sessionKey: abortSessionKey, runId });
|
|
1026
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Aborted chat dispatch for session ${abortSessionKey} (runId: ${runId})`);
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
await sendRpcReply(null, "chat.abort is not supported by this OpenClaw version");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
catch (e) {
|
|
1033
|
+
await sendRpcReply(null, `chat.abort error: ${e}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
await sendRpcReply(null, `Unknown RPC method: ${method}`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
catch (e) {
|
|
1042
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] RPC error: ${e}`);
|
|
1043
|
+
await sendRpcReply(null, String(e));
|
|
1044
|
+
}
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
async function handleInbound(ctx, accountId, msg) {
|
|
1048
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleInbound: type=${msg.type} content=${String(msg.content ?? "").slice(0, 100)}`);
|
|
1049
|
+
// Handle RPC requests (agents.list, sessions.list, etc.) without AI involvement
|
|
1050
|
+
if (await handleRpc(ctx, accountId, msg))
|
|
1051
|
+
return;
|
|
1052
|
+
if (msg.type !== "message" || !msg.content) {
|
|
1053
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Skipping non-message: type=${msg.type}`);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const state = getRelayState(accountId);
|
|
1057
|
+
state.statusSink?.({ lastInboundAt: new Date().toISOString() });
|
|
1058
|
+
const runtime = pluginRuntime;
|
|
1059
|
+
if (!runtime) {
|
|
1060
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const senderId = msg.senderId ?? "openclaw-app-user";
|
|
1064
|
+
const senderName = msg.senderName ?? "openclaw-app-user";
|
|
1065
|
+
const text = String(msg.content);
|
|
1066
|
+
const chatSessionKey = msg.chatSessionKey ? String(msg.chatSessionKey) : null;
|
|
1067
|
+
try {
|
|
1068
|
+
const cfg = runtime.config.loadConfig();
|
|
1069
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
1070
|
+
cfg,
|
|
1071
|
+
channel: CHANNEL_ID,
|
|
1072
|
+
accountId,
|
|
1073
|
+
peer: { kind: "direct", id: senderId },
|
|
1074
|
+
});
|
|
1075
|
+
// Derive an isolated Gateway session for each mobile conversation.
|
|
1076
|
+
//
|
|
1077
|
+
// chatSessionKey format from app: "agent:<agentId>:<uuid1>-<uuid2>"
|
|
1078
|
+
// We use this as the stable per-conversation identity on the Gateway side.
|
|
1079
|
+
// appSessionKey is the wire-level connection ID — used for E2E reply routing.
|
|
1080
|
+
const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
|
|
1081
|
+
const msgAgentId = msg.agentId ? String(msg.agentId) : null;
|
|
1082
|
+
let sessionKey;
|
|
1083
|
+
if (chatSessionKey) {
|
|
1084
|
+
// chatSessionKey from app: "agent:<agentId>:<uuid>" — use as-is for Gateway routing
|
|
1085
|
+
sessionKey = chatSessionKey;
|
|
1086
|
+
}
|
|
1087
|
+
else if (appSessionKey) {
|
|
1088
|
+
const agentId = msgAgentId || 'main';
|
|
1089
|
+
sessionKey = `agent:${agentId}:mobile-${appSessionKey}`;
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
sessionKey = route.mainSessionKey;
|
|
1093
|
+
}
|
|
1094
|
+
const from = `${CHANNEL_ID}:${senderId}`;
|
|
1095
|
+
const to = `user:${senderId}`;
|
|
1096
|
+
runtime.channel.activity.record({
|
|
1097
|
+
channel: CHANNEL_ID,
|
|
1098
|
+
accountId,
|
|
1099
|
+
direction: "inbound",
|
|
1100
|
+
});
|
|
1101
|
+
runtime.system.enqueueSystemEvent(`Mobile message from ${senderName}: ${text.slice(0, 160)}`, { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` });
|
|
1102
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
1103
|
+
channel: "OpenClaw App",
|
|
1104
|
+
from: `${senderName} (mobile)`,
|
|
1105
|
+
body: text,
|
|
1106
|
+
chatType: "direct",
|
|
1107
|
+
sender: { name: senderName, id: senderId },
|
|
1108
|
+
});
|
|
1109
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
1110
|
+
Body: body,
|
|
1111
|
+
BodyForAgent: text,
|
|
1112
|
+
RawBody: text,
|
|
1113
|
+
CommandBody: text,
|
|
1114
|
+
From: from,
|
|
1115
|
+
To: to,
|
|
1116
|
+
SessionKey: sessionKey,
|
|
1117
|
+
AccountId: accountId,
|
|
1118
|
+
ChatType: "direct",
|
|
1119
|
+
ConversationLabel: `Mobile DM from ${senderName}`,
|
|
1120
|
+
SenderName: senderName,
|
|
1121
|
+
SenderId: senderId,
|
|
1122
|
+
Provider: CHANNEL_ID,
|
|
1123
|
+
Surface: CHANNEL_ID,
|
|
1124
|
+
OriginatingChannel: CHANNEL_ID,
|
|
1125
|
+
OriginatingTo: to,
|
|
1126
|
+
});
|
|
1127
|
+
const textLimit = runtime.channel.text.resolveTextChunkLimit(cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 });
|
|
1128
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
1129
|
+
cfg, channel: CHANNEL_ID, accountId,
|
|
1130
|
+
});
|
|
1131
|
+
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
1132
|
+
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
1133
|
+
onTyping: async () => {
|
|
1134
|
+
const relayState = getRelayState(accountId);
|
|
1135
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN)
|
|
1136
|
+
return;
|
|
1137
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1138
|
+
if (!accountE2E.activeDevicePubKey)
|
|
1139
|
+
return;
|
|
1140
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1141
|
+
if (!sessionE2E?.ready)
|
|
1142
|
+
return;
|
|
1143
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1144
|
+
const innerPayload = {
|
|
1145
|
+
type: "typing",
|
|
1146
|
+
sessionKey: replySessionKey,
|
|
1147
|
+
};
|
|
1148
|
+
if (chatSessionKey)
|
|
1149
|
+
innerPayload.chatSessionKey = chatSessionKey;
|
|
1150
|
+
try {
|
|
1151
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1152
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1153
|
+
encrypted.sessionKey = replySessionKey;
|
|
1154
|
+
if (chatSessionKey)
|
|
1155
|
+
encrypted.chatSessionKey = chatSessionKey;
|
|
1156
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1157
|
+
}
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to send typing: ${e}`);
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
deliver: async (payload) => {
|
|
1163
|
+
const relayState = getRelayState(accountId);
|
|
1164
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
|
|
1165
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1169
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1170
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: no active device key for account`);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1174
|
+
if (!sessionE2E?.ready) {
|
|
1175
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: persistent session not ready`);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1179
|
+
if (payload.type === "typing") {
|
|
1180
|
+
const innerPayload = {
|
|
1181
|
+
type: "typing",
|
|
1182
|
+
sessionKey: replySessionKey,
|
|
1183
|
+
};
|
|
1184
|
+
if (chatSessionKey)
|
|
1185
|
+
innerPayload.chatSessionKey = chatSessionKey;
|
|
1186
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1187
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1188
|
+
encrypted.sessionKey = replySessionKey;
|
|
1189
|
+
if (chatSessionKey)
|
|
1190
|
+
encrypted.chatSessionKey = chatSessionKey;
|
|
1191
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const replyText = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
1195
|
+
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
1196
|
+
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
1197
|
+
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
1198
|
+
if (!chunk)
|
|
1199
|
+
continue;
|
|
1200
|
+
const messageId = crypto.randomUUID();
|
|
1201
|
+
const innerPayload = {
|
|
1202
|
+
type: "message",
|
|
1203
|
+
role: "assistant",
|
|
1204
|
+
content: chunk,
|
|
1205
|
+
sessionKey: replySessionKey,
|
|
1206
|
+
messageId,
|
|
1207
|
+
};
|
|
1208
|
+
if (chatSessionKey)
|
|
1209
|
+
innerPayload.chatSessionKey = chatSessionKey;
|
|
1210
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1211
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1212
|
+
encrypted.sessionKey = replySessionKey;
|
|
1213
|
+
encrypted.messageId = messageId;
|
|
1214
|
+
if (chatSessionKey)
|
|
1215
|
+
encrypted.chatSessionKey = chatSessionKey;
|
|
1216
|
+
const outMsg = JSON.stringify(encrypted);
|
|
1217
|
+
relayState.ws.send(outMsg);
|
|
1218
|
+
}
|
|
1219
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
|
|
1220
|
+
},
|
|
1221
|
+
onError: (err, info) => {
|
|
1222
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
1226
|
+
ctx: ctxPayload,
|
|
1227
|
+
cfg,
|
|
1228
|
+
dispatcher,
|
|
1229
|
+
replyOptions,
|
|
1230
|
+
});
|
|
1231
|
+
markDispatchIdle();
|
|
1232
|
+
const sessionCfg = cfg.session;
|
|
1233
|
+
const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
1234
|
+
agentId: route.agentId,
|
|
1235
|
+
});
|
|
1236
|
+
await runtime.channel.session.updateLastRoute({
|
|
1237
|
+
storePath,
|
|
1238
|
+
sessionKey,
|
|
1239
|
+
deliveryContext: {
|
|
1240
|
+
channel: CHANNEL_ID,
|
|
1241
|
+
to,
|
|
1242
|
+
accountId,
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
|
|
1246
|
+
}
|
|
1247
|
+
catch (e) {
|
|
1248
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1252
|
+
/**
|
|
1253
|
+
* Send a message directly to the App inbox via existing Relay/E2E.
|
|
1254
|
+
* Bypasses OpenClaw's channel outbound pipeline entirely — fully self-contained.
|
|
1255
|
+
* Returns true on success, false if no active E2E session is available.
|
|
1256
|
+
*/
|
|
1257
|
+
async function _sendToInbox(accountId, text, api) {
|
|
1258
|
+
const state = getRelayState(accountId);
|
|
1259
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
1260
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: relay not connected`);
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1264
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1265
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: no active device key`);
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
const targetE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1269
|
+
if (!targetE2E?.ready) {
|
|
1270
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: persistent e2e session not ready`);
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
const targetSessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
1274
|
+
const messageId = crypto.randomUUID();
|
|
1275
|
+
const plainMsg = JSON.stringify({
|
|
1276
|
+
type: "message",
|
|
1277
|
+
role: "assistant",
|
|
1278
|
+
content: text,
|
|
1279
|
+
sessionKey: targetSessionKey,
|
|
1280
|
+
chatSessionKey: INBOX_CHAT_SESSION_KEY,
|
|
1281
|
+
messageId,
|
|
1282
|
+
});
|
|
1283
|
+
const encrypted = JSON.parse(await e2eEncrypt(targetE2E, plainMsg));
|
|
1284
|
+
encrypted.sessionKey = targetSessionKey;
|
|
1285
|
+
encrypted.chatSessionKey = INBOX_CHAT_SESSION_KEY;
|
|
1286
|
+
encrypted.messageId = messageId;
|
|
1287
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
1288
|
+
api.logger?.info?.(`[${CHANNEL_ID}] _sendToInbox: targetSessionKey=${targetSessionKey} msgLen=${text.length} messageId=${messageId}`);
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
|
1292
|
+
export default function register(api) {
|
|
1293
|
+
pluginRuntime = api.runtime;
|
|
1294
|
+
api.registerChannel({ plugin: channel });
|
|
1295
|
+
// On gateway start, ensure the default account config exists so the
|
|
1296
|
+
// Control UI shows pre-filled values without any manual JSON editing.
|
|
1297
|
+
api.on("gateway_start", async () => {
|
|
1298
|
+
try {
|
|
1299
|
+
const runtime = pluginRuntime;
|
|
1300
|
+
if (!runtime)
|
|
1301
|
+
return;
|
|
1302
|
+
const cfg = runtime.config.loadConfig();
|
|
1303
|
+
// --- Monkey patch cron.add to auto-fill delivery.to ---
|
|
1304
|
+
if (runtime.cron && typeof runtime.cron.add === "function" && !runtime.cron.add.__patched) {
|
|
1305
|
+
const originalCronAdd = runtime.cron.add.bind(runtime.cron);
|
|
1306
|
+
runtime.cron.add = async (jobCreate) => {
|
|
1307
|
+
if (jobCreate && jobCreate.delivery && jobCreate.delivery.channel === CHANNEL_ID) {
|
|
1308
|
+
if (!jobCreate.delivery.to) {
|
|
1309
|
+
jobCreate.delivery.to = "openclaw-app-user";
|
|
1310
|
+
api.logger?.info?.(`[openclaw-app] Auto-filled delivery.to=openclaw-app-user for new cron job`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return await originalCronAdd(jobCreate);
|
|
1314
|
+
};
|
|
1315
|
+
runtime.cron.add.__patched = true;
|
|
1316
|
+
}
|
|
1317
|
+
const existing = cfg.channels?.[CHANNEL_ID]?.accounts?.[DEFAULT_ACCOUNT_ID];
|
|
1318
|
+
// Only write defaults if the account entry is completely absent
|
|
1319
|
+
if (existing !== undefined) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const patched = {
|
|
1323
|
+
...cfg,
|
|
1324
|
+
channels: {
|
|
1325
|
+
...cfg.channels,
|
|
1326
|
+
[CHANNEL_ID]: {
|
|
1327
|
+
...(cfg.channels?.[CHANNEL_ID] ?? {}),
|
|
1328
|
+
accounts: {
|
|
1329
|
+
...(cfg.channels?.[CHANNEL_ID]?.accounts ?? {}),
|
|
1330
|
+
[DEFAULT_ACCOUNT_ID]: {
|
|
1331
|
+
enabled: true,
|
|
1332
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
1333
|
+
roomId: DEFAULT_ROOM_ID,
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
await runtime.config.writeConfigFile(patched);
|
|
1340
|
+
api.logger?.info?.(`[openclaw-app] Wrote default account config (relayUrl=${DEFAULT_RELAY_URL}, roomId=${DEFAULT_ROOM_ID})`);
|
|
1341
|
+
}
|
|
1342
|
+
catch (err) {
|
|
1343
|
+
api.logger?.warn?.(`[openclaw-app] Could not write default config: ${err}`);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
api.logger?.info?.("[openclaw-app] Plugin registered");
|
|
1347
|
+
}
|
|
1348
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
1349
|
+
/**
|
|
1350
|
+
* Parse YAML frontmatter from a SKILL.md string.
|
|
1351
|
+
* Returns a flat key→value map for simple scalar fields.
|
|
1352
|
+
* Handles: name, description, user-invocable, disable-model-invocation, command-dispatch.
|
|
1353
|
+
*/
|
|
1354
|
+
function _parseSkillFrontmatter(content) {
|
|
1355
|
+
const result = {};
|
|
1356
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1357
|
+
if (!match)
|
|
1358
|
+
return result;
|
|
1359
|
+
for (const line of match[1].split('\n')) {
|
|
1360
|
+
const colon = line.indexOf(':');
|
|
1361
|
+
if (colon === -1)
|
|
1362
|
+
continue;
|
|
1363
|
+
const key = line.slice(0, colon).trim();
|
|
1364
|
+
const val = line.slice(colon + 1).trim();
|
|
1365
|
+
if (!key)
|
|
1366
|
+
continue;
|
|
1367
|
+
// Parse booleans
|
|
1368
|
+
if (val === 'true')
|
|
1369
|
+
result[key] = true;
|
|
1370
|
+
else if (val === 'false')
|
|
1371
|
+
result[key] = false;
|
|
1372
|
+
else
|
|
1373
|
+
result[key] = val;
|
|
1374
|
+
}
|
|
1375
|
+
return result;
|
|
1376
|
+
}
|
|
1377
|
+
//# sourceMappingURL=index.js.map
|