clawgram 2.25.0 → 2.26.1

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.
@@ -6,6 +6,7 @@ exports.buildVoiceNoteParams = buildVoiceNoteParams;
6
6
  const chunk_1 = require("./chunk");
7
7
  const telegram_1 = require("telegram");
8
8
  const sessions_1 = require("telegram/sessions");
9
+ const uploads_1 = require("telegram/client/uploads");
9
10
  // Deep import, but the documented one: GramJS ships its SRP helper here and
10
11
  // the package has no `exports` field to forbid it.
11
12
  const Password_1 = require("telegram/Password");
@@ -63,29 +64,6 @@ function uniqueCandidates(values) {
63
64
  }
64
65
  return result;
65
66
  }
66
- function parseTargetWithThread(rawTarget) {
67
- const raw = rawTarget.trim();
68
- const topicMatch = /^(.+?):topic:(\d+)$/.exec(raw);
69
- if (topicMatch) {
70
- return {
71
- raw,
72
- chatId: topicMatch[1],
73
- messageThreadId: Number.parseInt(topicMatch[2], 10),
74
- };
75
- }
76
- const colonMatch = /^(.+):(\d+)$/.exec(raw);
77
- if (colonMatch && /^-?\d+$/.test(colonMatch[1])) {
78
- return {
79
- raw,
80
- chatId: colonMatch[1],
81
- messageThreadId: Number.parseInt(colonMatch[2], 10),
82
- };
83
- }
84
- return {
85
- raw,
86
- chatId: raw,
87
- };
88
- }
89
67
  /**
90
68
  * Membership query for `getParticipants`.
91
69
  *
@@ -358,7 +336,7 @@ class GramJsClientManager {
358
336
  peer: entity
359
337
  };
360
338
  }
361
- const parsedTarget = parseTargetWithThread(rawTarget);
339
+ const parsedTarget = (0, history_1.parseTargetWithThread)(rawTarget);
362
340
  const raw = parsedTarget.raw;
363
341
  const chatLookupTarget = parsedTarget.chatId;
364
342
  const kind = options?.kind;
@@ -781,7 +759,11 @@ class GramJsClientManager {
781
759
  return sent;
782
760
  }
783
761
  return this.client.sendFile(resolved.peer, {
784
- file: args.file,
762
+ // Bytes core read through its scoped reader keep the file's own name;
763
+ // a bare Buffer would reach Telegram as "unnamed" (B5-14).
764
+ file: typeof args.file === "string"
765
+ ? args.file
766
+ : new uploads_1.CustomFile(args.file.fileName, args.file.buffer.length, "", args.file.buffer),
785
767
  // Captions are agent prose too — the outbound path sends `caption ??
786
768
  // text` — so they render exactly like sendText does. Before 2.15.0
787
769
  // captions carried no mode at all, which meant GramJS's default
package/dist/helpers.js CHANGED
@@ -834,8 +834,11 @@ function normalizeParseMode(raw) {
834
834
  * raw markup.
835
835
  */
836
836
  function resolveReplyParseMode(cfg, accountId) {
837
- const channel = cfg?.channels?.["clawgram"];
838
- const account = channel?.accounts?.[accountId] ?? channel;
837
+ // Account level only: the schema has never allowed `replyParseMode` on the
838
+ // channel itself, so the old fallback to `channels.clawgram.replyParseMode`
839
+ // read a key `openclaw config validate` rejects — a setting that could not
840
+ // exist was read, and a test pinned it (audit B5-10).
841
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
839
842
  return normalizeParseMode(account?.replyParseMode);
840
843
  }
841
844
  /**
package/dist/history.js CHANGED
@@ -20,6 +20,8 @@ exports.normalizeParticipants = normalizeParticipants;
20
20
  exports.parseListParticipantsParams = parseListParticipantsParams;
21
21
  exports.buildHistoryQuery = buildHistoryQuery;
22
22
  exports.normalizeChatKey = normalizeChatKey;
23
+ exports.normalizeScopeList = normalizeScopeList;
24
+ exports.parseTargetWithThread = parseTargetWithThread;
23
25
  exports.chatKeyCandidates = chatKeyCandidates;
24
26
  exports.isChatReadable = isChatReadable;
25
27
  exports.isWithinWindow = isWithinWindow;
@@ -234,6 +236,20 @@ function buildHistoryQuery(args) {
234
236
  function normalizeChatKey(value) {
235
237
  return String(value ?? "").trim().replace(/^@/, "").toLowerCase();
236
238
  }
239
+ /**
240
+ * A configured chat scope (`readChats`, `sendChats`, `manageChats`) as a list
241
+ * of chat keys — or `undefined` when the key is absent, because every gate
242
+ * tells "not configured" from "configured empty" by that difference.
243
+ *
244
+ * Four copies of this normalizer used to live in three files, two of them
245
+ * trimming only and two lowercasing, so the same entry could pass one gate
246
+ * and fail another (audit B5-13). One now.
247
+ */
248
+ function normalizeScopeList(raw) {
249
+ if (raw === undefined || raw === null)
250
+ return undefined;
251
+ return (Array.isArray(raw) ? raw : [raw]).map(normalizeChatKey).filter(Boolean);
252
+ }
237
253
  /**
238
254
  * Все написания одной цели, по которым её ищут в списке доступа.
239
255
  *
@@ -247,11 +263,44 @@ function normalizeChatKey(value) {
247
263
  * `-1001234:topic:5` сегодня работает как область в одну тему, и сведение
248
264
  * всего к чату молча расширило бы её на весь чат.
249
265
  */
266
+ /**
267
+ * One target address, split into the chat and the forum topic it may name.
268
+ *
269
+ * Two spellings carry a topic: `-1001234:topic:5` and the short `-1001234:5`
270
+ * (a numeric chat, a colon, a number). This is the parser the resolver in
271
+ * `gramjs-client` uses, and since B5-08 the only one: the gates used to strip
272
+ * `:topic:N` with a regex of their own and did not know the short form, so
273
+ * `-1001234:5` passed the resolver as a topic of a listed chat and failed the
274
+ * gate as an unknown one.
275
+ */
276
+ function parseTargetWithThread(rawTarget) {
277
+ const raw = rawTarget.trim();
278
+ const topicMatch = /^(.+?):topic:(\d+)$/.exec(raw);
279
+ if (topicMatch) {
280
+ return {
281
+ raw,
282
+ chatId: topicMatch[1],
283
+ messageThreadId: Number.parseInt(topicMatch[2], 10),
284
+ };
285
+ }
286
+ const colonMatch = /^(.+):(\d+)$/.exec(raw);
287
+ if (colonMatch && /^-?\d+$/.test(colonMatch[1])) {
288
+ return {
289
+ raw,
290
+ chatId: colonMatch[1],
291
+ messageThreadId: Number.parseInt(colonMatch[2], 10),
292
+ };
293
+ }
294
+ return {
295
+ raw,
296
+ chatId: raw,
297
+ };
298
+ }
250
299
  function chatKeyCandidates(target) {
251
300
  const raw = String(target ?? "").trim();
252
301
  const withoutChannel = raw.replace(/^(?:clawgram|tguserbot|telegram|tg):/i, "");
253
302
  const withoutKind = withoutChannel.replace(/^(?:user|channel|group|conversation|room|dm):/i, "");
254
- const chatOnly = withoutKind.replace(/:topic:\d+$/i, "");
303
+ const chatOnly = parseTargetWithThread(withoutKind).chatId;
255
304
  const candidates = [raw, withoutKind, chatOnly]
256
305
  .map(normalizeChatKey)
257
306
  .filter(Boolean);
@@ -280,11 +329,9 @@ function isChatReadable(target, readChats) {
280
329
  const candidates = chatKeyCandidates(target);
281
330
  if (candidates.includes(constants_1.TELEGRAM_SERVICE_CHAT_ID))
282
331
  return false;
283
- if (readChats === undefined || readChats === null)
332
+ const entries = normalizeScopeList(readChats);
333
+ if (entries === undefined)
284
334
  return true;
285
- const entries = (Array.isArray(readChats) ? readChats : [readChats])
286
- .map(normalizeChatKey)
287
- .filter(Boolean);
288
335
  // An empty list is a configured empty list — deny, rather than silently
289
336
  // reading everything because someone left brackets behind.
290
337
  if (entries.length === 0)
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.visibleReplyText = visibleReplyText;
3
4
  exports.handleInboundEvent = handleInboundEvent;
4
5
  // Входящий контур: одно событие Telegram от нормализации до ответа.
5
6
  //
@@ -26,6 +27,7 @@ const normalize_1 = require("./normalize");
26
27
  const reactions_1 = require("./reactions");
27
28
  const silent_reaction_1 = require("./silent-reaction");
28
29
  const system_notice_1 = require("./system-notice");
30
+ const account_registry_1 = require("./account-registry");
29
31
  const group_reply_address_1 = require("./group-reply-address");
30
32
  const helpers_1 = require("./helpers");
31
33
  const constants_2 = require("./constants");
@@ -67,8 +69,53 @@ async function reactToSilentMentionForAccount(params) {
67
69
  });
68
70
  }
69
71
  const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
72
+ /**
73
+ * The text a reply may carry into a chat, after the filters every reply path
74
+ * shares — or `undefined` when nothing should go out.
75
+ *
76
+ * Three doors deliver an agent's words: the group `deliver` closure, the
77
+ * direct-message `deliver` closure and the transcript fallback. Each carried
78
+ * its own copy of the same two checks — drop the silent token, drop core's
79
+ * telemetry — and the copies drifted: the DM path had no notice filter at
80
+ * all until B5-01 (audit B5-13). One function now; the log lines keep their
81
+ * historical wording so journals stay greppable.
82
+ */
83
+ function visibleReplyText(params) {
84
+ // Not destructured: a wiring ratchet in test/inbound-pipeline.test.ts finds
85
+ // handleInboundEvent's own destructuring of its context by pattern, and
86
+ // nothing shaped like it may stand in front.
87
+ const accountId = params.accountId;
88
+ const chatId = params.chatId;
89
+ const messageId = params.messageId;
90
+ const log = params.log;
91
+ const outboundText = typeof params.text === "string" ? params.text.trim() : "";
92
+ if (!outboundText) {
93
+ return undefined;
94
+ }
95
+ // The agent may decline to answer by returning the shared silent token.
96
+ // Dropped before addressing: otherwise the reply-address prefix turns it
97
+ // into a visible message.
98
+ const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
99
+ if (!visibleText) {
100
+ log?.info?.(`clawgram suppressing silent ${params.where}`, { accountId, chatId, messageId });
101
+ return undefined;
102
+ }
103
+ // Core glues its telemetry to the turn's payload and it arrives here the
104
+ // same way an answer does. In a group it never goes out; in a DM only the
105
+ // named operator may receive it (A5-10, A5-11, B5-01).
106
+ const notice = (0, system_notice_1.shouldSuppressGroupSystemNotice)(params.kind === "group"
107
+ ? { targetKind: "group", text: visibleText }
108
+ : { targetKind: "user", text: visibleText, to: chatId, operatorIds: (0, account_registry_1.operatorIdsFor)(accountId) });
109
+ if (notice) {
110
+ log?.warn?.(`clawgram suppressing system notice in ${params.where}`, {
111
+ accountId, chatId, messageId, noticeKind: notice, textLength: visibleText.length,
112
+ });
113
+ return undefined;
114
+ }
115
+ return visibleText;
116
+ }
70
117
  async function handleInboundEvent(event, ctx) {
71
- const { accountId, cfg, channelRuntime, client, gram, log, pairing, pluginRuntime, runtimes, selfId, selfLabel, selfUsername } = ctx;
118
+ const { accountId, cfg, channelRuntime, client, gram, log, pluginRuntime, runtimes, selfId, selfLabel, selfUsername } = ctx;
72
119
  try {
73
120
  const rawMessage = event?.message;
74
121
  const rawPeerUserId = rawMessage?.peerId?.userId;
@@ -326,6 +373,12 @@ async function handleInboundEvent(event, ctx) {
326
373
  // the answer — and by the same resolver `resolveAccount` uses, so the
327
374
  // gate applied here is the one the account was started with.
328
375
  const { allowFrom: directAllowFrom } = inboundScopes;
376
+ // Fixed, not configurable: clawgram admits a DM by `allowFrom` alone
377
+ // (the roster) and offers no pairing challenge — a stranger cannot
378
+ // talk their way in. Core's resolver is still called for the block
379
+ // decision and `commandAuthorized`; with "open" it never answers
380
+ // "pairing", so the 30-line challenge branch that once followed it
381
+ // was unreachable and read like a barrier (audit B5-15).
329
382
  const dmPolicy = "open";
330
383
  if (normalized.chatType === "group") {
331
384
  const groupConfig = inboundGroupConfig;
@@ -555,38 +608,11 @@ async function handleInboundEvent(event, ctx) {
555
608
  payloadTextLength: outboundText.length,
556
609
  payloadReplyToId: payload.replyToId ?? null,
557
610
  });
558
- if (!outboundText) {
559
- return;
560
- }
561
- // The agent may decline to answer by returning the shared
562
- // silent token. Drop it before addressing: otherwise the
563
- // reply-address prefix turns it into a visible message.
564
- const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
565
- if (!visibleText) {
566
- log?.info?.("clawgram suppressing silent group reply", {
567
- accountId,
568
- chatId: normalized.chatId,
569
- messageId: normalized.messageId,
570
- });
571
- return;
572
- }
573
- // Ядро подклеивает свою телеметрию к полезной нагрузке
574
- // хода, и сюда она приходит тем же путём, что ответ.
575
- // Проверка стояла только в `outbound.sendText`, то есть
576
- // класс инцидента 30.08–01.09 был закрыт для рассылок и
577
- // открыт для обычного ответа на упоминание (A5-10).
578
- const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
579
- targetKind: "group",
580
- text: visibleText,
611
+ const visibleText = visibleReplyText({
612
+ text: outboundText, kind: "group", where: "group reply",
613
+ accountId, chatId: normalized.chatId, messageId: normalized.messageId, log,
581
614
  });
582
- if (groupNotice) {
583
- log?.warn?.("clawgram suppressing system notice in group reply", {
584
- accountId,
585
- chatId: normalized.chatId,
586
- messageId: normalized.messageId,
587
- noticeKind: groupNotice,
588
- textLength: visibleText.length,
589
- });
615
+ if (!visibleText) {
590
616
  return;
591
617
  }
592
618
  const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
@@ -647,19 +673,10 @@ async function handleInboundEvent(event, ctx) {
647
673
  : "";
648
674
  // Тот же фильтр и здесь: последняя реплика в стенограмме
649
675
  // вполне может оказаться именно уведомлением об ошибке.
650
- const fallbackNotice = rawFallback
651
- ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
652
- : undefined;
653
- if (fallbackNotice) {
654
- log?.warn?.("clawgram suppressing system notice in transcript fallback", {
655
- accountId,
656
- chatId: normalized.chatId,
657
- messageId: normalized.messageId,
658
- noticeKind: fallbackNotice,
659
- textLength: rawFallback.length,
660
- });
661
- }
662
- const visibleFallbackText = fallbackNotice ? "" : rawFallback;
676
+ const visibleFallbackText = visibleReplyText({
677
+ text: rawFallback, kind: "group", where: "transcript fallback",
678
+ accountId, chatId: normalized.chatId, messageId: normalized.messageId, log,
679
+ }) ?? "";
663
680
  if (!visibleFallbackText) {
664
681
  if (fallbackText) {
665
682
  log?.info?.("clawgram skipping silent transcript fallback", {
@@ -769,7 +786,6 @@ async function handleInboundEvent(event, ctx) {
769
786
  senderId,
770
787
  senderUsername,
771
788
  }),
772
- readStoreAllowFrom: pairing.readStoreForDmPolicy,
773
789
  });
774
790
  if (access.access.decision === "block") {
775
791
  log?.info?.("clawgram blocking inbound direct message", {
@@ -782,36 +798,6 @@ async function handleInboundEvent(event, ctx) {
782
798
  });
783
799
  return;
784
800
  }
785
- if (access.access.decision === "pairing") {
786
- await pairing.issueChallenge({
787
- senderId,
788
- senderIdLine: `Your Telegram user id: ${senderId}`,
789
- meta: {
790
- username: normalized.senderUsername,
791
- name: normalized.senderDisplay,
792
- },
793
- sendPairingReply: async (pairingText) => {
794
- await sendTextToConversation({
795
- text: pairingText,
796
- });
797
- },
798
- onReplyError: (err) => {
799
- log?.info?.("clawgram pairing reply failed", {
800
- accountId,
801
- chatId: normalized.chatId,
802
- senderId,
803
- error: String(err),
804
- });
805
- },
806
- });
807
- log?.info?.("clawgram pairing required for inbound direct message", {
808
- accountId,
809
- chatId: normalized.chatId,
810
- messageId: normalized.messageId,
811
- senderId,
812
- });
813
- return;
814
- }
815
801
  // Same fetch as the group path. In a DM the parent is as often
816
802
  // the agent's own message as the person's — the owner answers a
817
803
  // notice she sent — and neither text is available any other way.
@@ -852,37 +838,13 @@ async function handleInboundEvent(event, ctx) {
852
838
  NativeChannelId: normalized.chatId,
853
839
  },
854
840
  deliver: async (payload) => {
855
- const outboundText = typeof payload.text === "string" ? payload.text.trim() : "";
856
- if (!outboundText) {
857
- return;
858
- }
859
- const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
860
- if (!visibleText) {
861
- log?.info?.("clawgram suppressing silent direct reply", {
862
- accountId,
863
- chatId: normalized.chatId,
864
- messageId: normalized.messageId,
865
- });
866
- return;
867
- }
868
- // Тот же фильтр, что у группового ответа и у outbound.sendText:
869
- // личный ответ идёт третьим путём, и закрытие A5-11 его не
870
- // покрывало — «⚠️ 🛠️ Bash failed: cat /opt/openclaw-secrets/…»
871
- // уходил любому из allowFrom, чей ход уронил инструмент (B5-01).
872
- const directNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
873
- targetKind: "user",
874
- text: visibleText,
875
- to: normalized.chatId,
876
- operatorIds: (0, system_notice_1.operatorIdsFor)(accountId),
841
+ // Тот же фильтр, что у группового ответа: личный ответ идёт
842
+ // третьим путём, и закрытие A5-11 его не покрывало (B5-01).
843
+ const visibleText = visibleReplyText({
844
+ text: payload.text, kind: "user", where: "direct reply",
845
+ accountId, chatId: normalized.chatId, messageId: normalized.messageId, log,
877
846
  });
878
- if (directNotice) {
879
- log?.warn?.("clawgram suppressing system notice in direct reply", {
880
- accountId,
881
- chatId: normalized.chatId,
882
- messageId: normalized.messageId,
883
- noticeKind: directNotice,
884
- textLength: visibleText.length,
885
- });
847
+ if (!visibleText) {
886
848
  return;
887
849
  }
888
850
  await sendTextToConversation({
package/dist/manage.js CHANGED
@@ -202,12 +202,7 @@ function parseInviteLinkParams(params, toolContext) {
202
202
  };
203
203
  }
204
204
  function normalizeScope(manageChats) {
205
- if (manageChats === undefined || manageChats === null) {
206
- return [];
207
- }
208
- return (Array.isArray(manageChats) ? manageChats : [manageChats])
209
- .map(history_1.normalizeChatKey)
210
- .filter(Boolean);
205
+ return (0, history_1.normalizeScopeList)(manageChats) ?? [];
211
206
  }
212
207
  /** True while the account is allowed to manage anything at all. */
213
208
  function isManagementEnabled(manageChats) {
package/dist/media.js CHANGED
@@ -22,6 +22,7 @@ exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
22
22
  exports.pruneFetchedMedia = pruneFetchedMedia;
23
23
  exports.isLocalMediaPath = isLocalMediaPath;
24
24
  exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
25
+ exports.loadOutboundMedia = loadOutboundMedia;
25
26
  const node_fs_1 = require("node:fs");
26
27
  const node_path_1 = __importDefault(require("node:path"));
27
28
  const util_1 = require("./util");
@@ -292,3 +293,24 @@ function assertLocalMediaWithinRoots(file, roots) {
292
293
  throw new Error(`clawgram: ${file} is outside the media roots this agent may read`);
293
294
  }
294
295
  }
296
+ /**
297
+ * The file an outbound send should hand to GramJS.
298
+ *
299
+ * Core scopes a call in two ways: `mediaLocalRoots` names the directories the
300
+ * agent may read from, and `mediaReadFile` is a reader that enforces them
301
+ * inside core. Bundled channels read local files through that reader; this
302
+ * one opened the path itself as the gateway process — the roots were checked
303
+ * here, the reader ignored, so a call core scoped with a reader and no roots
304
+ * (the default on the RPC and TTS paths) was not scoped at all (audit B5-14).
305
+ *
306
+ * Roots are still checked first, symlinks resolved. A local path is then read
307
+ * through core's reader when one is given, and sent as bytes under the file's
308
+ * own name; without a reader, or for a URL, the file goes to GramJS as before.
309
+ */
310
+ async function loadOutboundMedia(file, roots, readFile) {
311
+ assertLocalMediaWithinRoots(file, roots);
312
+ if (!readFile || !isLocalMediaPath(file)) {
313
+ return file;
314
+ }
315
+ return { buffer: await readFile(file), fileName: node_path_1.default.basename(file) };
316
+ }
package/dist/outbound.js CHANGED
@@ -10,6 +10,7 @@ exports.createOutbound = createOutbound;
10
10
  const core_1 = require("openclaw/plugin-sdk/core");
11
11
  const media_1 = require("./media");
12
12
  const send_scope_1 = require("./send-scope");
13
+ const account_registry_1 = require("./account-registry");
13
14
  const system_notice_1 = require("./system-notice");
14
15
  const group_reply_address_1 = require("./group-reply-address");
15
16
  const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
@@ -50,16 +51,14 @@ function createOutbound(runtimes) {
50
51
  // анонсы субагентов) идёт этим путём и мимо той проверки. Отказ
51
52
  // здесь возвращается результатом, а не броском: бросок в этом хуке
52
53
  // роняет весь 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";
54
+ if (!(0, send_scope_1.isChatSendable)(target, (0, account_registry_1.sendScopeFor)(ctx.accountId))) {
55
+ const refusal = (0, send_scope_1.describeSendRefusal)(target);
57
56
  actionLog.warn("clawgram outbound resolveTarget refused", {
58
57
  accountId: ctx.accountId,
59
- target,
60
- reason,
58
+ reason: refusal.reason,
59
+ ...refusal.logFields,
61
60
  });
62
- return { ok: false, error: new Error(`clawgram: not-allowed-chat ${target}`) };
61
+ return { ok: false, error: refusal.error };
63
62
  }
64
63
  return { ok: true, to: target };
65
64
  }
@@ -94,11 +93,12 @@ function createOutbound(runtimes) {
94
93
  // handleAction, где барьер уже стоял: третий из трёх исходящих путей
95
94
  // был открыт для любого адресата и телефонного номера (D2-01, A5-12).
96
95
  const scopedTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
97
- if (!(0, send_scope_1.isChatSendable)(scopedTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
96
+ if (!(0, send_scope_1.isChatSendable)(scopedTarget, (0, account_registry_1.sendScopeFor)(ctx.accountId))) {
97
+ const refusal = (0, send_scope_1.describeSendRefusal)(scopedTarget);
98
98
  actionLog.warn("clawgram outbound sendText refused", {
99
99
  accountId: ctx.accountId,
100
- target: scopedTarget,
101
- reason: (0, send_scope_1.isPhoneNumberTarget)(scopedTarget) ? "phone-number target" : "chat outside send scope",
100
+ reason: refusal.reason,
101
+ ...refusal.logFields,
102
102
  });
103
103
  return { skipped: "not-allowed" };
104
104
  }
@@ -111,7 +111,7 @@ function createOutbound(runtimes) {
111
111
  targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
112
112
  text: ctx.text,
113
113
  to: ctx.to,
114
- operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
114
+ operatorIds: (0, account_registry_1.operatorIdsFor)(ctx.accountId),
115
115
  });
116
116
  if (suppressedNotice) {
117
117
  actionLog.warn("clawgram suppressing system notice in group", {
@@ -122,10 +122,7 @@ function createOutbound(runtimes) {
122
122
  });
123
123
  return { skipped: "system-notice" };
124
124
  }
125
- const gram = runtimes.get(ctx.accountId);
126
- if (!gram) {
127
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
128
- }
125
+ const gram = (0, account_registry_1.requireRuntime)(runtimes, ctx.accountId);
129
126
  // The agent already answered this message with its own `send`, and this
130
127
  // is core delivering the same turn's final text. Two messages for one
131
128
  // answer is how 2026-08-10 read in a work chat: every request reported
@@ -177,10 +174,7 @@ function createOutbound(runtimes) {
177
174
  };
178
175
  },
179
176
  async sendMedia(ctx) {
180
- const gram = runtimes.get(ctx.accountId);
181
- if (!gram) {
182
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
183
- }
177
+ const gram = (0, account_registry_1.requireRuntime)(runtimes, ctx.accountId);
184
178
  // Same rule as the action path: a local file outside the declared
185
179
  // roots is refused before anything is uploaded.
186
180
  const outboundRoots = ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots;
@@ -197,8 +191,8 @@ function createOutbound(runtimes) {
197
191
  hasCaption: Boolean(ctx.caption),
198
192
  asVoice: ctx.audioAsVoice === true,
199
193
  });
200
- const file = ctx.filePath ?? ctx.mediaUrl;
201
- if (!file) {
194
+ const named = ctx.filePath ?? ctx.mediaUrl;
195
+ if (!named) {
202
196
  throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
203
197
  }
204
198
  // Ниже — проверки, которые у `sendText` были, а здесь не было ни
@@ -207,11 +201,12 @@ function createOutbound(runtimes) {
207
201
  const mediaTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
208
202
  // Область отправки: файл наружу — такое же исходящее, как текст.
209
203
  // `resolveTarget` ядро зовёт не на каждом пути, поэтому проверяем и тут.
210
- if (!(0, send_scope_1.isChatSendable)(mediaTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
204
+ if (!(0, send_scope_1.isChatSendable)(mediaTarget, (0, account_registry_1.sendScopeFor)(ctx.accountId))) {
205
+ const refusal = (0, send_scope_1.describeSendRefusal)(mediaTarget);
211
206
  actionLog.warn("clawgram outbound sendMedia refused", {
212
207
  accountId: ctx.accountId,
213
- target: mediaTarget,
214
- reason: (0, send_scope_1.isPhoneNumberTarget)(mediaTarget) ? "phone-number target" : "chat outside send scope",
208
+ reason: refusal.reason,
209
+ ...refusal.logFields,
215
210
  });
216
211
  return { skipped: "not-allowed" };
217
212
  }
@@ -244,6 +239,9 @@ function createOutbound(runtimes) {
244
239
  // exactly how a synthesized group reply died on 2026-08-08, silently
245
240
  // enough that the transcript fallback posted it as raw text instead.
246
241
  const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
242
+ // Read last, through core's scoped reader when it gave one: every
243
+ // refusal above must have passed before the file is opened.
244
+ const file = await (0, media_1.loadOutboundMedia)(named, outboundRoots, ctx.mediaReadFile ?? ctx.mediaAccess?.readFile);
247
245
  const sent = await gram.sendMedia({
248
246
  target,
249
247
  file,
@@ -3,9 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isPhoneNumberTarget = isPhoneNumberTarget;
4
4
  exports.isSendScopeConfigured = isSendScopeConfigured;
5
5
  exports.isChatSendable = isChatSendable;
6
- exports.rememberSendScope = rememberSendScope;
7
- exports.sendScopeFor = sendScopeFor;
8
- exports.forgetSendScope = forgetSendScope;
6
+ exports.describeSendRefusal = describeSendRefusal;
9
7
  const history_1 = require("./history");
10
8
  /**
11
9
  * Outbound scope for the account: who this account may write to.
@@ -49,11 +47,7 @@ function isPhoneNumberTarget(target) {
49
47
  return /[\s()\-.]/.test(raw) && /^\+?\d[\d\s()\-.]{5,}$/.test(raw);
50
48
  }
51
49
  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);
50
+ return (0, history_1.normalizeScopeList)(sendChats) ?? [];
57
51
  }
58
52
  /** True while the account has a declared outbound scope at all. */
59
53
  function isSendScopeConfigured(sendChats) {
@@ -74,24 +68,17 @@ function isChatSendable(target, sendChats) {
74
68
  return (0, history_1.chatKeyCandidates)(target).some((candidate) => entries.includes(candidate));
75
69
  }
76
70
  /**
77
- * Область отправки каждого аккаунта, запомненная при его старте.
71
+ * One refusal for every outbound door `handleAction`, `outbound.resolveTarget`,
72
+ * `sendText`, `sendMedia` — so the four read the same and log the same.
78
73
  *
79
- * В `outbound.resolveTarget` и `sendText` конфига нет ядро зовёт их с
80
- * `{ accountId, to }`, а тащить её туда параметром значило бы менять
81
- * контракт ядра ради одной проверки. Тот же приём уже применён к списку
82
- * операторов (`system-notice.ts`), и по той же причине.
83
- *
84
- * Перезапуск канала при правке конфига обновляет запись; аккаунт, о котором
85
- * ничего не помним, ведёт себя как аккаунт без области — то есть отправка
86
- * разрешена, но телефонный адресат всё равно отвергнут.
74
+ * A phone number is personal data: the journal gets the kind of target, not
75
+ * the value (B5-09). Two of the four doors used to log it anyway (D2-11).
87
76
  */
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);
77
+ function describeSendRefusal(target) {
78
+ const phone = isPhoneNumberTarget(target);
79
+ return {
80
+ reason: phone ? "phone-number target" : "chat outside send scope",
81
+ logFields: phone ? { targetKind: "phone" } : { target },
82
+ error: new Error(`clawgram: not-allowed-chat ${target}`),
83
+ };
97
84
  }
@@ -28,9 +28,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.classifySystemNotice = classifySystemNotice;
29
29
  exports.shouldSuppressGroupSystemNotice = shouldSuppressGroupSystemNotice;
30
30
  exports.isOperatorRecipient = isOperatorRecipient;
31
- exports.rememberOperatorIds = rememberOperatorIds;
32
- exports.operatorIdsFor = operatorIdsFor;
33
- exports.forgetOperatorIds = forgetOperatorIds;
34
31
  const TOOL_WARNING_PREFIX = "⚠️ 🛠️ ";
35
32
  const MESSAGE_FAILED_PREFIX = "⚠️ ✉️ message failed";
36
33
  const FALLBACK_NOTICE_PREFIX = "↪️ model fallback";
@@ -96,20 +93,3 @@ function isOperatorRecipient(to, operatorIds) {
96
93
  const target = String(to).trim().replace(/^@/, "").toLowerCase();
97
94
  return operatorIds.some((id) => String(id).trim().replace(/^@/, "").toLowerCase() === target);
98
95
  }
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);
115
- }