clawgram 2.21.1 → 2.23.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.
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ // Исходящий контур канала: куда уходит текст и что при этом проверяется.
3
+ //
4
+ // Вынесено из channel.ts — файла на 3009 строк при следующем по величине
5
+ // 1165 (находка A6-11). Замыкание использовало отсюда ровно две вещи:
6
+ // карту рантаймов и журнал, поэтому блок отделяется фабрикой, а не
7
+ // переписыванием.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.createOutbound = createOutbound;
10
+ const core_1 = require("openclaw/plugin-sdk/core");
11
+ const media_1 = require("./media");
12
+ const send_scope_1 = require("./send-scope");
13
+ const system_notice_1 = require("./system-notice");
14
+ const group_reply_address_1 = require("./group-reply-address");
15
+ const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
16
+ const helpers_1 = require("./helpers");
17
+ const helpers_2 = require("./helpers");
18
+ const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
19
+ /** Исходящий контур для карты рантаймов канала. */
20
+ function createOutbound(runtimes) {
21
+ return {
22
+ // Core's agent-delivery path (`--deliver`, subagent announces) calls this
23
+ // hook under three constraints, all learned live on 2026-08-06:
24
+ //
25
+ // - `to` may be undefined (no explicit target, session route yielded
26
+ // none), and a rejection is NOT caught: a throw here is an unhandled
27
+ // rejection that takes down the entire gateway process.
28
+ // - `resolveAgentDeliveryPlanWithSessionRoute` calls it WITHOUT await.
29
+ // An async hook hands core a Promise, `promise.ok` reads undefined and
30
+ // the error branch dereferences `promise.error.message` — the crash
31
+ // every subagent announce died on. The hook must return a plain value;
32
+ // the call sites that do await are unaffected, await of a value works.
33
+ // - In a not-ok result core reads `error.message`, so the error must be
34
+ // Error-like, not a bare string.
35
+ //
36
+ // Peer resolution deliberately does not happen here: `sendText` resolves
37
+ // the peer itself, and doing it here would force the hook async again.
38
+ resolveTarget(ctx) {
39
+ try {
40
+ const raw = typeof ctx.to === "string" ? ctx.to.trim() : "";
41
+ actionLog.info("clawgram outbound resolveTarget", {
42
+ accountId: ctx.accountId,
43
+ rawTo: raw || null,
44
+ });
45
+ if (!raw) {
46
+ return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
47
+ }
48
+ const target = (0, helpers_1.normalizeOutboundTarget)(raw);
49
+ // Тот же барьер, что у `handleAction`: доставка ядра (`--deliver`,
50
+ // анонсы субагентов) идёт этим путём и мимо той проверки. Отказ
51
+ // здесь возвращается результатом, а не броском: бросок в этом хуке
52
+ // роняет весь gateway (грабли 06.08.2026, выше).
53
+ if (!(0, send_scope_1.isChatSendable)(target, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
54
+ const reason = (0, send_scope_1.isPhoneNumberTarget)(target)
55
+ ? "phone-number target"
56
+ : "chat outside send scope";
57
+ actionLog.warn("clawgram outbound resolveTarget refused", {
58
+ accountId: ctx.accountId,
59
+ target,
60
+ reason,
61
+ });
62
+ return { ok: false, error: new Error(`clawgram: not-allowed-chat ${target}`) };
63
+ }
64
+ return { ok: true, to: target };
65
+ }
66
+ catch (err) {
67
+ return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
68
+ }
69
+ },
70
+ async sendText(ctx) {
71
+ // Never log `text`: outbound bodies are private correspondence and the
72
+ // channel log is a plain journald sink. Length is enough to tell an
73
+ // empty or truncated send apart from a real one.
74
+ actionLog.info("clawgram outbound sendText", {
75
+ accountId: ctx.accountId,
76
+ rawTo: ctx.to,
77
+ replyToId: ctx.replyToId ?? null,
78
+ threadId: ctx.threadId ?? null,
79
+ textLength: ctx.text.length,
80
+ });
81
+ // Core normalizes reply payloads and drops the silent token before a
82
+ // channel is called, so this should never see one. "Should never" is
83
+ // what the inbound path was assumed to be too, right until it posted a
84
+ // token — and the check costs a string comparison.
85
+ if (ctx.text.trim() && (0, helpers_1.isSilentReplyText)(ctx.text)) {
86
+ actionLog.info("clawgram suppressing silent outbound send", {
87
+ accountId: ctx.accountId,
88
+ rawTo: ctx.to,
89
+ });
90
+ return { skipped: "silent" };
91
+ }
92
+ // Core's operational chatter (tool-error warnings, fallback notices)
93
+ // stays out of group chats: it is telemetry for the operator, not a
94
+ // reply to the room, and it has already been seen carrying shell
95
+ // commands with secret-store paths. DMs keep it. The text itself is
96
+ // never logged — see system-notice.ts for why.
97
+ const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
98
+ targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
99
+ text: ctx.text,
100
+ to: ctx.to,
101
+ operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
102
+ });
103
+ if (suppressedNotice) {
104
+ actionLog.warn("clawgram suppressing system notice in group", {
105
+ accountId: ctx.accountId,
106
+ rawTo: ctx.to,
107
+ noticeKind: suppressedNotice,
108
+ textLength: ctx.text.length,
109
+ });
110
+ return { skipped: "system-notice" };
111
+ }
112
+ const gram = runtimes.get(ctx.accountId);
113
+ if (!gram) {
114
+ throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
115
+ }
116
+ // The agent already answered this message with its own `send`, and this
117
+ // is core delivering the same turn's final text. Two messages for one
118
+ // answer is how 2026-08-10 read in a work chat: every request reported
119
+ // twice, in slightly different words, seconds apart.
120
+ //
121
+ // Core's own convention is that an agent which has sent a message
122
+ // returns NO_REPLY; this catches the turns that forget. The window is
123
+ // seconds wide, so a result the assistant comes back with later is
124
+ // still delivered.
125
+ if (ctx.replyToId !== null && ctx.replyToId !== undefined && (0, group_visible_reply_guard_1.hadTurnSendJustNow)({
126
+ accountId: ctx.accountId,
127
+ chatId: (0, helpers_1.normalizeOutboundTarget)(ctx.to),
128
+ currentMessageId: ctx.replyToId,
129
+ })) {
130
+ actionLog.warn("clawgram suppressing echo of a turn that already sent", {
131
+ accountId: ctx.accountId,
132
+ rawTo: ctx.to,
133
+ replyToId: ctx.replyToId,
134
+ textLength: ctx.text.length,
135
+ });
136
+ return { skipped: "duplicate" };
137
+ }
138
+ const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
139
+ accountId: ctx.accountId,
140
+ chatId: ctx.to,
141
+ replyToId: ctx.replyToId,
142
+ });
143
+ const targetKind = (0, helpers_1.inferOutboundTargetKind)(ctx.to);
144
+ const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
145
+ const messageThreadId = (0, helpers_2.parseOptionalThreadId)(ctx.threadId);
146
+ const sent = await gram.sendText({
147
+ target,
148
+ text: (0, helpers_1.prefixReplyTextToAddress)(ctx.text, groupReplyAddress),
149
+ targetKind,
150
+ replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
151
+ messageThreadId,
152
+ parseMode: gram.replyParseMode,
153
+ });
154
+ actionLog.info("clawgram outbound sendText completed", {
155
+ accountId: ctx.accountId,
156
+ to: target,
157
+ targetKind,
158
+ replyToId: ctx.replyToId ?? null,
159
+ sentMessageId: String(sent?.id ?? ""),
160
+ });
161
+ return {
162
+ ok: true,
163
+ messageId: String(sent?.id ?? ""),
164
+ };
165
+ },
166
+ async sendMedia(ctx) {
167
+ const gram = runtimes.get(ctx.accountId);
168
+ if (!gram) {
169
+ throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
170
+ }
171
+ // Same rule as the action path: a local file outside the declared
172
+ // roots is refused before anything is uploaded.
173
+ const outboundRoots = ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots;
174
+ (0, media_1.assertLocalMediaWithinRoots)(ctx.filePath, outboundRoots);
175
+ (0, media_1.assertLocalMediaWithinRoots)(ctx.mediaUrl, outboundRoots);
176
+ actionLog.info("clawgram outbound sendMedia", {
177
+ accountId: ctx.accountId,
178
+ rawTo: ctx.to,
179
+ replyToId: ctx.replyToId ?? null,
180
+ threadId: ctx.threadId ?? null,
181
+ filePath: ctx.filePath ?? null,
182
+ mediaUrl: ctx.mediaUrl ?? null,
183
+ hasText: Boolean(ctx.text),
184
+ hasCaption: Boolean(ctx.caption),
185
+ asVoice: ctx.audioAsVoice === true,
186
+ });
187
+ const file = ctx.filePath ?? ctx.mediaUrl;
188
+ if (!file) {
189
+ throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
190
+ }
191
+ // Ниже — проверки, которые у `sendText` были, а здесь не было ни
192
+ // одной: путь доставки медиа писался отдельно и обзавёлся только
193
+ // своими границами (находка A6-18).
194
+ const mediaTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
195
+ // Область отправки: файл наружу — такое же исходящее, как текст.
196
+ // `resolveTarget` ядро зовёт не на каждом пути, поэтому проверяем и тут.
197
+ if (!(0, send_scope_1.isChatSendable)(mediaTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
198
+ actionLog.warn("clawgram outbound sendMedia refused", {
199
+ accountId: ctx.accountId,
200
+ target: mediaTarget,
201
+ reason: (0, send_scope_1.isPhoneNumberTarget)(mediaTarget) ? "phone-number target" : "chat outside send scope",
202
+ });
203
+ return { skipped: "not-allowed" };
204
+ }
205
+ // Молчаливый ответ: подпись с токеном молчания означает «ничего не
206
+ // говорить», и отправлять файл с ним в подписи — тем более.
207
+ const mediaCaption = ctx.caption ?? ctx.text;
208
+ if (mediaCaption?.trim() && (0, helpers_1.isSilentReplyText)(mediaCaption)) {
209
+ actionLog.info("clawgram suppressing silent outbound media", {
210
+ accountId: ctx.accountId,
211
+ rawTo: ctx.to,
212
+ });
213
+ return { skipped: "silent" };
214
+ }
215
+ // Обращение в группе — то же, что у текста: адрес принадлежит
216
+ // конкретному входящему сообщению, а не последнему говорившему.
217
+ const mediaReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
218
+ accountId: ctx.accountId,
219
+ chatId: ctx.to,
220
+ replyToId: ctx.replyToId,
221
+ });
222
+ // Чего здесь НЕТ намеренно:
223
+ // — подавление эха хода (`hadTurnSendJustNow`): у текста дубль стоит
224
+ // лишнего сообщения, а у медиа отказ стоит потерянного файла —
225
+ // картинку агент готовил, и второй раз она не появится;
226
+ // — подавление служебных сообщений ядра в группах: они текстовые,
227
+ // медиа-доставка ими не бывает.
228
+ const messageThreadId = (0, helpers_2.parseOptionalThreadId)(ctx.threadId);
229
+ // Same normalization `sendText` does two functions up. Without it the
230
+ // channel prefix reaches peer resolution and the send throws — which is
231
+ // exactly how a synthesized group reply died on 2026-08-08, silently
232
+ // enough that the transcript fallback posted it as raw text instead.
233
+ const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
234
+ const sent = await gram.sendMedia({
235
+ target,
236
+ file,
237
+ // Подпись получает то же обращение, что и текстовый ответ.
238
+ caption: mediaCaption
239
+ ? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
240
+ : mediaCaption,
241
+ // Captions follow the account reply format like every other reply:
242
+ // they are the same agent prose, just attached to a file (2.15.0).
243
+ parseMode: gram.replyParseMode,
244
+ replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
245
+ messageThreadId,
246
+ asVoice: ctx.audioAsVoice === true,
247
+ });
248
+ actionLog.info("clawgram outbound sendMedia completed", {
249
+ accountId: ctx.accountId,
250
+ to: ctx.to,
251
+ replyToId: ctx.replyToId ?? null,
252
+ sentMessageId: String(sent?.id ?? ""),
253
+ });
254
+ return {
255
+ ok: true,
256
+ messageId: String(sent?.id ?? ""),
257
+ };
258
+ },
259
+ };
260
+ }
@@ -3,10 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveProxyConfig = resolveProxyConfig;
4
4
  exports.buildTelegramClientOptions = buildTelegramClientOptions;
5
5
  exports.describeProxy = describeProxy;
6
+ const util_1 = require("./util");
6
7
  const CONNECTION_RETRIES = 5;
7
- function isPlainObject(value) {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- }
10
8
  function toFiniteNumber(value) {
11
9
  if (typeof value === "number") {
12
10
  return value;
@@ -83,7 +81,7 @@ function resolveProxyConfig(value) {
83
81
  if (value === undefined || value === null) {
84
82
  return undefined;
85
83
  }
86
- if (!isPlainObject(value)) {
84
+ if (!(0, util_1.isPlainObject)(value)) {
87
85
  throw new Error("clawgram: proxy must be an object.");
88
86
  }
89
87
  const ip = resolveProxyHost(value.ip);
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
  }
@@ -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
+ }
@@ -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 ? exports.TELEGRAM_REACTIONS : allowed;
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
- exports.TELEGRAM_REACTIONS = [
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
- return value.replace(/️/g, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
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
- ? exports.TELEGRAM_REACTIONS
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
+ }
@@ -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
- // DMs keep the telemetry: there the reader is the person running the agent.
66
- if (params.targetKind !== "group" && params.targetKind !== "channel") {
67
- return undefined;
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 classifySystemNotice(params.text);
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
  }