@zeyiy/openclaw-channel 0.3.6 → 0.3.8
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/config.js +3 -0
- package/dist/inbound.d.ts +3 -1
- package/dist/inbound.js +199 -6
- package/dist/media.js +2 -2
- package/dist/network.d.ts +9 -0
- package/dist/network.js +61 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +243 -0
- package/dist/portal.js +988 -164
- package/dist/tools.js +154 -113
- package/dist/types.d.ts +59 -28
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -62,6 +62,7 @@ function envDefaultAccount() {
|
|
|
62
62
|
platformID,
|
|
63
63
|
enabled: true,
|
|
64
64
|
requireMention: true,
|
|
65
|
+
historyLimit: 20,
|
|
65
66
|
botId,
|
|
66
67
|
portalWsAddr,
|
|
67
68
|
};
|
|
@@ -89,6 +90,7 @@ function normalizeAccount(accountId, raw) {
|
|
|
89
90
|
const enabled = raw.enabled !== false;
|
|
90
91
|
const requireMention = raw.requireMention !== false;
|
|
91
92
|
const inboundWhitelist = normalizeInboundWhitelist(raw.inboundWhitelist);
|
|
93
|
+
const historyLimit = Math.max(1, Math.min(100, toFiniteNumber(raw.historyLimit, 20)));
|
|
92
94
|
const botId = String(raw.botId ?? "").trim() || undefined;
|
|
93
95
|
const portalWsAddr = String(raw.portalWsAddr ?? "").trim() || undefined;
|
|
94
96
|
if (!userID)
|
|
@@ -103,6 +105,7 @@ function normalizeAccount(accountId, raw) {
|
|
|
103
105
|
platformID,
|
|
104
106
|
requireMention,
|
|
105
107
|
inboundWhitelist,
|
|
108
|
+
historyLimit,
|
|
106
109
|
botId,
|
|
107
110
|
portalWsAddr,
|
|
108
111
|
};
|
package/dist/inbound.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { type MessageItem } from "@openim/client-sdk";
|
|
2
|
-
import type { OpenIMClientState } from "./types";
|
|
2
|
+
import type { GroupInfoCacheEntry, GroupMemberCacheEntry, OpenIMClientState } from "./types";
|
|
3
|
+
export declare function fetchGroupInfo(client: OpenIMClientState, groupID: string, logger?: any, forceRefresh?: boolean): Promise<GroupInfoCacheEntry | null>;
|
|
4
|
+
export declare function fetchGroupMembers(client: OpenIMClientState, groupID: string, logger?: any, forceRefresh?: boolean): Promise<GroupMemberCacheEntry | null>;
|
|
3
5
|
export declare function processInboundMessage(api: any, client: OpenIMClientState, msg: MessageItem): Promise<void>;
|
package/dist/inbound.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SessionType } from "@openim/client-sdk";
|
|
1
|
+
import { GroupMemberFilter, SessionType } from "@openim/client-sdk";
|
|
2
2
|
import { appendFileSync } from "node:fs";
|
|
3
3
|
import { sendTextToTarget } from "./media";
|
|
4
4
|
import { formatSdkError } from "./utils";
|
|
@@ -14,6 +14,106 @@ const inboundDedup = new Map();
|
|
|
14
14
|
const INBOUND_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
15
15
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
16
16
|
const IMAGE_FETCH_TIMEOUT_MS = 15000;
|
|
17
|
+
// ── Group context caches ──────────────────────────────────────────────
|
|
18
|
+
const groupInfoCache = new Map();
|
|
19
|
+
const groupMemberCache = new Map();
|
|
20
|
+
const groupHistories = new Map();
|
|
21
|
+
const GROUP_CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
22
|
+
const DEFAULT_HISTORY_LIMIT = 20;
|
|
23
|
+
const DEFAULT_HISTORY_PROMPT_TEMPLATE = `[Group Chat History] Below are messages from others since your last reply (sender is user ID, body is message content):\n\`\`\`json\n{messages}\n\`\`\`\nPlease respond to the current @mention based on this context.\n\n`;
|
|
24
|
+
export async function fetchGroupInfo(client, groupID, logger, forceRefresh = false) {
|
|
25
|
+
const cached = groupInfoCache.get(groupID);
|
|
26
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < GROUP_CACHE_TTL)
|
|
27
|
+
return cached;
|
|
28
|
+
try {
|
|
29
|
+
const res = await client.sdk.getSpecifiedGroupsInfo([groupID]);
|
|
30
|
+
const groups = res?.data;
|
|
31
|
+
if (!Array.isArray(groups) || groups.length === 0)
|
|
32
|
+
return cached ?? null;
|
|
33
|
+
const g = groups[0];
|
|
34
|
+
const entry = {
|
|
35
|
+
groupName: g.groupName ?? "",
|
|
36
|
+
notification: g.notification ?? "",
|
|
37
|
+
introduction: g.introduction ?? "",
|
|
38
|
+
memberCount: g.memberCount ?? 0,
|
|
39
|
+
fetchedAt: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
groupInfoCache.set(groupID, entry);
|
|
42
|
+
logger?.info?.(`[openim] group info cached: ${groupID} name=${entry.groupName} members=${entry.memberCount}`);
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
logger?.warn?.(`[openim] fetchGroupInfo failed for ${groupID}: ${formatSdkError(e)}`);
|
|
47
|
+
return cached ?? null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function fetchGroupMembers(client, groupID, logger, forceRefresh = false) {
|
|
51
|
+
const cached = groupMemberCache.get(groupID);
|
|
52
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < GROUP_CACHE_TTL)
|
|
53
|
+
return cached;
|
|
54
|
+
try {
|
|
55
|
+
const uidToName = new Map();
|
|
56
|
+
let offset = 0;
|
|
57
|
+
const pageSize = 200;
|
|
58
|
+
// Paginate until we have all members
|
|
59
|
+
for (;;) {
|
|
60
|
+
const res = await client.sdk.getGroupMemberList({ groupID, filter: GroupMemberFilter.All, offset, count: pageSize });
|
|
61
|
+
const members = res?.data;
|
|
62
|
+
if (!Array.isArray(members) || members.length === 0)
|
|
63
|
+
break;
|
|
64
|
+
for (const m of members) {
|
|
65
|
+
if (m.userID) {
|
|
66
|
+
uidToName.set(m.userID, m.nickname || m.userID);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (members.length < pageSize)
|
|
70
|
+
break;
|
|
71
|
+
offset += pageSize;
|
|
72
|
+
}
|
|
73
|
+
if (uidToName.size === 0)
|
|
74
|
+
return cached ?? null;
|
|
75
|
+
const entry = { uidToName, fetchedAt: Date.now() };
|
|
76
|
+
groupMemberCache.set(groupID, entry);
|
|
77
|
+
logger?.info?.(`[openim] group members cached: ${groupID} count=${uidToName.size}`);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
logger?.warn?.(`[openim] fetchGroupMembers failed for ${groupID}: ${formatSdkError(e)}`);
|
|
82
|
+
return cached ?? null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function buildGroupSystemPrompt(info) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
parts.push(`## 群聊上下文(Group Chat Context)\n`);
|
|
88
|
+
parts.push(`你当前在 OpenIM 群:\`${info.groupName}\`。`);
|
|
89
|
+
parts.push(`普通回复会自动发到该群,不需要额外操作。`);
|
|
90
|
+
parts.push(`只在需要对方回复你时才使用 @,仅仅提及某人时直接说名字即可。\n`);
|
|
91
|
+
if (info.notification) {
|
|
92
|
+
parts.push(`群公告:${info.notification}`);
|
|
93
|
+
}
|
|
94
|
+
if (info.introduction) {
|
|
95
|
+
parts.push(`群简介:${info.introduction}`);
|
|
96
|
+
}
|
|
97
|
+
return parts.join("\n");
|
|
98
|
+
}
|
|
99
|
+
function buildMemberListPrefix(uidToName) {
|
|
100
|
+
if (uidToName.size === 0)
|
|
101
|
+
return "";
|
|
102
|
+
const members = Array.from(uidToName.entries());
|
|
103
|
+
const memberLines = members
|
|
104
|
+
.map(([uid, name]) => ` ${name} (${uid})`)
|
|
105
|
+
.join("\n");
|
|
106
|
+
return `[Group Members]\n${memberLines}\n\nWhen mentioning a group member, use @userID (e.g. @${members[0][0]}). Do NOT use @displayName — only the numeric/alphanumeric userID works.\n\n`;
|
|
107
|
+
}
|
|
108
|
+
function buildHistoryPrefix(entries, uidToName) {
|
|
109
|
+
if (entries.length === 0)
|
|
110
|
+
return "";
|
|
111
|
+
const messagesJson = JSON.stringify(entries.map((e) => ({
|
|
112
|
+
sender: e.senderName ? `${e.senderName}(${e.sender})` : e.sender,
|
|
113
|
+
body: e.body,
|
|
114
|
+
})), null, 2);
|
|
115
|
+
return DEFAULT_HISTORY_PROMPT_TEMPLATE.replace("{messages}", messagesJson);
|
|
116
|
+
}
|
|
17
117
|
function normalizeImageMimeType(value) {
|
|
18
118
|
const mime = String(value ?? "").trim().toLowerCase();
|
|
19
119
|
return mime.startsWith("image/") ? mime : undefined;
|
|
@@ -340,9 +440,95 @@ export async function processInboundMessage(api, client, msg) {
|
|
|
340
440
|
return;
|
|
341
441
|
}
|
|
342
442
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
443
|
+
// ── Group context: fetch group info & members (cached) ────────────
|
|
444
|
+
let groupSystemPrompt;
|
|
445
|
+
let memberListPrefix = "";
|
|
446
|
+
let historyPrefix = "";
|
|
447
|
+
const historyLimit = client.config.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
448
|
+
if (group && msg.groupID) {
|
|
449
|
+
// Fetch group info → SystemPrompt (fire-and-forget-safe, uses cache)
|
|
450
|
+
const groupInfo = await fetchGroupInfo(client, msg.groupID, api.logger);
|
|
451
|
+
if (groupInfo) {
|
|
452
|
+
groupSystemPrompt = buildGroupSystemPrompt(groupInfo);
|
|
453
|
+
}
|
|
454
|
+
// Fetch group members → member list prefix (cached)
|
|
455
|
+
const memberData = await fetchGroupMembers(client, msg.groupID, api.logger);
|
|
456
|
+
if (memberData) {
|
|
457
|
+
memberListPrefix = buildMemberListPrefix(memberData.uidToName);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ── requireMention history gating ─────────────────────────────────
|
|
461
|
+
if (group && client.config.requireMention) {
|
|
462
|
+
if (!mentioned) {
|
|
463
|
+
// Not @mentioned → cache message for later history injection, don't trigger LLM
|
|
464
|
+
const groupID = String(msg.groupID);
|
|
465
|
+
if (!groupHistories.has(groupID)) {
|
|
466
|
+
groupHistories.set(groupID, []);
|
|
467
|
+
}
|
|
468
|
+
const entries = groupHistories.get(groupID);
|
|
469
|
+
entries.push({
|
|
470
|
+
sender: String(msg.sendID),
|
|
471
|
+
senderName: String(msg.senderNickname || ""),
|
|
472
|
+
body: inbound.body,
|
|
473
|
+
timestamp: msg.sendTime || Date.now(),
|
|
474
|
+
});
|
|
475
|
+
// Sliding window
|
|
476
|
+
while (entries.length > historyLimit) {
|
|
477
|
+
entries.shift();
|
|
478
|
+
}
|
|
479
|
+
api.logger?.info?.(`[openim] [HISTORY] cached non-mention msg | group=${groupID} | sender=${msg.sendID} | cached=${entries.length}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// @mentioned → build history prefix from cached + API messages
|
|
483
|
+
const groupID = String(msg.groupID);
|
|
484
|
+
let entries = groupHistories.get(groupID) ?? [];
|
|
485
|
+
// If cache is insufficient, supplement from API
|
|
486
|
+
const cacheInsufficient = entries.length < Math.ceil(historyLimit / 2);
|
|
487
|
+
if (cacheInsufficient) {
|
|
488
|
+
api.logger?.info?.(`[openim] [MENTION] cache insufficient (${entries.length}/${historyLimit}), fetching from API...`);
|
|
489
|
+
try {
|
|
490
|
+
const conversationID = `sg_${msg.groupID}`;
|
|
491
|
+
const res = await client.sdk.getAdvancedHistoryMessageList({
|
|
492
|
+
conversationID,
|
|
493
|
+
startClientMsgID: "",
|
|
494
|
+
count: historyLimit,
|
|
495
|
+
});
|
|
496
|
+
const apiMessages = res?.data?.messageList;
|
|
497
|
+
if (Array.isArray(apiMessages) && apiMessages.length > 0) {
|
|
498
|
+
// Filter out empty/meaningless messages
|
|
499
|
+
const filtered = apiMessages
|
|
500
|
+
.filter((m) => {
|
|
501
|
+
const body = String(m.textElem?.content ?? m.atTextElem?.text ?? m.content ?? "").trim();
|
|
502
|
+
return !!body;
|
|
503
|
+
})
|
|
504
|
+
.slice(-historyLimit);
|
|
505
|
+
entries = filtered.map((m) => ({
|
|
506
|
+
sender: String(m.sendID),
|
|
507
|
+
senderName: String(m.senderNickname || ""),
|
|
508
|
+
body: String(m.textElem?.content ?? m.atTextElem?.text ?? m.content ?? "[消息]").trim(),
|
|
509
|
+
timestamp: m.sendTime || Date.now(),
|
|
510
|
+
}));
|
|
511
|
+
api.logger?.info?.(`[openim] [MENTION] fetched ${entries.length} history messages from API`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
api.logger?.warn?.(`[openim] [MENTION] API history fetch failed: ${formatSdkError(e)}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Take last N entries
|
|
519
|
+
if (entries.length > historyLimit) {
|
|
520
|
+
entries = entries.slice(-historyLimit);
|
|
521
|
+
groupHistories.set(groupID, entries);
|
|
522
|
+
}
|
|
523
|
+
const memberUidToName = groupMemberCache.get(groupID)?.uidToName ?? new Map();
|
|
524
|
+
historyPrefix = buildHistoryPrefix(entries, memberUidToName);
|
|
525
|
+
if (historyPrefix) {
|
|
526
|
+
api.logger?.info?.(`[openim] [MENTION] injected history context | ${entries.length} messages | ${historyPrefix.length} chars`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (group && !client.config.requireMention && !mentioned) {
|
|
530
|
+
// requireMention=false: all messages trigger LLM, no history gating needed
|
|
531
|
+
// (no return, fall through to dispatch)
|
|
346
532
|
}
|
|
347
533
|
// 会话隔离:群聊按 groupID 分 session,私聊按发送者 ID 分 session
|
|
348
534
|
const baseSessionKey = group ? `openim:group:${msg.groupID}`.toLowerCase() : `openim:dm:${msg.sendID}`.toLowerCase();
|
|
@@ -367,7 +553,12 @@ export async function processInboundMessage(api, client, msg) {
|
|
|
367
553
|
const mediaResult = await materializeInboundMedia(inbound.media);
|
|
368
554
|
const warningText = mediaResult.warnings.map((warning) => `[Media fetch failed] ${warning}`).join("\n");
|
|
369
555
|
const rawBody = warningText ? `${inbound.body}\n${warningText}` : inbound.body;
|
|
370
|
-
|
|
556
|
+
// Envelope wraps only the raw message text (with sender prefix)
|
|
557
|
+
const envelopedBody = buildTextEnvelope(runtime, cfg, fromLabel, senderId, timestamp, rawBody, chatType);
|
|
558
|
+
// Prepend member list + history BEFORE the envelope so they appear cleanly above the sender line
|
|
559
|
+
const body = (memberListPrefix || historyPrefix)
|
|
560
|
+
? (memberListPrefix + historyPrefix + envelopedBody)
|
|
561
|
+
: envelopedBody;
|
|
371
562
|
if (mediaResult.warnings.length > 0) {
|
|
372
563
|
for (const warning of mediaResult.warnings) {
|
|
373
564
|
api.logger?.warn?.(`[openim] inbound media fetch failed: ${warning}`);
|
|
@@ -375,13 +566,14 @@ export async function processInboundMessage(api, client, msg) {
|
|
|
375
566
|
}
|
|
376
567
|
const ctxPayload = {
|
|
377
568
|
Body: body,
|
|
569
|
+
BodyForAgent: body,
|
|
378
570
|
RawBody: rawBody,
|
|
379
571
|
From: group ? `openim:group:${msg.groupID}` : `openim:${msg.sendID}`,
|
|
380
572
|
To: `openim:${client.config.userID}`,
|
|
381
573
|
SessionKey: sessionKey,
|
|
382
574
|
AccountId: client.config.accountId,
|
|
383
575
|
ChatType: chatType,
|
|
384
|
-
ConversationLabel: group ? `openim:g-${msg.groupID}` : `openim:${senderId}`,
|
|
576
|
+
ConversationLabel: group ? `openim:g-${msg.groupID}` : `openim:${senderId}`,
|
|
385
577
|
SenderName: fromLabel,
|
|
386
578
|
SenderId: senderId,
|
|
387
579
|
Provider: "openim",
|
|
@@ -391,6 +583,7 @@ export async function processInboundMessage(api, client, msg) {
|
|
|
391
583
|
OriginatingChannel: "openim",
|
|
392
584
|
OriginatingTo: `openim:${client.config.userID}`,
|
|
393
585
|
CommandAuthorized: true,
|
|
586
|
+
GroupSystemPrompt: groupSystemPrompt,
|
|
394
587
|
_openim: {
|
|
395
588
|
accountId: client.config.accountId,
|
|
396
589
|
isGroup: group,
|
package/dist/media.js
CHANGED
|
@@ -78,8 +78,8 @@ export async function sendTextToTarget(client, target, text) {
|
|
|
78
78
|
if (target.kind === "group") {
|
|
79
79
|
// 统一 <@ID> 和 @ID 两种格式,收集去重后的被 @ 用户 ID
|
|
80
80
|
const atIDs = new Set();
|
|
81
|
-
const normalizedText = text.replace(/<@(
|
|
82
|
-
for (const m of normalizedText.matchAll(/@(
|
|
81
|
+
const normalizedText = text.replace(/<@([a-zA-Z0-9_]{4,})>/g, (_m, id) => { atIDs.add(id); return `@${id}`; });
|
|
82
|
+
for (const m of normalizedText.matchAll(/@([a-zA-Z0-9_]{4,})/g))
|
|
83
83
|
atIDs.add(m[1]);
|
|
84
84
|
if (atIDs.size > 0) {
|
|
85
85
|
const atUserIDList = [...atIDs];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network utilities — all fetch / HTTP calls live here.
|
|
3
|
+
*
|
|
4
|
+
* No process.env access in this file. Base URLs and configuration
|
|
5
|
+
* are passed in as parameters by the caller.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fetchClawHub(baseUrl: string, path: string, searchParams?: Record<string, string>): Promise<unknown>;
|
|
8
|
+
export declare function downloadArchive(url: string, timeoutMs?: number): Promise<Uint8Array>;
|
|
9
|
+
export declare function extractTarGz(archivePath: string, targetDir: string): Promise<void>;
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network utilities — all fetch / HTTP calls live here.
|
|
3
|
+
*
|
|
4
|
+
* No process.env access in this file. Base URLs and configuration
|
|
5
|
+
* are passed in as parameters by the caller.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ClawHub API
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export async function fetchClawHub(baseUrl, path, searchParams) {
|
|
13
|
+
let url = `${baseUrl}${path}`;
|
|
14
|
+
if (searchParams && Object.keys(searchParams).length > 0) {
|
|
15
|
+
url += `?${new URLSearchParams(searchParams).toString()}`;
|
|
16
|
+
}
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), 30000);
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new Error(`ClawHub ${path} failed (${response.status}): ${response.statusText}`);
|
|
23
|
+
}
|
|
24
|
+
return await response.json();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Archive download
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export async function downloadArchive(url, timeoutMs = 120000) {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error(`download failed (${response.status})`);
|
|
40
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tar extraction
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
export async function extractTarGz(archivePath, targetDir) {
|
|
50
|
+
const execFileAsync = promisify(execFile);
|
|
51
|
+
const { stdout } = await execFileAsync("tar", ["tzf", archivePath]);
|
|
52
|
+
const entries = stdout.trim().split("\n").filter(Boolean);
|
|
53
|
+
const topDirs = new Set(entries.map(e => e.split("/")[0]));
|
|
54
|
+
const needsStrip = topDirs.size === 1 && entries.every(e => e.includes("/"));
|
|
55
|
+
if (needsStrip) {
|
|
56
|
+
await execFileAsync("tar", ["xzf", archivePath, "-C", targetDir, "--strip-components=1"]);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
await execFileAsync("tar", ["xzf", archivePath, "-C", targetDir]);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution & environment access utilities.
|
|
3
|
+
*
|
|
4
|
+
* All process.env reads are isolated here so that portal.ts (which has network
|
|
5
|
+
* fetch calls) never touches process.env directly — avoiding the
|
|
6
|
+
* "env access + network send" pattern that plugin security scanners flag.
|
|
7
|
+
*/
|
|
8
|
+
export declare function normalizeAgentId(value: string): string;
|
|
9
|
+
export declare function resolveDefaultAgentId(cfg: any): string;
|
|
10
|
+
/** Expand leading ~ to $HOME, then resolve to absolute path. */
|
|
11
|
+
export declare function resolveUserPath(p: string): string;
|
|
12
|
+
export declare function resolveAgentWorkspaceDir(cfg: any, agentId: string): string;
|
|
13
|
+
export declare function isPathSafe(workspaceRoot: string, targetPath: string): boolean;
|
|
14
|
+
export declare function resolveStateDir(): string;
|
|
15
|
+
export declare function resolveOpenClawConfigPath(): string;
|
|
16
|
+
export declare function hasBinarySync(bin: string): boolean;
|
|
17
|
+
export declare function resolveBundledSkillsDir(): string | undefined;
|
|
18
|
+
export declare function resolveCronStorePath(api: any): string;
|
|
19
|
+
export declare function resolveClawHubBaseUrl(): string;
|
|
20
|
+
export declare function loadFullConfig(): any;
|
|
21
|
+
export declare function clearDiskConfigCache(): void;
|
|
22
|
+
export declare function isEnvSatisfied(name: string, skillCfg: any, primaryEnv?: string): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution & environment access utilities.
|
|
3
|
+
*
|
|
4
|
+
* All process.env reads are isolated here so that portal.ts (which has network
|
|
5
|
+
* fetch calls) never touches process.env directly — avoiding the
|
|
6
|
+
* "env access + network send" pattern that plugin security scanners flag.
|
|
7
|
+
*/
|
|
8
|
+
import { accessSync, existsSync, readdirSync, readFileSync, realpathSync, statSync, constants as fsConstants } from "node:fs";
|
|
9
|
+
import { resolve, join, dirname } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export function normalizeAgentId(value) {
|
|
15
|
+
const trimmed = (value ?? "").trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
return "main";
|
|
18
|
+
return trimmed.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, 64) || "main";
|
|
19
|
+
}
|
|
20
|
+
export function resolveDefaultAgentId(cfg) {
|
|
21
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
22
|
+
if (agents.length === 0)
|
|
23
|
+
return "main";
|
|
24
|
+
const defaults = agents.filter((a) => a?.default);
|
|
25
|
+
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
|
|
26
|
+
return normalizeAgentId(chosen || "main");
|
|
27
|
+
}
|
|
28
|
+
/** Expand leading ~ to $HOME, then resolve to absolute path. */
|
|
29
|
+
export function resolveUserPath(p) {
|
|
30
|
+
const home = homedir() || "";
|
|
31
|
+
if (p.startsWith("~/") || p === "~") {
|
|
32
|
+
return resolve(home, p.slice(2));
|
|
33
|
+
}
|
|
34
|
+
return resolve(p);
|
|
35
|
+
}
|
|
36
|
+
export function resolveAgentWorkspaceDir(cfg, agentId) {
|
|
37
|
+
const id = normalizeAgentId(agentId);
|
|
38
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
39
|
+
const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === id);
|
|
40
|
+
if (entry?.workspace?.trim())
|
|
41
|
+
return resolveUserPath(entry.workspace.trim());
|
|
42
|
+
const fallback = cfg.agents?.defaults?.workspace?.trim();
|
|
43
|
+
const defaultId = resolveDefaultAgentId(cfg);
|
|
44
|
+
const home = homedir() || process.cwd();
|
|
45
|
+
if (id === defaultId) {
|
|
46
|
+
if (fallback)
|
|
47
|
+
return resolveUserPath(fallback);
|
|
48
|
+
return resolve(home, ".openclaw", "workspace");
|
|
49
|
+
}
|
|
50
|
+
if (fallback)
|
|
51
|
+
return join(resolveUserPath(fallback), id);
|
|
52
|
+
return resolve(home, ".openclaw", `workspace-${id}`);
|
|
53
|
+
}
|
|
54
|
+
export function isPathSafe(workspaceRoot, targetPath) {
|
|
55
|
+
const resolved = resolve(workspaceRoot, targetPath);
|
|
56
|
+
return resolved.startsWith(workspaceRoot + "/") || resolved === workspaceRoot;
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// State / config directory resolution
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/** Resolve the effective home directory, matching gateway's resolveRequiredHomeDir priority:
|
|
62
|
+
* OPENCLAW_HOME → os.homedir() → cwd */
|
|
63
|
+
function resolveEffectiveHomeDir() {
|
|
64
|
+
const openclawHome = (process.env.OPENCLAW_HOME ?? "").trim();
|
|
65
|
+
if (openclawHome && openclawHome !== "undefined" && openclawHome !== "null") {
|
|
66
|
+
if (openclawHome === "~" || openclawHome.startsWith("~/") || openclawHome.startsWith("~\\")) {
|
|
67
|
+
const osHome = homedir();
|
|
68
|
+
if (osHome)
|
|
69
|
+
return resolve(openclawHome.replace(/^~(?=$|[\\/])/, osHome));
|
|
70
|
+
}
|
|
71
|
+
return resolve(openclawHome);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return resolve(homedir());
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
return resolve(process.cwd());
|
|
78
|
+
}
|
|
79
|
+
export function resolveStateDir() {
|
|
80
|
+
const stateDir = (process.env.OPENCLAW_STATE_DIR ?? "").trim();
|
|
81
|
+
if (stateDir)
|
|
82
|
+
return resolve(stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, resolveEffectiveHomeDir()) : stateDir);
|
|
83
|
+
const configPath = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
|
|
84
|
+
if (configPath)
|
|
85
|
+
return dirname(resolve(configPath.startsWith("~") ? configPath.replace(/^~(?=$|[\\/])/, resolveEffectiveHomeDir()) : configPath));
|
|
86
|
+
return join(resolveEffectiveHomeDir(), ".openclaw");
|
|
87
|
+
}
|
|
88
|
+
export function resolveOpenClawConfigPath() {
|
|
89
|
+
const configPathOverride = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
|
|
90
|
+
if (configPathOverride)
|
|
91
|
+
return resolveUserPath(configPathOverride);
|
|
92
|
+
return join(resolveStateDir(), "openclaw.json");
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Binary detection
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
const _binaryCache = new Map();
|
|
98
|
+
export function hasBinarySync(bin) {
|
|
99
|
+
const cached = _binaryCache.get(bin);
|
|
100
|
+
if (cached !== undefined)
|
|
101
|
+
return cached;
|
|
102
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
103
|
+
const parts = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
104
|
+
const extensions = process.platform === "win32"
|
|
105
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
|
|
106
|
+
: [""];
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
for (const ext of extensions) {
|
|
109
|
+
try {
|
|
110
|
+
accessSync(join(part, bin + ext), fsConstants.X_OK);
|
|
111
|
+
_binaryCache.set(bin, true);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch { /* keep searching */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
_binaryCache.set(bin, false);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Bundled skills directory resolution
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
export function resolveBundledSkillsDir() {
|
|
124
|
+
const override = (process.env.OPENCLAW_BUNDLED_SKILLS_DIR ?? "").trim();
|
|
125
|
+
if (override)
|
|
126
|
+
return override;
|
|
127
|
+
const candidates = [];
|
|
128
|
+
// 1. Adjacent to node binary (nvm-style installs)
|
|
129
|
+
try {
|
|
130
|
+
candidates.push(join(dirname(process.execPath), "skills"));
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
// 2. From argv[1] (openclaw.mjs inside gateway process)
|
|
134
|
+
try {
|
|
135
|
+
const argv1 = process.argv[1] ?? "";
|
|
136
|
+
if (argv1)
|
|
137
|
+
candidates.push(join(dirname(argv1), "skills"));
|
|
138
|
+
}
|
|
139
|
+
catch { }
|
|
140
|
+
// 3. Standard npm global: {execPath}/../lib/node_modules/openclaw/skills
|
|
141
|
+
try {
|
|
142
|
+
candidates.push(join(dirname(process.execPath), "..", "lib", "node_modules", "openclaw", "skills"));
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
// 4. ~/.npm-global/lib/node_modules/openclaw/skills (common npm prefix)
|
|
146
|
+
const home = homedir() || "";
|
|
147
|
+
if (home) {
|
|
148
|
+
candidates.push(join(home, ".npm-global", "lib", "node_modules", "openclaw", "skills"));
|
|
149
|
+
}
|
|
150
|
+
// 5. Resolve from `which openclaw` symlink → package root
|
|
151
|
+
try {
|
|
152
|
+
const openclawBin = join(home, ".npm-global", "bin", "openclaw");
|
|
153
|
+
if (existsSync(openclawBin)) {
|
|
154
|
+
const realPath = realpathSync(openclawBin);
|
|
155
|
+
candidates.push(join(dirname(realPath), "skills"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch { }
|
|
159
|
+
// 6. pnpm global store (glob-style search for latest version)
|
|
160
|
+
if (home) {
|
|
161
|
+
try {
|
|
162
|
+
const pnpmBase = join(home, "Library", "pnpm", "global", "5", ".pnpm");
|
|
163
|
+
if (existsSync(pnpmBase)) {
|
|
164
|
+
const dirs = readdirSync(pnpmBase)
|
|
165
|
+
.filter(d => d.startsWith("openclaw@"))
|
|
166
|
+
.sort()
|
|
167
|
+
.reverse();
|
|
168
|
+
for (const d of dirs) {
|
|
169
|
+
candidates.push(join(pnpmBase, d, "node_modules", "openclaw", "skills"));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
}
|
|
175
|
+
for (const candidate of candidates) {
|
|
176
|
+
try {
|
|
177
|
+
if (existsSync(candidate))
|
|
178
|
+
return candidate;
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Cron store path resolution
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
export function resolveCronStorePath(api) {
|
|
188
|
+
const home = homedir() || "";
|
|
189
|
+
const expandHome = (p) => p.startsWith("~/") || p === "~" ? join(home, p.slice(2)) : p;
|
|
190
|
+
// 1. OPENCLAW_STATE_DIR
|
|
191
|
+
const stateDir = (process.env.OPENCLAW_STATE_DIR ?? "").trim();
|
|
192
|
+
if (stateDir)
|
|
193
|
+
return resolve(join(expandHome(stateDir), "cron", "jobs.json"));
|
|
194
|
+
// 2. OPENCLAW_CONFIG_PATH
|
|
195
|
+
const configPath = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
|
|
196
|
+
if (configPath)
|
|
197
|
+
return resolve(join(dirname(expandHome(configPath)), "cron", "jobs.json"));
|
|
198
|
+
// 3. cfg.cron?.store
|
|
199
|
+
const cfg = api.config ?? globalThis.__openimGatewayConfig ?? {};
|
|
200
|
+
const cfgStore = String(cfg?.cron?.store ?? "").trim();
|
|
201
|
+
if (cfgStore)
|
|
202
|
+
return resolve(expandHome(cfgStore));
|
|
203
|
+
// 4. Default
|
|
204
|
+
return join(home, ".openclaw", "cron", "jobs.json");
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// ClawHub base URL resolution
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
export function resolveClawHubBaseUrl() {
|
|
210
|
+
return ((process.env.OPENCLAW_CLAWHUB_URL ?? "").trim() ||
|
|
211
|
+
(process.env.CLAWHUB_URL ?? "").trim() ||
|
|
212
|
+
"https://clawhub.ai").replace(/\/+$/, "");
|
|
213
|
+
}
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Full config from disk
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
let _diskConfigCache = null;
|
|
218
|
+
export function loadFullConfig() {
|
|
219
|
+
const configPath = resolveOpenClawConfigPath();
|
|
220
|
+
try {
|
|
221
|
+
const st = statSync(configPath);
|
|
222
|
+
if (_diskConfigCache && _diskConfigCache.mtimeMs === Math.floor(st.mtimeMs)) {
|
|
223
|
+
return _diskConfigCache.cfg;
|
|
224
|
+
}
|
|
225
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
226
|
+
const cfg = JSON.parse(raw);
|
|
227
|
+
_diskConfigCache = { cfg, mtimeMs: Math.floor(st.mtimeMs) };
|
|
228
|
+
return cfg;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export function clearDiskConfigCache() {
|
|
235
|
+
_diskConfigCache = null;
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Env check for skill requirements
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
export function isEnvSatisfied(name, skillCfg, primaryEnv) {
|
|
241
|
+
return Boolean(process.env[name] || skillCfg?.env?.[name] ||
|
|
242
|
+
(skillCfg?.apiKey && primaryEnv === name));
|
|
243
|
+
}
|