clawgram 2.21.1 → 2.22.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 +34 -8
- package/dist/channel.js +289 -57
- package/dist/chat-info.js +28 -22
- package/dist/cli-core.js +109 -20
- package/dist/expiring-map.js +96 -0
- package/dist/gramjs-client.js +104 -29
- package/dist/group-reply-address.js +6 -16
- package/dist/group-visible-reply-guard.js +12 -32
- package/dist/helpers.js +50 -5
- package/dist/history.js +54 -22
- package/dist/html-render.js +44 -1
- package/dist/joins.js +8 -6
- package/dist/manage.js +9 -15
- package/dist/media.js +15 -16
- package/dist/normalize.js +6 -1
- package/dist/proxy-config.js +2 -4
- package/dist/reactions.js +3 -12
- package/dist/secret-refs.js +1 -0
- package/dist/send-scope.js +97 -0
- package/dist/silent-reaction.js +9 -5
- package/dist/state-dir.js +24 -0
- package/dist/system-notice.js +49 -4
- package/dist/update-config.js +139 -33
- package/dist/util.js +36 -0
- package/npm-shrinkwrap.json +4531 -0
- package/openclaw.plugin.json +39 -5
- package/package.json +12 -4
package/dist/reactions.js
CHANGED
|
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
14
14
|
exports.resolveAgentReactionGuidance = resolveAgentReactionGuidance;
|
|
15
15
|
exports.parseReactionParams = parseReactionParams;
|
|
16
16
|
const helpers_1 = require("./helpers");
|
|
17
|
+
const history_1 = require("./history");
|
|
17
18
|
/**
|
|
18
19
|
* How freely the agent may react, from the account's `reactionLevel`.
|
|
19
20
|
*
|
|
@@ -59,24 +60,14 @@ function readBooleanFlag(value) {
|
|
|
59
60
|
* some unrelated message, so anything else is refused rather than coerced —
|
|
60
61
|
* the same reasoning as the history parser's id/date guard.
|
|
61
62
|
*/
|
|
62
|
-
function parseMessageId(value) {
|
|
63
|
-
if (value === undefined || value === null || value === "") {
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
const parsed = Number(value);
|
|
67
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
68
|
-
throw new Error(`clawgram: react messageId must be a positive integer, got ${JSON.stringify(value)}`);
|
|
69
|
-
}
|
|
70
|
-
return parsed;
|
|
71
|
-
}
|
|
72
63
|
function parseReactionParams(params, toolContext) {
|
|
73
64
|
const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
|
|
74
65
|
const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
|
|
75
66
|
if (!target) {
|
|
76
67
|
throw new Error("clawgram: react requires a chatId");
|
|
77
68
|
}
|
|
78
|
-
const messageId = parseMessageId(params.messageId ?? params.msgId ?? params.message_id)
|
|
79
|
-
?? parseMessageId(toolContext?.currentMessageId);
|
|
69
|
+
const messageId = (0, history_1.parseMessageId)(params.messageId ?? params.msgId ?? params.message_id, "react messageId")
|
|
70
|
+
?? (0, history_1.parseMessageId)(toolContext?.currentMessageId, "react messageId");
|
|
80
71
|
if (messageId === undefined) {
|
|
81
72
|
throw new Error("clawgram: react requires a messageId");
|
|
82
73
|
}
|
package/dist/secret-refs.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.secretRefKey = secretRefKey;
|
|
22
|
+
exports.asSecretRef = asSecretRef;
|
|
22
23
|
exports.collectAccountSecretRefs = collectAccountSecretRefs;
|
|
23
24
|
exports.applyAccountSecrets = applyAccountSecrets;
|
|
24
25
|
exports.hasUnresolvedSecretRef = hasUnresolvedSecretRef;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPhoneNumberTarget = isPhoneNumberTarget;
|
|
4
|
+
exports.isSendScopeConfigured = isSendScopeConfigured;
|
|
5
|
+
exports.isChatSendable = isChatSendable;
|
|
6
|
+
exports.rememberSendScope = rememberSendScope;
|
|
7
|
+
exports.sendScopeFor = sendScopeFor;
|
|
8
|
+
exports.forgetSendScope = forgetSendScope;
|
|
9
|
+
const history_1 = require("./history");
|
|
10
|
+
/**
|
|
11
|
+
* Outbound scope for the account: who this account may write to.
|
|
12
|
+
*
|
|
13
|
+
* Reading has had a declared scope since 2.x (`readChats`), management has
|
|
14
|
+
* one (`manageChats`), and sending had none: `send`, `upload-file` and
|
|
15
|
+
* `react` resolved whatever target the caller named and delivered it. The
|
|
16
|
+
* account is a person's own Telegram account, so an injected turn could
|
|
17
|
+
* message strangers under the owner's name, or carry a work chat's content
|
|
18
|
+
* into an attacker's DM one `send` at a time (finding A5-12).
|
|
19
|
+
*
|
|
20
|
+
* Two decisions worth stating, because both could reasonably have gone the
|
|
21
|
+
* other way:
|
|
22
|
+
*
|
|
23
|
+
* 1. **An absent `sendChats` still allows sending.** `manageChats` denies by
|
|
24
|
+
* default because management arrived as a new capability; sending is what
|
|
25
|
+
* this plugin has always done, and flipping it to "current conversation
|
|
26
|
+
* only" would silence every existing deployment on upgrade — including
|
|
27
|
+
* scheduled digests that legitimately write to an id nobody is talking to
|
|
28
|
+
* right now. A deployment that wants the boundary writes `sendChats`, and
|
|
29
|
+
* then it is a boundary in code rather than a sentence in a prompt.
|
|
30
|
+
*
|
|
31
|
+
* 2. **A phone number is refused in every configuration**, wildcard included.
|
|
32
|
+
* Messaging a raw number starts a conversation with someone who never
|
|
33
|
+
* interacted with the account and hands them the account's identity;
|
|
34
|
+
* no deployment has a reason to do that from an assistant, and the
|
|
35
|
+
* address book is not the model's to walk.
|
|
36
|
+
*/
|
|
37
|
+
/** Anything that looks like a dialable number rather than a chat we know. */
|
|
38
|
+
function isPhoneNumberTarget(target) {
|
|
39
|
+
const raw = String(target ?? "").trim();
|
|
40
|
+
if (!raw)
|
|
41
|
+
return false;
|
|
42
|
+
// A Telegram chat id is digits (a user) or `-100…` (a group); a phone
|
|
43
|
+
// number is what a person writes with a plus, spaces, dashes or brackets.
|
|
44
|
+
// The `+` is the giveaway that survives normalisation, and a long digit
|
|
45
|
+
// string with separators is the other spelling of the same thing.
|
|
46
|
+
const compact = raw.replace(/[\s()\-.]/g, "");
|
|
47
|
+
if (/^\+\d{6,15}$/.test(compact))
|
|
48
|
+
return true;
|
|
49
|
+
return /[\s()\-.]/.test(raw) && /^\+?\d[\d\s()\-.]{5,}$/.test(raw);
|
|
50
|
+
}
|
|
51
|
+
function normalizeScope(sendChats) {
|
|
52
|
+
if (sendChats === undefined || sendChats === null)
|
|
53
|
+
return [];
|
|
54
|
+
return (Array.isArray(sendChats) ? sendChats : [sendChats])
|
|
55
|
+
.map(history_1.normalizeChatKey)
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
/** True while the account has a declared outbound scope at all. */
|
|
59
|
+
function isSendScopeConfigured(sendChats) {
|
|
60
|
+
return sendChats !== undefined && sendChats !== null;
|
|
61
|
+
}
|
|
62
|
+
function isChatSendable(target, sendChats) {
|
|
63
|
+
if (isPhoneNumberTarget(target))
|
|
64
|
+
return false;
|
|
65
|
+
if (!isSendScopeConfigured(sendChats))
|
|
66
|
+
return true;
|
|
67
|
+
const entries = normalizeScope(sendChats);
|
|
68
|
+
// A configured empty list is a decision, not an oversight: deny, the same
|
|
69
|
+
// way `readChats: []` denies rather than reading everything.
|
|
70
|
+
if (entries.length === 0)
|
|
71
|
+
return false;
|
|
72
|
+
if (entries.includes("*"))
|
|
73
|
+
return true;
|
|
74
|
+
return (0, history_1.chatKeyCandidates)(target).some((candidate) => entries.includes(candidate));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Область отправки каждого аккаунта, запомненная при его старте.
|
|
78
|
+
*
|
|
79
|
+
* В `outbound.resolveTarget` и `sendText` конфига нет — ядро зовёт их с
|
|
80
|
+
* `{ accountId, to }`, — а тащить её туда параметром значило бы менять
|
|
81
|
+
* контракт ядра ради одной проверки. Тот же приём уже применён к списку
|
|
82
|
+
* операторов (`system-notice.ts`), и по той же причине.
|
|
83
|
+
*
|
|
84
|
+
* Перезапуск канала при правке конфига обновляет запись; аккаунт, о котором
|
|
85
|
+
* ничего не помним, ведёт себя как аккаунт без области — то есть отправка
|
|
86
|
+
* разрешена, но телефонный адресат всё равно отвергнут.
|
|
87
|
+
*/
|
|
88
|
+
const sendScopeByAccount = new Map();
|
|
89
|
+
function rememberSendScope(accountId, sendChats) {
|
|
90
|
+
sendScopeByAccount.set(accountId, sendChats);
|
|
91
|
+
}
|
|
92
|
+
function sendScopeFor(accountId) {
|
|
93
|
+
return sendScopeByAccount.get(accountId);
|
|
94
|
+
}
|
|
95
|
+
function forgetSendScope(accountId) {
|
|
96
|
+
sendScopeByAccount.delete(accountId);
|
|
97
|
+
}
|
package/dist/silent-reaction.js
CHANGED
|
@@ -20,14 +20,13 @@
|
|
|
20
20
|
* tested without Telegram or a model.
|
|
21
21
|
*/
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.TELEGRAM_REACTIONS = void 0;
|
|
24
23
|
exports.buildEmojiSystemPrompt = buildEmojiSystemPrompt;
|
|
25
24
|
exports.canonicalizeReactionEmoji = canonicalizeReactionEmoji;
|
|
26
25
|
exports.parseEmojiChoice = parseEmojiChoice;
|
|
27
26
|
exports.shouldReactToSilentTurn = shouldReactToSilentTurn;
|
|
28
27
|
exports.reactToSilentMention = reactToSilentMention;
|
|
29
28
|
function buildEmojiSystemPrompt(appetite, allowed) {
|
|
30
|
-
const choices = allowed === undefined || allowed.length === 0 ?
|
|
29
|
+
const choices = allowed === undefined || allowed.length === 0 ? TELEGRAM_REACTIONS : allowed;
|
|
31
30
|
const shared = [
|
|
32
31
|
"You pick a single emoji reaction for a chat message.",
|
|
33
32
|
"The assistant was mentioned in this message but decided it needs no written reply.",
|
|
@@ -84,7 +83,9 @@ function buildEmojiSystemPrompt(appetite, allowed) {
|
|
|
84
83
|
* difference is invisible in an editor and a stray U+FE0F would break them
|
|
85
84
|
* again silently.
|
|
86
85
|
*/
|
|
87
|
-
|
|
86
|
+
// Модульная константа: снаружи её никто не читает, а `export` обещает
|
|
87
|
+
// публичную поверхность, которой нет (A6-21).
|
|
88
|
+
const TELEGRAM_REACTIONS = [
|
|
88
89
|
"👍", "👎", "❤", "🔥", "🥰", "👏", "😁", "🤔", "🤯", "😱",
|
|
89
90
|
"🤬", "😢", "🎉", "🤩", "🤮", "💩", "🙏", "👌", "\u{1F54A}", "🤡",
|
|
90
91
|
"🥱", "🥴", "😍", "🐳", "🌚", "🌭", "💯", "🤣", "⚡", "🍌",
|
|
@@ -101,7 +102,10 @@ exports.TELEGRAM_REACTIONS = [
|
|
|
101
102
|
* `👍🏽` is not a member of the set, `👍` is.
|
|
102
103
|
*/
|
|
103
104
|
function canonicalizeReactionEmoji(value) {
|
|
104
|
-
|
|
105
|
+
// U+FE0F записан кодом, а не символом: в исходнике он невидим, и
|
|
106
|
+
// регекс выглядел как `/ /g` — пустая на вид группа, которую при
|
|
107
|
+
// следующей правке легко «почистить» вместе со смыслом (S1-09).
|
|
108
|
+
return value.replace(/\u{FE0F}/gu, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
|
|
105
109
|
}
|
|
106
110
|
/**
|
|
107
111
|
* Turns a model answer into an emoji Telegram will actually take, or nothing.
|
|
@@ -132,7 +136,7 @@ function parseEmojiChoice(raw, allowed) {
|
|
|
132
136
|
// `ChatReactionsNone` — reactions switched off — and must permit nothing.
|
|
133
137
|
// Collapsing the two would react in a chat that forbids reacting.
|
|
134
138
|
const permitted = allowed === undefined
|
|
135
|
-
?
|
|
139
|
+
? TELEGRAM_REACTIONS
|
|
136
140
|
: allowed.map(canonicalizeReactionEmoji);
|
|
137
141
|
return permitted.includes(candidate) ? candidate : undefined;
|
|
138
142
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveStateDir = resolveStateDir;
|
|
4
|
+
/**
|
|
5
|
+
* Где живёт состояние OpenClaw — одним ответом для всего плагина.
|
|
6
|
+
*
|
|
7
|
+
* `OPENCLAW_STATE_DIR` первичен, дом — запасной вариант. Правило не
|
|
8
|
+
* косметическое: изолированный Gateway, который AGENTS.md предписывает для
|
|
9
|
+
* любой локальной проверки («никогда не трогай `~/.openclaw` — всегда задавай
|
|
10
|
+
* `OPENCLAW_STATE_DIR`»), иначе продолжает писать в боевое состояние.
|
|
11
|
+
*
|
|
12
|
+
* Помощник по медиа переменную уже уважал, журнал присоединений — нет, и
|
|
13
|
+
* тестовый экземпляр дописывал записи в живой журнал, а на двух тысячах
|
|
14
|
+
* записей переписывал его (находка A6-07). Резолвер один, чтобы такие
|
|
15
|
+
* расхождения не заводились по одному на файл.
|
|
16
|
+
*/
|
|
17
|
+
const node_os_1 = require("node:os");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
function resolveStateDir(env = process.env) {
|
|
20
|
+
const configured = env.OPENCLAW_STATE_DIR;
|
|
21
|
+
if (typeof configured === "string" && configured.trim())
|
|
22
|
+
return configured.trim();
|
|
23
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw");
|
|
24
|
+
}
|
package/dist/system-notice.js
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.classifySystemNotice = classifySystemNotice;
|
|
29
29
|
exports.shouldSuppressGroupSystemNotice = shouldSuppressGroupSystemNotice;
|
|
30
|
+
exports.isOperatorRecipient = isOperatorRecipient;
|
|
31
|
+
exports.rememberOperatorIds = rememberOperatorIds;
|
|
32
|
+
exports.operatorIdsFor = operatorIdsFor;
|
|
33
|
+
exports.forgetOperatorIds = forgetOperatorIds;
|
|
30
34
|
const TOOL_WARNING_PREFIX = "⚠️ 🛠️ ";
|
|
31
35
|
const MESSAGE_FAILED_PREFIX = "⚠️ ✉️ message failed";
|
|
32
36
|
const FALLBACK_NOTICE_PREFIX = "↪️ model fallback";
|
|
@@ -62,9 +66,50 @@ function classifySystemNotice(text) {
|
|
|
62
66
|
* so the text seen here is core's payload verbatim.
|
|
63
67
|
*/
|
|
64
68
|
function shouldSuppressGroupSystemNotice(params) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
if (params.targetKind === "group" || params.targetKind === "channel") {
|
|
70
|
+
return classifySystemNotice(params.text);
|
|
71
|
+
}
|
|
72
|
+
// Личка держала телеметрию на допущении «здесь читает тот, кто запустил
|
|
73
|
+
// агента». Допущение неверно: в личку пишет всякий, кто попал в `allowFrom`,
|
|
74
|
+
// и посторонний, чей ход уронил инструмент, получал `⚠️ 🛠️ Bash failed:`
|
|
75
|
+
// с полной командой и путями вроде /opt/openclaw-secrets/… (A5-11).
|
|
76
|
+
//
|
|
77
|
+
// Поэтому телеметрия уходит только названному оператору. Список пуст или
|
|
78
|
+
// содержит `*` — значит «оператор» не определён, и уведомление подавляется:
|
|
79
|
+
// потерять диагностику дешевле, чем отдать раскладку инфраструктуры
|
|
80
|
+
// незнакомцу, тем более что те же сбои лежат в диагностике прогона,
|
|
81
|
+
// в `lastError` джоба и в логе gateway.
|
|
82
|
+
if (!isOperatorRecipient(params.to, params.operatorIds)) {
|
|
83
|
+
return classifySystemNotice(params.text);
|
|
68
84
|
}
|
|
69
|
-
return
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function isOperatorRecipient(to, operatorIds) {
|
|
88
|
+
if (to === undefined || to === null || String(to).trim() === "")
|
|
89
|
+
return false;
|
|
90
|
+
if (!operatorIds || operatorIds.length === 0)
|
|
91
|
+
return false;
|
|
92
|
+
// `*` здесь не «все операторы», а «оператор не назван»: в списке отправителей
|
|
93
|
+
// звёздочка означает «кто угодно», и телеметрию кому угодно слать нельзя.
|
|
94
|
+
if (operatorIds.some((id) => String(id).trim() === "*"))
|
|
95
|
+
return false;
|
|
96
|
+
const target = String(to).trim().replace(/^@/, "").toLowerCase();
|
|
97
|
+
return operatorIds.some((id) => String(id).trim().replace(/^@/, "").toLowerCase() === target);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Кто оператор у каждого аккаунта.
|
|
101
|
+
*
|
|
102
|
+
* Список запоминается при старте аккаунта: в `outbound.sendText` конфига нет,
|
|
103
|
+
* а тащить её туда параметром значило бы менять контракт ради одной проверки.
|
|
104
|
+
* Перезапуск канала при правке конфига обновляет запись.
|
|
105
|
+
*/
|
|
106
|
+
const operatorIdsByAccount = new Map();
|
|
107
|
+
function rememberOperatorIds(accountId, ids) {
|
|
108
|
+
operatorIdsByAccount.set(accountId, [...ids]);
|
|
109
|
+
}
|
|
110
|
+
function operatorIdsFor(accountId) {
|
|
111
|
+
return operatorIdsByAccount.get(accountId) ?? [];
|
|
112
|
+
}
|
|
113
|
+
function forgetOperatorIds(accountId) {
|
|
114
|
+
operatorIdsByAccount.delete(accountId);
|
|
70
115
|
}
|
package/dist/update-config.js
CHANGED
|
@@ -3,12 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.keepSecretRefs = keepSecretRefs;
|
|
6
7
|
exports.createConfigBackup = createConfigBackup;
|
|
8
|
+
exports.secretRefFieldsFor = secretRefFieldsFor;
|
|
7
9
|
exports.updateConfigFileDirectly = updateConfigFileDirectly;
|
|
8
10
|
const node_fs_1 = require("node:fs");
|
|
9
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
12
|
const json5_1 = __importDefault(require("json5"));
|
|
11
13
|
const constants_1 = require("./constants");
|
|
14
|
+
const secret_refs_1 = require("./secret-refs");
|
|
15
|
+
const util_1 = require("./util");
|
|
12
16
|
function formatBackupTimestamp(date) {
|
|
13
17
|
const year = String(date.getUTCFullYear());
|
|
14
18
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
@@ -24,9 +28,6 @@ function buildConfigBackupPath(configPath) {
|
|
|
24
28
|
const suffix = `${formatBackupTimestamp(new Date())}-clawgram-auth`;
|
|
25
29
|
return node_path_1.default.join(dir, `${fileName}.bak-${suffix}`);
|
|
26
30
|
}
|
|
27
|
-
function isPlainObject(value) {
|
|
28
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
-
}
|
|
30
31
|
function buildAccountPayload(auth) {
|
|
31
32
|
return {
|
|
32
33
|
enabled: true,
|
|
@@ -35,19 +36,39 @@ function buildAccountPayload(auth) {
|
|
|
35
36
|
sessionString: auth.sessionString,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
39
|
+
/** Первый конфиг закрыт: см. одноимённую функцию в cli-core.ts. */
|
|
38
40
|
function buildAccountConfigFragment(auth) {
|
|
39
41
|
return {
|
|
40
42
|
...buildAccountPayload(auth),
|
|
41
|
-
allowFrom: [
|
|
42
|
-
|
|
43
|
-
"*": {
|
|
44
|
-
enabled: true,
|
|
45
|
-
groupPolicy: "mention",
|
|
46
|
-
allowFrom: ["*"],
|
|
47
|
-
},
|
|
48
|
-
},
|
|
43
|
+
allowFrom: auth.selfId ? [auth.selfId] : [],
|
|
44
|
+
readChats: [],
|
|
49
45
|
};
|
|
50
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Credentials already moved into the secret store must survive re-auth.
|
|
49
|
+
*
|
|
50
|
+
* An account whose apiHash/sessionString were migrated to `{source, provider,
|
|
51
|
+
* id}` had them overwritten with the literal strings typed during the
|
|
52
|
+
* interactive flow: the spread put the fresh payload on top of the reference.
|
|
53
|
+
* So an operator re-authorising after a session expiry silently undid the
|
|
54
|
+
* migration and left plaintext credentials in a file that gets backed up and
|
|
55
|
+
* synced — the exact thing secret-refs.ts was written to prevent.
|
|
56
|
+
*
|
|
57
|
+
* The reference wins. The new value is handed back so the caller can tell the
|
|
58
|
+
* operator to store it where the reference points; it is never written to the
|
|
59
|
+
* config, and never printed here.
|
|
60
|
+
*/
|
|
61
|
+
function keepSecretRefs(existingAccount, payload) {
|
|
62
|
+
const next = { ...payload };
|
|
63
|
+
const kept = [];
|
|
64
|
+
for (const field of ["apiHash", "sessionString"]) {
|
|
65
|
+
if ((0, secret_refs_1.asSecretRef)(existingAccount?.[field])) {
|
|
66
|
+
next[field] = existingAccount[field];
|
|
67
|
+
kept.push(field);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { payload: next, kept };
|
|
71
|
+
}
|
|
51
72
|
function applyAuthToConfig(config, accountId, auth) {
|
|
52
73
|
const channels = config.channels && typeof config.channels === "object" ? config.channels : {};
|
|
53
74
|
const channelConfig = channels[constants_1.CHANNEL_ID] && typeof channels[constants_1.CHANNEL_ID] === "object" ? channels[constants_1.CHANNEL_ID] : {};
|
|
@@ -63,16 +84,12 @@ function applyAuthToConfig(config, accountId, auth) {
|
|
|
63
84
|
...accounts,
|
|
64
85
|
[accountId]: {
|
|
65
86
|
...existingAccount,
|
|
66
|
-
...buildAccountPayload(auth),
|
|
87
|
+
...keepSecretRefs(existingAccount, buildAccountPayload(auth)).payload,
|
|
67
88
|
enabled: existingAccount.enabled ?? true,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
groupPolicy: "mention",
|
|
73
|
-
allowFrom: ["*"],
|
|
74
|
-
},
|
|
75
|
-
},
|
|
89
|
+
// Существующие настройки не трогаем — повторная авторизация не
|
|
90
|
+
// повод переписать чужие решения. Отсутствующие садятся закрытыми.
|
|
91
|
+
allowFrom: existingAccount.allowFrom ?? (auth.selfId ? [auth.selfId] : []),
|
|
92
|
+
readChats: existingAccount.readChats ?? [],
|
|
76
93
|
},
|
|
77
94
|
},
|
|
78
95
|
},
|
|
@@ -244,12 +261,27 @@ function scanValue(raw, start) {
|
|
|
244
261
|
kind: "scalar",
|
|
245
262
|
};
|
|
246
263
|
}
|
|
247
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Позиция СРАЗУ ЗА закрывающей скобкой объекта — как `end` у среза.
|
|
266
|
+
*
|
|
267
|
+
* Имя обманывало: `scanEnclosedValue` возвращает индекс после `}`, а вставка
|
|
268
|
+
* свойства обращалась с этим числом как с позицией самой скобки и клала
|
|
269
|
+
* свойство ЗА объектом. Итог — испорченный конфиг: аккаунт, добавленный к
|
|
270
|
+
* существующему `accounts`, оказывался соседом `accounts` внутри `clawgram`,
|
|
271
|
+
* а при отсутствующем `channels` вставка уезжала за корневую `}` и файл
|
|
272
|
+
* переставал быть JSON вовсе. Ловилось только на путях вставки, а обычная
|
|
273
|
+
* переавторизация идёт путём замены — поэтому и жило (находка A6-17).
|
|
274
|
+
*/
|
|
275
|
+
function findObjectEndExclusive(raw, objectStart) {
|
|
248
276
|
return scanEnclosedValue(raw, objectStart, "{", "}");
|
|
249
277
|
}
|
|
278
|
+
/** Позиция самой закрывающей скобки — точка, ПЕРЕД которой вставляют. */
|
|
279
|
+
function findObjectCloseBrace(raw, objectStart) {
|
|
280
|
+
return findObjectEndExclusive(raw, objectStart) - 1;
|
|
281
|
+
}
|
|
250
282
|
function listObjectProperties(raw, objectStart) {
|
|
251
283
|
const properties = [];
|
|
252
|
-
const objectEnd =
|
|
284
|
+
const objectEnd = findObjectEndExclusive(raw, objectStart);
|
|
253
285
|
let cursor = skipTrivia(raw, objectStart + 1);
|
|
254
286
|
while (cursor < objectEnd) {
|
|
255
287
|
if (raw[cursor] === "}") {
|
|
@@ -311,16 +343,16 @@ function replaceRange(raw, start, end, value) {
|
|
|
311
343
|
return `${raw.slice(0, start)}${value}${raw.slice(end)}`;
|
|
312
344
|
}
|
|
313
345
|
function insertObjectProperty(raw, objectStart, key, value, format) {
|
|
314
|
-
const
|
|
346
|
+
const closeBrace = findObjectCloseBrace(raw, objectStart);
|
|
315
347
|
const parentIndent = getLineIndent(raw, objectStart);
|
|
316
348
|
const propertyIndent = `${parentIndent}${format.indentUnit}`;
|
|
317
349
|
const propertyText = `${JSON.stringify(key)}: ${formatConfigValue(value, propertyIndent, format)}`;
|
|
318
350
|
const properties = listObjectProperties(raw, objectStart);
|
|
319
351
|
if (properties.length === 0) {
|
|
320
352
|
const insertion = `${format.eol}${propertyIndent}${propertyText}${format.eol}${parentIndent}`;
|
|
321
|
-
return replaceRange(raw,
|
|
353
|
+
return replaceRange(raw, closeBrace, closeBrace, insertion);
|
|
322
354
|
}
|
|
323
|
-
let insertAt =
|
|
355
|
+
let insertAt = closeBrace;
|
|
324
356
|
while (insertAt > objectStart + 1 && /[ \t\r\n]/.test(raw[insertAt - 1])) {
|
|
325
357
|
insertAt -= 1;
|
|
326
358
|
}
|
|
@@ -333,23 +365,74 @@ function replaceObjectPropertyValue(raw, property, value, format) {
|
|
|
333
365
|
const formattedValue = formatConfigValue(value, propertyIndent, format);
|
|
334
366
|
return replaceRange(raw, property.valueStart, property.valueEnd, formattedValue);
|
|
335
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Спуск по пути объектов. Недостающие звенья создаются пустыми объектами.
|
|
370
|
+
*
|
|
371
|
+
* Возвращает и текст, и позицию начала найденного объекта: после вставки
|
|
372
|
+
* прежние смещения указывают не туда.
|
|
373
|
+
*/
|
|
374
|
+
function descend(raw, objectStart, path) {
|
|
375
|
+
let start = objectStart;
|
|
376
|
+
for (let i = 0; i < path.length; i += 1) {
|
|
377
|
+
const property = findObjectProperty(raw, start, path[i]);
|
|
378
|
+
// Недостающее звено не достраивается по одному: вставить пустой объект и
|
|
379
|
+
// тут же найти его снова — значит положиться на разбор свойств сразу
|
|
380
|
+
// после правки текста, а это самое хрупкое место здесь. Вместо этого
|
|
381
|
+
// вызывающий вставляет весь остаток пути одним литералом.
|
|
382
|
+
if (!property)
|
|
383
|
+
return { objectStart: start, missing: path.slice(i) };
|
|
384
|
+
const valueStart = skipTrivia(raw, property.valueStart);
|
|
385
|
+
if (raw[valueStart] !== "{") {
|
|
386
|
+
throw new Error(`clawgram: ${path[i]} в конфиге не объект — правка вручную безопаснее`);
|
|
387
|
+
}
|
|
388
|
+
start = valueStart;
|
|
389
|
+
}
|
|
390
|
+
return { objectStart: start, missing: [] };
|
|
391
|
+
}
|
|
392
|
+
/** Вложенный литерал `{a: {b: {c: value}}}` для недостающего остатка пути. */
|
|
393
|
+
function nest(path, value) {
|
|
394
|
+
return path.reduceRight((inner, key) => ({ [key]: inner }), value);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Правится один аккаунт, а не весь блок `channels`.
|
|
398
|
+
*
|
|
399
|
+
* Раньше блок находился хирургически, а его значение целиком пересобиралось
|
|
400
|
+
* через `JSON.stringify`: пропадали все комментарии JSON5, висячие запятые и
|
|
401
|
+
* ручное форматирование — включая блоки ДРУГИХ каналов, к авторизации
|
|
402
|
+
* отношения не имеющих. Файл потому и JSON5, что в нём пишут пояснения; после
|
|
403
|
+
* каждой переавторизации они исчезали, а diff тонул в переформатировании,
|
|
404
|
+
* пряча ровно то изменение, ради которого всё делалось (находка A6-06).
|
|
405
|
+
*
|
|
406
|
+
* Чего это НЕ спасает: комментарии внутри самого правимого аккаунта. Его
|
|
407
|
+
* значение пересобирается — там меняются учётные данные, и разбирать его
|
|
408
|
+
* посвойственно значит полагаться на разбор комментариев в позициях, который
|
|
409
|
+
* здесь и так самое хрупкое место. Всё за пределами этого аккаунта остаётся
|
|
410
|
+
* как было.
|
|
411
|
+
*/
|
|
336
412
|
function buildUpdatedConfigText(raw, accountId, auth) {
|
|
337
413
|
const parsed = json5_1.default.parse(raw);
|
|
338
|
-
if (!isPlainObject(parsed)) {
|
|
414
|
+
if (!(0, util_1.isPlainObject)(parsed)) {
|
|
339
415
|
throw new Error("OpenClaw config root must be an object.");
|
|
340
416
|
}
|
|
341
|
-
const updatedConfig = applyAuthToConfig(parsed, accountId, auth);
|
|
342
|
-
const updatedChannels = isPlainObject(updatedConfig.channels) ? updatedConfig.channels : {};
|
|
343
417
|
const format = detectTextFormat(raw);
|
|
344
418
|
const rootStart = skipTrivia(raw, 0);
|
|
345
419
|
if (raw[rootStart] !== "{") {
|
|
346
420
|
throw new Error("OpenClaw config file is not a JSON object.");
|
|
347
421
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
422
|
+
// Что должно оказаться в аккаунте — считает та же функция, что и раньше:
|
|
423
|
+
// правила про SecretRef и закрытый посев живут в одном месте.
|
|
424
|
+
const updatedConfig = applyAuthToConfig(parsed, accountId, auth);
|
|
425
|
+
const account = updatedConfig?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[accountId] ?? {};
|
|
426
|
+
const path = ["channels", constants_1.CHANNEL_ID, "accounts"];
|
|
427
|
+
const { objectStart, missing } = descend(raw, rootStart, path);
|
|
428
|
+
if (missing.length) {
|
|
429
|
+
// Раздела нет — вставляем недостающий остаток вместе с аккаунтом.
|
|
430
|
+
return insertObjectProperty(raw, objectStart, missing[0], nest(missing.slice(1), { [accountId]: account }), format);
|
|
351
431
|
}
|
|
352
|
-
|
|
432
|
+
const existing = findObjectProperty(raw, objectStart, accountId);
|
|
433
|
+
return existing
|
|
434
|
+
? replaceObjectPropertyValue(raw, existing, account, format)
|
|
435
|
+
: insertObjectProperty(raw, objectStart, accountId, account, format);
|
|
353
436
|
}
|
|
354
437
|
/**
|
|
355
438
|
* The config this writes holds `apiHash` and `sessionString` in plaintext, so
|
|
@@ -391,11 +474,34 @@ async function createConfigBackup(configPath) {
|
|
|
391
474
|
await node_fs_1.promises.chmod(backupPath, mode);
|
|
392
475
|
return backupPath;
|
|
393
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Which credentials stayed as secret-store references, so the caller can say
|
|
479
|
+
* so. Silence here would be the worst outcome: the operator would believe the
|
|
480
|
+
* freshly issued credential is in the config and find out at the next start.
|
|
481
|
+
*/
|
|
482
|
+
function secretRefFieldsFor(raw, accountId) {
|
|
483
|
+
let parsed;
|
|
484
|
+
try {
|
|
485
|
+
parsed = json5_1.default.parse(raw);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
if (!(0, util_1.isPlainObject)(parsed))
|
|
491
|
+
return [];
|
|
492
|
+
const channels = (0, util_1.isPlainObject)(parsed.channels) ? parsed.channels : {};
|
|
493
|
+
const channel = (0, util_1.isPlainObject)(channels[constants_1.CHANNEL_ID]) ? channels[constants_1.CHANNEL_ID] : {};
|
|
494
|
+
const accounts = (0, util_1.isPlainObject)(channel.accounts) ? channel.accounts : {};
|
|
495
|
+
const account = (0, util_1.isPlainObject)(accounts[accountId]) ? accounts[accountId] : {};
|
|
496
|
+
return ["apiHash", "sessionString"].filter((field) => (0, secret_refs_1.asSecretRef)(account[field]));
|
|
497
|
+
}
|
|
394
498
|
async function updateConfigFileDirectly(configPath, accountId, auth) {
|
|
395
499
|
const raw = await node_fs_1.promises.readFile(configPath, "utf8");
|
|
500
|
+
const keptRefs = secretRefFieldsFor(raw, accountId);
|
|
396
501
|
const nextRaw = buildUpdatedConfigText(raw, accountId, auth);
|
|
397
502
|
if (nextRaw === raw) {
|
|
398
|
-
return;
|
|
503
|
+
return keptRefs;
|
|
399
504
|
}
|
|
400
505
|
await writeConfigAtomically(configPath, nextRaw);
|
|
506
|
+
return keptRefs;
|
|
401
507
|
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Small readers reimplemented once per module.
|
|
4
|
+
*
|
|
5
|
+
* `readString`, `readNumber` and `isPlainObject` each existed in two or three
|
|
6
|
+
* files with identical bodies. Identical is the good case: `toStringId` had
|
|
7
|
+
* drifted, and one path accepted an id the other rejected (finding A12-05).
|
|
8
|
+
* Nothing here is clever; the point is that there is one of each.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.readString = readString;
|
|
12
|
+
exports.readNumber = readNumber;
|
|
13
|
+
exports.isPlainObject = isPlainObject;
|
|
14
|
+
/** A non-empty trimmed string, or nothing. */
|
|
15
|
+
function readString(value) {
|
|
16
|
+
if (typeof value !== "string") {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
return trimmed === "" ? undefined : trimmed;
|
|
21
|
+
}
|
|
22
|
+
/** A finite number, from a number or from its decimal spelling. */
|
|
23
|
+
function readNumber(value) {
|
|
24
|
+
if (typeof value === "number") {
|
|
25
|
+
return Number.isFinite(value) ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
if (value === undefined || value === null) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const parsed = Number(String(value));
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** An object that is not an array and not null — a config or params bag. */
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|