@botcord/daemon 0.2.51 → 0.2.53
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/dist/gateway/channels/http-types.d.ts +4 -1
- package/dist/gateway/channels/wechat.js +152 -19
- package/dist/gateway/types.d.ts +10 -0
- package/dist/index.js +85 -15
- package/dist/openclaw-discovery.js +33 -2
- package/dist/provision.js +55 -13
- package/dist/start-auth.js +2 -2
- package/dist/turn-text.js +9 -0
- package/package.json +1 -1
- package/src/__tests__/openclaw-discovery.test.ts +37 -2
- package/src/__tests__/provision.test.ts +67 -1
- package/src/__tests__/start-auth.test.ts +2 -2
- package/src/__tests__/turn-text.test.ts +3 -0
- package/src/__tests__/wechat-channel.test.ts +126 -8
- package/src/gateway/channels/http-types.ts +2 -1
- package/src/gateway/channels/wechat.ts +180 -19
- package/src/gateway/types.ts +11 -0
- package/src/index.ts +83 -16
- package/src/openclaw-discovery.ts +33 -2
- package/src/provision.ts +51 -12
- package/src/start-auth.ts +1 -1
- package/src/turn-text.ts +13 -0
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
export type FetchLike = (input: string, init?: {
|
|
11
11
|
method?: string;
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
|
-
body?: string;
|
|
13
|
+
body?: BodyInit | Uint8Array | string;
|
|
14
14
|
signal?: AbortSignal;
|
|
15
15
|
}) => Promise<{
|
|
16
16
|
status?: number;
|
|
17
17
|
ok?: boolean;
|
|
18
|
+
headers?: {
|
|
19
|
+
get(name: string): string | null;
|
|
20
|
+
};
|
|
18
21
|
text(): Promise<string>;
|
|
19
22
|
}>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createCipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
1
4
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
2
5
|
import { GatewayStateStore } from "./state-store.js";
|
|
3
6
|
import { loadGatewaySecret } from "./secret-store.js";
|
|
4
7
|
import { splitText } from "./text-split.js";
|
|
5
8
|
import { wechatHeaders, WECHAT_BASE_INFO } from "./wechat-http.js";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
9
|
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
10
|
+
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
8
11
|
/**
|
|
9
12
|
* Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
|
|
10
13
|
* No-ops when token is falsy (not yet loaded).
|
|
@@ -149,6 +152,125 @@ export function createWechatChannel(opts) {
|
|
|
149
152
|
return {};
|
|
150
153
|
}
|
|
151
154
|
}
|
|
155
|
+
function cdnUploadUrl(resp) {
|
|
156
|
+
if (typeof resp.upload_full_url === "string" && resp.upload_full_url.length > 0) {
|
|
157
|
+
return resp.upload_full_url;
|
|
158
|
+
}
|
|
159
|
+
if (typeof resp.upload_param === "string" && resp.upload_param.length > 0) {
|
|
160
|
+
return `${DEFAULT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(resp.upload_param)}`;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
async function uploadEncryptedMedia(trace, attachment) {
|
|
165
|
+
const raw = attachment.data ??
|
|
166
|
+
(attachment.filePath ? await readFile(attachment.filePath) : undefined);
|
|
167
|
+
if (!raw || raw.length === 0) {
|
|
168
|
+
throw new Error("wechat media upload requires non-empty attachment data or filePath");
|
|
169
|
+
}
|
|
170
|
+
const data = Buffer.from(raw);
|
|
171
|
+
const filename = attachment.filename ??
|
|
172
|
+
(attachment.filePath ? basename(attachment.filePath) : "attachment");
|
|
173
|
+
const kind = attachment.kind ?? kindFromContentType(attachment.contentType);
|
|
174
|
+
const mediaType = kind === "image" ? 1 : kind === "video" ? 2 : 3;
|
|
175
|
+
const itemType = kind === "image" ? 2 : kind === "video" ? 5 : 4;
|
|
176
|
+
const aesKey = randomBytes(16);
|
|
177
|
+
const aesKeyHex = aesKey.toString("hex");
|
|
178
|
+
const encrypted = encryptAes128Ecb(data, aesKey);
|
|
179
|
+
const filekey = `botcord-${randomUUID()}`;
|
|
180
|
+
const uploadResp = await callApi("ilink/bot/getuploadurl", {
|
|
181
|
+
filekey,
|
|
182
|
+
media_type: mediaType,
|
|
183
|
+
to_user_id: trace.fromUserId,
|
|
184
|
+
rawsize: data.length,
|
|
185
|
+
rawfilemd5: md5Hex(data),
|
|
186
|
+
filesize: encrypted.length,
|
|
187
|
+
aeskey: aesKeyHex,
|
|
188
|
+
no_need_thumb: true,
|
|
189
|
+
}, 15_000);
|
|
190
|
+
if (uploadResp.ret !== 0 && uploadResp.ret !== undefined) {
|
|
191
|
+
throw new Error(redactSecret(`wechat getuploadurl failed: ret=${uploadResp.ret}`, botToken));
|
|
192
|
+
}
|
|
193
|
+
const uploadUrl = cdnUploadUrl(uploadResp);
|
|
194
|
+
if (!uploadUrl)
|
|
195
|
+
throw new Error("wechat getuploadurl returned no upload URL");
|
|
196
|
+
const uploadResult = await fetchImpl(uploadUrl, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
199
|
+
body: encrypted,
|
|
200
|
+
signal: AbortSignal.timeout(30_000),
|
|
201
|
+
});
|
|
202
|
+
const encryptedParam = uploadResult.headers?.get("x-encrypted-param") ??
|
|
203
|
+
uploadResult.headers?.get("X-Encrypted-Param") ??
|
|
204
|
+
(await readEncryptedParamFromBody(uploadResult));
|
|
205
|
+
if (!encryptedParam) {
|
|
206
|
+
throw new Error("wechat CDN upload returned no x-encrypted-param");
|
|
207
|
+
}
|
|
208
|
+
const media = {
|
|
209
|
+
encrypt_query_param: encryptedParam,
|
|
210
|
+
aes_key: Buffer.from(aesKeyHex, "utf8").toString("base64"),
|
|
211
|
+
};
|
|
212
|
+
if (itemType === 2) {
|
|
213
|
+
return {
|
|
214
|
+
type: itemType,
|
|
215
|
+
image_item: {
|
|
216
|
+
media,
|
|
217
|
+
aeskey: aesKeyHex,
|
|
218
|
+
mid_size: data.length,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (itemType === 5) {
|
|
223
|
+
return {
|
|
224
|
+
type: itemType,
|
|
225
|
+
video_item: {
|
|
226
|
+
media,
|
|
227
|
+
video_size: data.length,
|
|
228
|
+
file_name: filename,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
type: itemType,
|
|
234
|
+
file_item: {
|
|
235
|
+
media,
|
|
236
|
+
file_name: filename,
|
|
237
|
+
md5: md5Hex(data),
|
|
238
|
+
len: data.length,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async function readEncryptedParamFromBody(resp) {
|
|
243
|
+
const raw = await resp.text().catch(() => "");
|
|
244
|
+
if (!raw)
|
|
245
|
+
return null;
|
|
246
|
+
try {
|
|
247
|
+
const json = JSON.parse(raw);
|
|
248
|
+
const v = json.encrypted_query_param ?? json.encrypt_query_param ?? json.upload_param;
|
|
249
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function sendItems(trace, items) {
|
|
256
|
+
const clientId = `botcord-${randomUUID()}`;
|
|
257
|
+
const body = {
|
|
258
|
+
msg: {
|
|
259
|
+
from_user_id: "",
|
|
260
|
+
to_user_id: trace.fromUserId,
|
|
261
|
+
client_id: clientId,
|
|
262
|
+
message_type: 2, // BOT → user
|
|
263
|
+
message_state: 2, // FINISH
|
|
264
|
+
context_token: trace.contextToken,
|
|
265
|
+
item_list: items,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const resp = await callApi("ilink/bot/sendmessage", body, 15_000);
|
|
269
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
270
|
+
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
271
|
+
}
|
|
272
|
+
return clientId;
|
|
273
|
+
}
|
|
152
274
|
function extractText(msg) {
|
|
153
275
|
const parts = [];
|
|
154
276
|
for (const item of msg.item_list ?? []) {
|
|
@@ -398,27 +520,23 @@ export function createWechatChannel(opts) {
|
|
|
398
520
|
throw new Error(`wechat send: no context_token for traceId=${message.traceId ?? "<missing>"}` +
|
|
399
521
|
` (expired or never bound — daemon does not support unsolicited replies)`);
|
|
400
522
|
}
|
|
401
|
-
const chunks = splitText(message.text, splitAt);
|
|
523
|
+
const chunks = message.text.length > 0 ? splitText(message.text, splitAt) : [];
|
|
402
524
|
let lastClientId = null;
|
|
403
525
|
for (const chunk of chunks) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
418
|
-
log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
|
|
419
|
-
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
526
|
+
lastClientId = await sendItems(trace, [{ type: 1, text_item: { text: chunk } }]);
|
|
527
|
+
}
|
|
528
|
+
for (const attachment of message.attachments ?? []) {
|
|
529
|
+
try {
|
|
530
|
+
const item = await uploadEncryptedMedia(trace, attachment);
|
|
531
|
+
lastClientId = await sendItems(trace, [item]);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
log.warn("wechat media send failed", {
|
|
535
|
+
err: redactSecret(String(err), botToken),
|
|
536
|
+
filename: attachment.filename ?? attachment.filePath ?? "attachment",
|
|
537
|
+
});
|
|
538
|
+
throw err;
|
|
420
539
|
}
|
|
421
|
-
lastClientId = clientId;
|
|
422
540
|
}
|
|
423
541
|
const sendAt = Date.now();
|
|
424
542
|
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
@@ -453,6 +571,21 @@ export function createWechatChannel(opts) {
|
|
|
453
571
|
};
|
|
454
572
|
return adapter;
|
|
455
573
|
}
|
|
574
|
+
function md5Hex(data) {
|
|
575
|
+
return createHash("md5").update(data).digest("hex");
|
|
576
|
+
}
|
|
577
|
+
function encryptAes128Ecb(data, key) {
|
|
578
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
579
|
+
cipher.setAutoPadding(true);
|
|
580
|
+
return Buffer.concat([cipher.update(data), cipher.final()]);
|
|
581
|
+
}
|
|
582
|
+
function kindFromContentType(contentType) {
|
|
583
|
+
if (contentType?.startsWith("image/"))
|
|
584
|
+
return "image";
|
|
585
|
+
if (contentType?.startsWith("video/"))
|
|
586
|
+
return "video";
|
|
587
|
+
return "file";
|
|
588
|
+
}
|
|
456
589
|
function sleep(ms, signal) {
|
|
457
590
|
return new Promise((resolve) => {
|
|
458
591
|
if (signal?.aborted) {
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -140,12 +140,22 @@ export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
|
|
|
140
140
|
*/
|
|
141
141
|
export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
|
|
142
142
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
143
|
+
export interface GatewayOutboundAttachment {
|
|
144
|
+
/** Local daemon-readable file path. */
|
|
145
|
+
filePath?: string;
|
|
146
|
+
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
147
|
+
data?: Uint8Array;
|
|
148
|
+
filename?: string;
|
|
149
|
+
contentType?: string;
|
|
150
|
+
kind?: "image" | "file" | "video";
|
|
151
|
+
}
|
|
143
152
|
export interface GatewayOutboundMessage {
|
|
144
153
|
channel: string;
|
|
145
154
|
accountId: string;
|
|
146
155
|
conversationId: string;
|
|
147
156
|
threadId?: string | null;
|
|
148
157
|
text: string;
|
|
158
|
+
attachments?: GatewayOutboundAttachment[];
|
|
149
159
|
replyTo?: string | null;
|
|
150
160
|
traceId?: string | null;
|
|
151
161
|
}
|
package/dist/index.js
CHANGED
|
@@ -172,6 +172,48 @@ function pidAlive(pid) {
|
|
|
172
172
|
return false;
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
if (!pidAlive(pid))
|
|
179
|
+
return true;
|
|
180
|
+
await delay(100);
|
|
181
|
+
}
|
|
182
|
+
return !pidAlive(pid);
|
|
183
|
+
}
|
|
184
|
+
async function stopExistingDaemonForRestart(pid) {
|
|
185
|
+
if (pid === process.pid)
|
|
186
|
+
return;
|
|
187
|
+
log.info("existing daemon found; restarting", { pid });
|
|
188
|
+
try {
|
|
189
|
+
process.kill(pid, "SIGTERM");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
try {
|
|
193
|
+
unlinkSync(PID_PATH);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!(await waitForPidExit(pid, 5_000))) {
|
|
201
|
+
log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
|
|
202
|
+
try {
|
|
203
|
+
process.kill(pid, "SIGKILL");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
await waitForPidExit(pid, 2_000);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(PID_PATH);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// ignore
|
|
215
|
+
}
|
|
216
|
+
}
|
|
175
217
|
/**
|
|
176
218
|
* Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
|
|
177
219
|
* with sensible defaults on first run. `--agent` (repeated) pins explicit
|
|
@@ -251,6 +293,8 @@ async function redeemInstallToken(opts) {
|
|
|
251
293
|
const body = { install_token: opts.installToken };
|
|
252
294
|
if (opts.label)
|
|
253
295
|
body.label = opts.label;
|
|
296
|
+
if (opts.daemonInstanceId)
|
|
297
|
+
body.daemon_instance_id = opts.daemonInstanceId;
|
|
254
298
|
const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
|
|
255
299
|
method: "POST",
|
|
256
300
|
headers: { "Content-Type": "application/json" },
|
|
@@ -259,7 +303,9 @@ async function redeemInstallToken(opts) {
|
|
|
259
303
|
});
|
|
260
304
|
if (!resp.ok) {
|
|
261
305
|
const text = await resp.text().catch(() => "");
|
|
262
|
-
|
|
306
|
+
const err = new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
|
|
307
|
+
err.status = resp.status;
|
|
308
|
+
throw err;
|
|
263
309
|
}
|
|
264
310
|
return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
|
|
265
311
|
}
|
|
@@ -330,10 +376,10 @@ async function runDeviceCodeFlow(opts) {
|
|
|
330
376
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
331
377
|
*
|
|
332
378
|
* Decision tree (plan §4.4 + §6.4):
|
|
333
|
-
* 1.
|
|
334
|
-
*
|
|
335
|
-
* the
|
|
336
|
-
* 2.
|
|
379
|
+
* 1. `--install-token` → redeem the one-time dashboard ticket. If local
|
|
380
|
+
* user-auth exists, include its daemonInstanceId so Hub can re-authorize
|
|
381
|
+
* the same device instead of creating a new one.
|
|
382
|
+
* 2. Have existing creds and no `--relogin` → return existing record.
|
|
337
383
|
* 3. `--relogin` → device-code login.
|
|
338
384
|
* 4. No creds + TTY → device-code login.
|
|
339
385
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
@@ -359,22 +405,40 @@ async function ensureUserAuthForStart(args) {
|
|
|
359
405
|
if (labelFlag && existing.label !== labelFlag) {
|
|
360
406
|
console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
|
|
361
407
|
}
|
|
362
|
-
if (installToken) {
|
|
363
|
-
console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
|
|
364
|
-
}
|
|
365
408
|
return existing;
|
|
366
409
|
}
|
|
367
410
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
368
411
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
369
412
|
const label = labelFlag ?? defaultLoginLabel();
|
|
370
413
|
if (authAction === "install-token" && installToken) {
|
|
371
|
-
|
|
372
|
-
|
|
414
|
+
let tok;
|
|
415
|
+
try {
|
|
416
|
+
tok = await redeemInstallToken({
|
|
417
|
+
hubUrl,
|
|
418
|
+
installToken,
|
|
419
|
+
label,
|
|
420
|
+
daemonInstanceId: existing?.daemonInstanceId,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
if (existing && !relogin && !existsSync(AUTH_EXPIRED_FLAG_PATH)) {
|
|
425
|
+
console.error(`note: --install-token could not be redeemed (${err instanceof Error ? err.message : String(err)}); reusing existing daemon auth`);
|
|
426
|
+
return existing;
|
|
427
|
+
}
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
const record = userAuthFromTokenResponse(tok, {
|
|
431
|
+
label,
|
|
432
|
+
loggedInAt: existing?.daemonInstanceId && existing.daemonInstanceId === tok.daemonInstanceId
|
|
433
|
+
? existing.loggedInAt
|
|
434
|
+
: undefined,
|
|
435
|
+
});
|
|
373
436
|
saveUserAuth(record);
|
|
374
437
|
clearAuthExpiredFlag();
|
|
375
438
|
log.info("install-token flow: authorized", {
|
|
376
439
|
userId: record.userId,
|
|
377
440
|
daemonInstanceId: record.daemonInstanceId,
|
|
441
|
+
reusedExistingDaemonInstance: existing?.daemonInstanceId === record.daemonInstanceId,
|
|
378
442
|
hubUrl: record.hubUrl,
|
|
379
443
|
label,
|
|
380
444
|
});
|
|
@@ -424,11 +488,6 @@ async function cmdStart(args) {
|
|
|
424
488
|
relogin: args.flags.relogin === true,
|
|
425
489
|
child: process.env.BOTCORD_DAEMON_CHILD === "1",
|
|
426
490
|
});
|
|
427
|
-
const existing = readPid();
|
|
428
|
-
if (existing && pidAlive(existing)) {
|
|
429
|
-
console.error(`daemon already running (pid ${existing})`);
|
|
430
|
-
process.exit(1);
|
|
431
|
-
}
|
|
432
491
|
// Login MUST happen before fork — once detached, stdio is gone and the
|
|
433
492
|
// user can't see the device code. We also run it for explicit
|
|
434
493
|
// --foreground so an interactive user can log in without the fork dance.
|
|
@@ -436,6 +495,17 @@ async function cmdStart(args) {
|
|
|
436
495
|
// var so we don't try to re-prompt for credentials it already has.
|
|
437
496
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
438
497
|
await ensureUserAuthForStart(args);
|
|
498
|
+
const existing = readPid();
|
|
499
|
+
if (existing && pidAlive(existing)) {
|
|
500
|
+
await stopExistingDaemonForRestart(existing);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const existing = readPid();
|
|
505
|
+
if (existing && existing !== process.pid && pidAlive(existing)) {
|
|
506
|
+
console.error(`daemon already running (pid ${existing})`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
439
509
|
}
|
|
440
510
|
if (background) {
|
|
441
511
|
// Detached child re-exec in foreground mode. The child writes the PID
|
|
@@ -3,8 +3,8 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { log as daemonLog } from "./log.js";
|
|
5
5
|
import { probeOpenclawAgents } from "./provision.js";
|
|
6
|
-
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
|
|
7
|
-
const DEFAULT_PORTS = [18789, 16200];
|
|
6
|
+
const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
|
|
7
|
+
const DEFAULT_PORTS = [18789, 16200, 28789];
|
|
8
8
|
const DEFAULT_TOKEN_FILE_PATHS = [
|
|
9
9
|
"/run/openclaw/gateway-token",
|
|
10
10
|
"/var/run/openclaw/gateway-token",
|
|
@@ -346,6 +346,9 @@ function discoverFromConfigDir(root) {
|
|
|
346
346
|
}
|
|
347
347
|
function parseJsonConfig(raw) {
|
|
348
348
|
const obj = JSON.parse(raw);
|
|
349
|
+
const qclaw = pickQclawGatewayValues(obj);
|
|
350
|
+
if (qclaw)
|
|
351
|
+
return qclaw;
|
|
349
352
|
// Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
|
|
350
353
|
// The legacy `acp.url` shape is also supported for explicit user-authored configs.
|
|
351
354
|
const native = pickOpenclawGatewayValues(obj?.gateway);
|
|
@@ -354,6 +357,34 @@ function parseJsonConfig(raw) {
|
|
|
354
357
|
const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
|
|
355
358
|
return pickConfigValues(acp);
|
|
356
359
|
}
|
|
360
|
+
function pickQclawGatewayValues(obj) {
|
|
361
|
+
if (!obj || typeof obj !== "object")
|
|
362
|
+
return null;
|
|
363
|
+
const port = typeof obj.port === "number" ? obj.port : undefined;
|
|
364
|
+
const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
|
|
365
|
+
? obj.configPath.trim()
|
|
366
|
+
: undefined;
|
|
367
|
+
if (!port && !configPath)
|
|
368
|
+
return null;
|
|
369
|
+
const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
|
|
370
|
+
if (fromConfig)
|
|
371
|
+
return fromConfig;
|
|
372
|
+
if (!port)
|
|
373
|
+
return null;
|
|
374
|
+
return { url: `ws://127.0.0.1:${port}` };
|
|
375
|
+
}
|
|
376
|
+
function readGatewayValuesFromConfigPath(configPath) {
|
|
377
|
+
try {
|
|
378
|
+
const raw = readFileSync(expandHome(configPath), "utf8");
|
|
379
|
+
const parsed = parseJsonConfig(raw);
|
|
380
|
+
if (parsed?.url)
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// qclaw.json may be copied without its referenced openclaw.json.
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
357
388
|
function pickOpenclawGatewayValues(gw) {
|
|
358
389
|
if (!gw || typeof gw !== "object")
|
|
359
390
|
return null;
|
package/dist/provision.js
CHANGED
|
@@ -1075,10 +1075,10 @@ function localOpenclawAcpDisabled(rawUrl) {
|
|
|
1075
1075
|
if (!isLoopbackUrl(rawUrl))
|
|
1076
1076
|
return false;
|
|
1077
1077
|
try {
|
|
1078
|
-
const
|
|
1079
|
-
if (!
|
|
1078
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1079
|
+
if (!source)
|
|
1080
1080
|
return false;
|
|
1081
|
-
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
1081
|
+
const cfg = JSON.parse(readFileSync(source.file, "utf8"));
|
|
1082
1082
|
return cfg?.acp?.enabled === false;
|
|
1083
1083
|
}
|
|
1084
1084
|
catch {
|
|
@@ -1491,12 +1491,13 @@ export async function probeOpenclawAgents(profile, opts = {}) {
|
|
|
1491
1491
|
token: prepared.resolvedToken,
|
|
1492
1492
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
1493
1493
|
});
|
|
1494
|
-
// For loopback gateways the agent roster lives in
|
|
1494
|
+
// For loopback gateways the agent roster lives in local OpenClaw config
|
|
1495
|
+
// (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
|
|
1495
1496
|
// and is the source of truth — listing it over the wire would require a
|
|
1496
1497
|
// paired device identity (operator.read scope). When the WS probe is the
|
|
1497
1498
|
// default (i.e. no test injection) we enrich the result from disk.
|
|
1498
1499
|
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
1499
|
-
const local = readLocalOpenclawAgents();
|
|
1500
|
+
const local = readLocalOpenclawAgents(profile.url);
|
|
1500
1501
|
if (local && local.length > 0)
|
|
1501
1502
|
result.agents = local;
|
|
1502
1503
|
}
|
|
@@ -1511,17 +1512,18 @@ function isLoopbackUrl(raw) {
|
|
|
1511
1512
|
return false;
|
|
1512
1513
|
}
|
|
1513
1514
|
}
|
|
1514
|
-
function readLocalOpenclawAgents() {
|
|
1515
|
+
function readLocalOpenclawAgents(rawUrl) {
|
|
1515
1516
|
try {
|
|
1516
|
-
const
|
|
1517
|
-
if (!
|
|
1518
|
-
return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
|
|
1517
|
+
const source = pickLocalOpenclawConfig(rawUrl);
|
|
1518
|
+
if (!source)
|
|
1519
|
+
return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
|
|
1520
|
+
const { file, stateDir } = source;
|
|
1519
1521
|
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
1520
1522
|
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
1521
1523
|
const explicitDefaultId = typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
|
|
1522
1524
|
? cfg.agents.defaults.id
|
|
1523
1525
|
: null;
|
|
1524
|
-
const dirAgents = readLocalOpenclawAgentDirs();
|
|
1526
|
+
const dirAgents = readLocalOpenclawAgentDirs(stateDir);
|
|
1525
1527
|
const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
|
|
1526
1528
|
const seen = new Set();
|
|
1527
1529
|
const out = [];
|
|
@@ -1565,16 +1567,52 @@ function readLocalOpenclawAgents() {
|
|
|
1565
1567
|
return null;
|
|
1566
1568
|
}
|
|
1567
1569
|
}
|
|
1568
|
-
function
|
|
1570
|
+
function pickLocalOpenclawConfig(rawUrl) {
|
|
1571
|
+
const candidates = [
|
|
1572
|
+
{ file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
|
|
1573
|
+
{ file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
|
|
1574
|
+
];
|
|
1575
|
+
const targetPort = urlPort(rawUrl);
|
|
1576
|
+
let firstExisting = null;
|
|
1577
|
+
for (const candidate of candidates) {
|
|
1578
|
+
if (!existsSync(candidate.file))
|
|
1579
|
+
continue;
|
|
1580
|
+
firstExisting ??= candidate;
|
|
1581
|
+
if (!targetPort)
|
|
1582
|
+
continue;
|
|
1583
|
+
try {
|
|
1584
|
+
const cfg = JSON.parse(readFileSync(candidate.file, "utf8"));
|
|
1585
|
+
if (Number(cfg?.gateway?.port) === targetPort)
|
|
1586
|
+
return candidate;
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// Try the next local config.
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return firstExisting;
|
|
1593
|
+
}
|
|
1594
|
+
function urlPort(rawUrl) {
|
|
1595
|
+
if (!rawUrl)
|
|
1596
|
+
return null;
|
|
1569
1597
|
try {
|
|
1570
|
-
const
|
|
1598
|
+
const u = new URL(rawUrl);
|
|
1599
|
+
const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
|
|
1600
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
1601
|
+
}
|
|
1602
|
+
catch {
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function readLocalOpenclawAgentDirs(stateDir) {
|
|
1607
|
+
try {
|
|
1608
|
+
const dir = path.join(stateDir, "agents");
|
|
1571
1609
|
if (!existsSync(dir))
|
|
1572
1610
|
return null;
|
|
1573
1611
|
const agents = readdirSync(dir, { withFileTypes: true })
|
|
1574
1612
|
.filter((entry) => entry.isDirectory() && entry.name.length > 0)
|
|
1575
1613
|
.map((entry) => ({
|
|
1576
1614
|
id: entry.name,
|
|
1577
|
-
workspace:
|
|
1615
|
+
workspace: resolveAgentDirWorkspace(dir, entry.name),
|
|
1578
1616
|
}));
|
|
1579
1617
|
if (agents.length === 0)
|
|
1580
1618
|
return null;
|
|
@@ -1591,6 +1629,10 @@ function readLocalOpenclawAgentDirs() {
|
|
|
1591
1629
|
return null;
|
|
1592
1630
|
}
|
|
1593
1631
|
}
|
|
1632
|
+
function resolveAgentDirWorkspace(agentsDir, agentId) {
|
|
1633
|
+
const nested = path.join(agentsDir, agentId, "agent");
|
|
1634
|
+
return existsSync(nested) ? nested : path.join(agentsDir, agentId);
|
|
1635
|
+
}
|
|
1594
1636
|
function resolveOpenclawIdentityName(agentId, workspace, cfg) {
|
|
1595
1637
|
const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
|
|
1596
1638
|
if (!root)
|
package/dist/start-auth.js
CHANGED
package/dist/turn-text.js
CHANGED
|
@@ -40,6 +40,13 @@ function replyDeliveryHint(msg) {
|
|
|
40
40
|
? THIRD_PARTY_REPLY_HINT
|
|
41
41
|
: NON_OWNER_REPLY_HINT;
|
|
42
42
|
}
|
|
43
|
+
function appendConversationFields(fields, msg) {
|
|
44
|
+
const conversationId = sanitizeSenderName(msg.conversation.id);
|
|
45
|
+
fields.push(`conversation_id: ${conversationId}`);
|
|
46
|
+
if (isThirdPartyConversation(msg.conversation.id)) {
|
|
47
|
+
fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
43
50
|
/**
|
|
44
51
|
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
45
52
|
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
@@ -138,6 +145,7 @@ export function composeBotCordUserTurn(msg) {
|
|
|
138
145
|
`from: ${sanitizedSenderLabel}`,
|
|
139
146
|
`to: ${msg.accountId}`,
|
|
140
147
|
];
|
|
148
|
+
appendConversationFields(headerFields, msg);
|
|
141
149
|
if (isGroup && roomTitle) {
|
|
142
150
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
143
151
|
headerFields.push(`room: ${safeRoom}`);
|
|
@@ -190,6 +198,7 @@ function composeBatchedTurn(msg, batch) {
|
|
|
190
198
|
`[BotCord Messages (${batch.length} new)]`,
|
|
191
199
|
`to: ${msg.accountId}`,
|
|
192
200
|
];
|
|
201
|
+
appendConversationFields(header, msg);
|
|
193
202
|
if (isGroup && roomTitle) {
|
|
194
203
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
195
204
|
header.push(`room: ${safeRoom}`);
|