clawgram 2.21.0 → 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/dist/channel.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createChannelPlugin = exports.CORE_ACTION_SYNONYMS = void 0;
7
+ exports.canonicalAction = canonicalAction;
7
8
  const core_1 = require("openclaw/plugin-sdk/core");
8
9
  const node_os_1 = __importDefault(require("node:os"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -20,7 +21,10 @@ const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
20
21
  * that a chat full of images does not silently become a copy of itself in the
21
22
  * temp directory.
22
23
  */
23
- const FETCHED_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
24
+ // Час, а не сутки: `fetch-media` существует ради «прочитать и переслать», и
25
+ // файл нужен ровно на время хода. Сутки означали сутки чужой личной переписки
26
+ // на диске (A5-13).
27
+ const FETCHED_MEDIA_TTL_MS = 60 * 60 * 1000;
24
28
  /**
25
29
  * What this channel promises the Gateway.
26
30
  *
@@ -67,11 +71,13 @@ const constants_1 = require("./constants");
67
71
  const gramjs_client_1 = require("./gramjs-client");
68
72
  const normalize_1 = require("./normalize");
69
73
  const history_1 = require("./history");
74
+ const send_scope_1 = require("./send-scope");
70
75
  const joins_1 = require("./joins");
71
76
  const reactions_1 = require("./reactions");
72
77
  const manage_1 = require("./manage");
73
78
  const silent_reaction_1 = require("./silent-reaction");
74
79
  const system_notice_1 = require("./system-notice");
80
+ const state_dir_1 = require("./state-dir");
75
81
  const chat_info_1 = require("./chat-info");
76
82
  const topics_1 = require("./topics");
77
83
  const dialogs_1 = require("./dialogs");
@@ -159,12 +165,71 @@ function readAccountReadChats(account) {
159
165
  function resolveAccountReadChats(cfg, accountId) {
160
166
  return readAccountReadChats(cfg?.channels?.["clawgram"]?.accounts?.[accountId]);
161
167
  }
168
+ /**
169
+ * Outbound scope as configured. Handed to `isChatSendable` raw: an absent
170
+ * value means "unrestricted" and an empty list means "deny", and only the
171
+ * raw value tells those apart — same shape as `readChats`.
172
+ */
173
+ /**
174
+ * Хэндл в `allowFrom` — обещание, которое Telegram не держит.
175
+ *
176
+ * Запись `@username` утверждает не про человека, а про хэндл: хэндл можно
177
+ * освободить, и тогда его берёт кто угодно — запись начинает пускать
178
+ * постороннего, ничего об этом не сказав. Числовой id так не переходит из рук
179
+ * в руки. Отказываться от хэндлов нельзя (люди пишут ими, и конфиг у многих
180
+ * уже такой), но молчать об этом тоже не годится — поэтому предупреждение
181
+ * один раз при старте аккаунта (находка A5-16).
182
+ */
183
+ function warnAboutHandleAllowlistEntries(cfg, accountId) {
184
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
185
+ const entries = Array.isArray(account?.allowFrom) ? account.allowFrom : [];
186
+ const handles = entries
187
+ .map((entry) => String(entry ?? "").trim())
188
+ .filter((entry) => entry.startsWith("@"));
189
+ if (handles.length === 0) {
190
+ return;
191
+ }
192
+ actionLog.warn("clawgram allowFrom names handles, not ids", {
193
+ accountId,
194
+ // Сами хэндлы — это про людей: в лог уходит только их число.
195
+ handleEntries: handles.length,
196
+ why: "a released handle can be taken by someone else; numeric ids do not change hands",
197
+ });
198
+ }
199
+ function resolveAccountSendChats(cfg, accountId) {
200
+ return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.sendChats;
201
+ }
202
+ /** One refusal for every outbound action, so the three read the same. */
203
+ function refuseOutboundOutsideScope(action, accountId, target) {
204
+ const reason = (0, send_scope_1.isPhoneNumberTarget)(target) ? "phone-number target" : "chat outside send scope";
205
+ actionLog.warn(`clawgram ${action} refused: ${reason}`, { accountId, target });
206
+ throw new Error(`clawgram: not-allowed-chat ${target}`);
207
+ }
162
208
  /**
163
209
  * Management scope as configured. Handed to `isChatManageable` raw: unlike
164
210
  * `readChats`, an absent value already means "deny", so there is nothing to
165
211
  * tell apart here.
166
212
  */
167
213
  /** Chat discovery as configured; absent means "deny", like management scope. */
214
+ /**
215
+ * Кому уходит операционная телеметрия ядра в личке.
216
+ *
217
+ * `operatorIds` — если задан. Иначе `allowFrom`, но только когда это
218
+ * конкретный список: со звёздочкой он означает «пишет кто угодно», и слать
219
+ * туда пути secret-store нельзя (A5-11). Пустой результат означает «оператор
220
+ * не назван», и уведомление подавляется везде.
221
+ *
222
+ * Запоминается при старте аккаунта — см. реестр в system-notice.ts.
223
+ */
224
+ function resolveAccountOperatorIds(cfg, accountId) {
225
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
226
+ const explicit = account?.operatorIds;
227
+ const raw = explicit !== undefined && explicit !== null ? explicit : account?.allowFrom;
228
+ if (raw === undefined || raw === null)
229
+ return [];
230
+ const entries = Array.isArray(raw) ? raw : [raw];
231
+ return entries.map((entry) => String(entry).trim()).filter(Boolean);
232
+ }
168
233
  function resolveAccountDiscoverChats(cfg, accountId) {
169
234
  return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.discoverChats;
170
235
  }
@@ -180,55 +245,62 @@ function readAccountManageChats(account) {
180
245
  return entries.map((entry) => String(entry).trim()).filter(Boolean);
181
246
  }
182
247
  /**
183
- * Core's own name for a clawgram action, and the only thing that makes the
184
- * action reachable from the agent's `message` tool.
248
+ * Every accepted spelling of an action, mapped to its canonical name.
185
249
  *
186
- * Core keys its target policy by `CHANNEL_MESSAGE_ACTION_NAMES`, and an action
187
- * outside that vocabulary is simultaneously "requires a target" and "does not
188
- * accept a target" there is no call that satisfies both. Declaring `chatId`
189
- * through `messageActionTargetAliases` looks like the fix and is not: core
190
- * resolves the channel with `getBootstrapChannelPlugin`, which only knows
191
- * bundled channels, so a plugin channel's declaration is never read. Measured
192
- * on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
193
- * from the same caller, on the same chat.
250
+ * One table, not three. The synonyms used to live in
251
+ * `CORE_ACTION_SYNONYMS`, again in `MANAGE_ACTION_ALIASES`, and a third time
252
+ * as `action === "…" || …` chains inside the dispatcher and the dispatcher
253
+ * read only the chains. A name could therefore be added to a table and to the
254
+ * advertised list and still reach nothing, with the suite none the wiser:
255
+ * it only ever dispatched the native spellings (finding A6-10).
194
256
  *
195
- * Every name on the right maps to core target mode `"none"` except
196
- * `channel-info`, which is `"channelId"`: the chat arrives in
197
- * `params.channelId`, a spelling no parser here read until 2.21.0 — so the
198
- * call fell through to the current chat and answered about the wrong one.
199
- * `readChatTargetParam` is the single list of accepted spellings now.
257
+ * `canonicalAction` is now the only place a name is resolved, and
258
+ * `CORE_ACTION_SYNONYMS` below is derived from this table rather than kept
259
+ * beside it.
200
260
  */
201
- exports.CORE_ACTION_SYNONYMS = {
261
+ const ACTION_ALIASES = {
262
+ send: "send",
263
+ read: "read",
264
+ // `list` is accepted so a caller that guessed the other obvious name is not
265
+ // silently refused.
266
+ list: "read",
267
+ react: "react",
268
+ joins: "joins",
269
+ "upload-file": "upload-file",
270
+ sendAttachment: "upload-file",
271
+ "fetch-media": "fetch-media",
272
+ fetchMedia: "fetch-media",
273
+ "download-media": "fetch-media",
274
+ downloadMedia: "fetch-media",
275
+ getMedia: "fetch-media",
276
+ "download-file": "fetch-media",
277
+ participants: "participants",
278
+ members: "participants",
279
+ "member-info": "participants",
280
+ topics: "topics",
281
+ forumTopics: "topics",
202
282
  "thread-list": "topics",
283
+ dialogs: "dialogs",
284
+ chats: "dialogs",
203
285
  "channel-list": "dialogs",
286
+ chatInfo: "chatInfo",
287
+ getChatInfo: "chatInfo",
204
288
  "channel-info": "chatInfo",
205
- "member-info": "participants",
206
- "download-file": "fetch-media",
289
+ chatMetadata: "chatInfo",
290
+ getChatMetadata: "chatInfo",
207
291
  // Chat management. `kick` was already accepted; the rest were advertised
208
292
  // under names core does not know and were therefore never callable from the
209
293
  // tool at all — 2.19.4 gives them core's nearest name. `transferOwnership`
210
294
  // and `inviteLink` have no counterpart in that vocabulary and stay
211
295
  // gateway-only, as does `joins`.
212
- "channel-create": "createGroup",
213
- addParticipant: "addMembers",
214
- kick: "removeMember",
215
- "role-add": "promoteAdmin",
216
- "role-remove": "demoteAdmin",
217
- };
218
- /** Canonical management action for every accepted spelling. */
219
- const MANAGE_ACTION_ALIASES = {
220
- // Core's spellings first — these are the only ones the agent's tool can
221
- // reach; see CORE_ACTION_SYNONYMS.
222
- "channel-create": "createGroup",
223
- addParticipant: "addMembers",
224
- "role-add": "promoteAdmin",
225
- "role-remove": "demoteAdmin",
226
296
  createGroup: "createGroup",
227
297
  createChat: "createGroup",
228
298
  "create-group": "createGroup",
299
+ "channel-create": "createGroup",
229
300
  addMembers: "addMembers",
230
301
  addMember: "addMembers",
231
302
  "add-members": "addMembers",
303
+ addParticipant: "addMembers",
232
304
  removeMember: "removeMember",
233
305
  removeMembers: "removeMember",
234
306
  "remove-member": "removeMember",
@@ -237,9 +309,11 @@ const MANAGE_ACTION_ALIASES = {
237
309
  promote: "promoteAdmin",
238
310
  "promote-admin": "promoteAdmin",
239
311
  setAdmin: "promoteAdmin",
312
+ "role-add": "promoteAdmin",
240
313
  demoteAdmin: "demoteAdmin",
241
314
  demote: "demoteAdmin",
242
315
  "demote-admin": "demoteAdmin",
316
+ "role-remove": "demoteAdmin",
243
317
  transferOwnership: "transferOwnership",
244
318
  transferOwner: "transferOwnership",
245
319
  "transfer-ownership": "transferOwnership",
@@ -247,6 +321,43 @@ const MANAGE_ACTION_ALIASES = {
247
321
  exportInviteLink: "inviteLink",
248
322
  "invite-link": "inviteLink",
249
323
  };
324
+ /** The canonical action for a spelling; an unknown name stays itself. */
325
+ function canonicalAction(action) {
326
+ return ACTION_ALIASES[action] ?? action;
327
+ }
328
+ /**
329
+ * Core's own name for a clawgram action, and the only thing that makes the
330
+ * action reachable from the agent's `message` tool.
331
+ *
332
+ * Core keys its target policy by `CHANNEL_MESSAGE_ACTION_NAMES`, and an action
333
+ * outside that vocabulary is simultaneously "requires a target" and "does not
334
+ * accept a target" — there is no call that satisfies both. Declaring `chatId`
335
+ * through `messageActionTargetAliases` looks like the fix and is not: core
336
+ * resolves the channel with `getBootstrapChannelPlugin`, which only knows
337
+ * bundled channels, so a plugin channel's declaration is never read. Measured
338
+ * on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
339
+ * from the same caller, on the same chat.
340
+ *
341
+ * Every name on the right maps to core target mode `"none"` except
342
+ * `channel-info`, which is `"channelId"`: the chat arrives in
343
+ * `params.channelId`, a spelling no parser here read until 2.21.0 — so the
344
+ * call fell through to the current chat and answered about the wrong one.
345
+ * `readChatTargetParam` is the single list of accepted spellings now.
346
+ *
347
+ * These spellings are derived from `ACTION_ALIASES` rather than kept beside
348
+ * it; that core actually knows each of them is asserted against the installed
349
+ * core in `core-action-synonyms.test.ts`.
350
+ */
351
+ const CORE_VOCABULARY_SPELLINGS = [
352
+ "thread-list", "channel-list", "channel-info", "member-info", "download-file",
353
+ "channel-create", "addParticipant", "kick", "role-add", "role-remove",
354
+ ];
355
+ exports.CORE_ACTION_SYNONYMS = Object.fromEntries(CORE_VOCABULARY_SPELLINGS.map((name) => [name, ACTION_ALIASES[name]]));
356
+ /** Canonical actions that go through the chat-management gate. */
357
+ const MANAGE_ACTIONS = new Set([
358
+ "createGroup", "addMembers", "removeMember",
359
+ "promoteAdmin", "demoteAdmin", "transferOwnership", "inviteLink",
360
+ ]);
250
361
  function parseOptionalThreadId(value) {
251
362
  if (typeof value === "number") {
252
363
  return Number.isFinite(value) ? Math.trunc(value) : undefined;
@@ -285,9 +396,7 @@ function parseOptionalThreadId(value) {
285
396
  * the caller degrade instead of throwing.
286
397
  */
287
398
  function resolveAgentDirForMedia(cfg) {
288
- const stateDir = typeof process.env.OPENCLAW_STATE_DIR === "string" && process.env.OPENCLAW_STATE_DIR.trim()
289
- ? process.env.OPENCLAW_STATE_DIR.trim()
290
- : node_path_1.default.join(node_os_1.default.homedir(), ".openclaw");
399
+ const stateDir = (0, state_dir_1.resolveStateDir)();
291
400
  const configuredId = cfg?.agents?.defaults?.id;
292
401
  const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
293
402
  const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
@@ -451,6 +560,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
451
560
  "Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
452
561
  "Use the `channel-info` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id. Name the chat with `chatId` and do not pass `target`: core refuses it for this action, and the descriptive spelling `chatInfo` is not callable from this tool at all.",
453
562
  "Use the `thread-list` action to list a forum's topics by name (optional `query` narrows by title); that is where a `threadId` comes from when someone names a topic instead of quoting a message in it. Name the chat with `chatId` and do not pass `target` — core refuses it for this action. `topics` is the same call under a name core does not know, and is only reachable through the gateway RPC.",
563
+ "Name the chat for `read` with `target`, never `chatId`: `read` is in core's own vocabulary, so core resolves the destination itself and reads only `to`/`target` — `chatId` is silently ignored and the call is refused as targetless. The chat-shaped reads next to it (`thread-list`, `channel-info`, `member-info`) are the opposite, because core does not know them; that asymmetry is core's, not a typo, and it cost 745 refused reads in the week before 2026-09-04.",
454
564
  "Pass that `threadId` to `read` as well: without it a forum read returns every topic interleaved rather than the one that was asked about.",
455
565
  "Use the `download-file` action to fetch the attachment on a message `read` reported. Name the chat with `chatId` and the message with `messageId`; do not pass `target` — core refuses it for this action: `mode: \"read\"` returns a description of an image or a transcript of a voice note, `\"file\"` returns a path to reuse, `\"both\"` (default) returns both. `read` only says an attachment exists; this is what brings it.",
456
566
  "Use the `channel-list` action to find out which group chats this account is actually in — including ones nobody has configured yet. It reports id, title and type only, never direct chats, and only when the account enables `discoverChats`.",
@@ -550,6 +660,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
550
660
  const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
551
661
  await gram.start();
552
662
  runtimes.set(accountId, gram);
663
+ (0, system_notice_1.rememberOperatorIds)(accountId, resolveAccountOperatorIds(cfg, accountId));
664
+ // Область отправки — туда же и по той же причине: в `outbound.*`
665
+ // конфига нет, а барьер нужен и на пути доставки ядра (A5-12).
666
+ (0, send_scope_1.rememberSendScope)(accountId, resolveAccountSendChats(cfg, accountId));
667
+ warnAboutHandleAllowlistEntries(cfg, accountId);
553
668
  const pairing = (0, channel_pairing_1.createChannelPairingController)({
554
669
  // The controller only reads core.channel.pairing, but its parameter is typed
555
670
  // as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
@@ -1032,6 +1147,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1032
1147
  });
1033
1148
  return;
1034
1149
  }
1150
+ // Ядро подклеивает свою телеметрию к полезной нагрузке
1151
+ // хода, и сюда она приходит тем же путём, что ответ.
1152
+ // Проверка стояла только в `outbound.sendText`, то есть
1153
+ // класс инцидента 30.08–01.09 был закрыт для рассылок и
1154
+ // открыт для обычного ответа на упоминание (A5-10).
1155
+ const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
1156
+ targetKind: "group",
1157
+ text: visibleText,
1158
+ });
1159
+ if (groupNotice) {
1160
+ log?.warn?.("clawgram suppressing system notice in group reply", {
1161
+ accountId,
1162
+ chatId: normalized.chatId,
1163
+ messageId: normalized.messageId,
1164
+ noticeKind: groupNotice,
1165
+ textLength: visibleText.length,
1166
+ });
1167
+ return;
1168
+ }
1035
1169
  const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
1036
1170
  const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
1037
1171
  accountId: route.accountId ?? accountId,
@@ -1085,9 +1219,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1085
1219
  // `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
1086
1220
  // spoken words are kept — a synthesis that did not happen
1087
1221
  // should degrade to readable text, not to markup.
1088
- const visibleFallbackText = fallbackText
1222
+ const rawFallback = fallbackText
1089
1223
  ? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
1090
1224
  : "";
1225
+ // Тот же фильтр и здесь: последняя реплика в стенограмме
1226
+ // вполне может оказаться именно уведомлением об ошибке.
1227
+ const fallbackNotice = rawFallback
1228
+ ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
1229
+ : undefined;
1230
+ if (fallbackNotice) {
1231
+ log?.warn?.("clawgram suppressing system notice in transcript fallback", {
1232
+ accountId,
1233
+ chatId: normalized.chatId,
1234
+ messageId: normalized.messageId,
1235
+ noticeKind: fallbackNotice,
1236
+ textLength: rawFallback.length,
1237
+ });
1238
+ }
1239
+ const visibleFallbackText = fallbackNotice ? "" : rawFallback;
1091
1240
  if (!visibleFallbackText) {
1092
1241
  if (fallbackText) {
1093
1242
  log?.info?.("clawgram skipping silent transcript fallback", {
@@ -1569,10 +1718,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1569
1718
  // Both count, because a rehearsal flag that is silently ignored puts
1570
1719
  // a real message in a real chat — twice, so far (2.13.1).
1571
1720
  const dryRun = (0, helpers_1.resolveDryRun)(dryRunFlag, params);
1721
+ // Every branch below compares the canonical name, so a spelling is
1722
+ // resolved once, here, and `ACTION_ALIASES` is the only place that
1723
+ // decides what a name means. An unknown name stays itself and falls
1724
+ // through to the unsupported-action error, as before.
1725
+ const canonical = canonicalAction(action);
1572
1726
  // `read` is what OpenClaw core dispatches (`openclaw message read`,
1573
- // MCP `messages_read`). `list` is accepted as a synonym so a caller that
1574
- // guessed the other obvious name is not silently refused.
1575
- if (action === "read" || action === "list") {
1727
+ // MCP `messages_read`); `list` resolves to it too.
1728
+ if (canonical === "read") {
1576
1729
  const listParams = (0, history_1.parseListMessagesParams)(params);
1577
1730
  const listAccountId = resolveRuntimeAccountId(cfg, accountId);
1578
1731
  if (!listAccountId) {
@@ -1621,9 +1774,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1621
1774
  // channel and unreachable to the agent. Same `readChats` scope as
1622
1775
  // history: this must not become a way to pull bytes out of a chat the
1623
1776
  // account was never allowed to read.
1624
- if (action === "fetch-media" || action === "fetchMedia" ||
1625
- action === "download-media" || action === "downloadMedia" ||
1626
- action === "getMedia" || action === "download-file") {
1777
+ if (canonical === "fetch-media") {
1627
1778
  const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
1628
1779
  const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
1629
1780
  if (!fetchAccountId) {
@@ -1664,11 +1815,21 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1664
1815
  // the shared directory is keyed by chat and message, and deleting
1665
1816
  // that path would pull the file out from under an earlier `both`
1666
1817
  // fetch of the same message that handed the caller a path.
1667
- const sharedFetchDir = node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-fetched");
1818
+ // Не общий /tmp: там файлы видит каждый локальный пользователь, а на
1819
+ // этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
1820
+ // принадлежит агенту; если он не задан, остаётся /tmp — но права
1821
+ // 0700/0600 ставятся в любом случае (A5-13).
1822
+ // Каталог состояния принадлежит агенту; при явно заданном
1823
+ // OPENCLAW_STATE_DIR вложения не покидают его.
1824
+ const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
1825
+ ? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
1826
+ : node_os_1.default.tmpdir();
1827
+ const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
1668
1828
  let fetchDir = sharedFetchDir;
1669
1829
  if (fetchParams.mode === "read") {
1670
- const { mkdtemp } = await import("node:fs/promises");
1671
- fetchDir = await mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-media-"));
1830
+ const { mkdtemp, mkdir } = await import("node:fs/promises");
1831
+ await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
1832
+ fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
1672
1833
  }
1673
1834
  else {
1674
1835
  await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
@@ -1775,7 +1936,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1775
1936
  // Membership is a read, so the same `readChats` scope that gates history
1776
1937
  // gates it too: this cannot become a way to enumerate chats the account
1777
1938
  // was never allowed to read.
1778
- if (action === "participants" || action === "members" || action === "member-info") {
1939
+ if (canonical === "participants") {
1779
1940
  const participantsParams = (0, history_1.parseListParticipantsParams)(params);
1780
1941
  const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
1781
1942
  if (!participantsAccountId) {
@@ -1815,7 +1976,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1815
1976
  // id could only be lifted off an inbound message — so a topic nobody had
1816
1977
  // written in yet was unreachable, and one named in words was unfindable.
1817
1978
  // Titles say what a chat is working on, so the read scope gates them.
1818
- if (action === "topics" || action === "forumTopics" || action === "thread-list") {
1979
+ if (canonical === "topics") {
1819
1980
  const topicsParams = (0, topics_1.parseTopicsParams)(params);
1820
1981
  const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
1821
1982
  if (!topicsAccountId) {
@@ -1852,7 +2013,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1852
2013
  // Which chats this account is in. Not gated by `readChats` — the whole
1853
2014
  // point is to find chats that are not in it yet — so it has a gate of
1854
2015
  // its own, is metadata only, and never reports direct chats.
1855
- if (action === "dialogs" || action === "chats" || action === "channel-list") {
2016
+ if (canonical === "dialogs") {
1856
2017
  const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
1857
2018
  const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
1858
2019
  if (!dialogsAccountId) {
@@ -1888,7 +2049,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1888
2049
  // Where this account was recently added, and by whom. Reading the journal
1889
2050
  // has no scope check of its own: it only ever contains chats this account
1890
2051
  // was put into, which is exactly what the caller is allowed to learn.
1891
- if (action === "joins") {
2052
+ if (canonical === "joins") {
1892
2053
  const joinsParams = (0, joins_1.parseJoinsParams)(params);
1893
2054
  const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
1894
2055
  if (!joinsAccountId) {
@@ -1912,8 +2073,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1912
2073
  // Describing a chat is a read, so the same `readChats` scope that gates
1913
2074
  // history gates it too — this must not become a way to learn the title
1914
2075
  // and size of a chat the account was never allowed to read.
1915
- if (action === "chatInfo" || action === "getChatInfo" || action === "channel-info"
1916
- || action === "chatMetadata" || action === "getChatMetadata") {
2076
+ if (canonical === "chatInfo") {
1917
2077
  const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
1918
2078
  const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
1919
2079
  if (!chatInfoAccountId) {
@@ -1949,12 +2109,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1949
2109
  // A reaction is an outbound act on someone else's message, so it is
1950
2110
  // gated like sending rather than like reading — and it respects
1951
2111
  // `dryRun`, which reading does not need to.
1952
- if (action === "react") {
2112
+ if (canonical === "react") {
1953
2113
  const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
1954
2114
  const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
1955
2115
  if (!reactionAccountId) {
1956
2116
  throw new Error("clawgram: no configured account found");
1957
2117
  }
2118
+ // Реакция — видимое действие от имени владельца в чужом чате, и
2119
+ // адресуется она так же, как сообщение: та же область (A5-12).
2120
+ if (!(0, send_scope_1.isChatSendable)(reactionParams.target, resolveAccountSendChats(cfg, reactionAccountId))) {
2121
+ refuseOutboundOutsideScope("react", reactionAccountId, String(reactionParams.target));
2122
+ }
1958
2123
  actionLog.info("clawgram handleAction react", {
1959
2124
  accountId: reactionAccountId,
1960
2125
  dryRun: dryRun === true,
@@ -1999,7 +2164,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1999
2164
  // returns after the gate so a dry run exercises the same refusals a
2000
2165
  // real call would hit. People's ids stay out of the logs throughout;
2001
2166
  // the JSON result carries them to the caller, the journal does not.
2002
- const manageAction = MANAGE_ACTION_ALIASES[action];
2167
+ const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
2003
2168
  if (manageAction) {
2004
2169
  const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
2005
2170
  if (!manageAccountId) {
@@ -2210,13 +2375,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2210
2375
  // and arrives from older callers. A plain `send` carrying a file lands
2211
2376
  // here too — `openclaw message send --media` does exactly that, and
2212
2377
  // routing it to the text path dropped the file without a word.
2213
- if (action === "upload-file" || action === "sendAttachment" || (action === "send" && attachedFile)) {
2378
+ if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
2214
2379
  const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
2215
2380
  const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
2216
2381
  const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
2217
2382
  if (!uploadAccountId) {
2218
2383
  throw new Error("clawgram: no configured account found");
2219
2384
  }
2385
+ // Та же граница, что у `send`: файл наружу — такое же исходящее.
2386
+ if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
2387
+ refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
2388
+ }
2220
2389
  const file = attachedFile;
2221
2390
  if (!file) {
2222
2391
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
@@ -2306,6 +2475,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2306
2475
  if (!resolvedAccountId) {
2307
2476
  throw new Error("clawgram: no configured account found");
2308
2477
  }
2478
+ // Проверка ПОСЛЕ резолва аккаунта и ДО любой доставки: область задаётся
2479
+ // на аккаунт, а отказ должен случиться раньше, чем цель разрешена в
2480
+ // Telegram-сущность — resolve сам по себе виден собеседнику (A5-12).
2481
+ if (!(0, send_scope_1.isChatSendable)(to, resolveAccountSendChats(cfg, resolvedAccountId))) {
2482
+ refuseOutboundOutsideScope("send", resolvedAccountId, to);
2483
+ }
2309
2484
  const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
2310
2485
  const currentMessageId = toolContext?.currentMessageId;
2311
2486
  const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
@@ -2459,7 +2634,23 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2459
2634
  if (!raw) {
2460
2635
  return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
2461
2636
  }
2462
- return { ok: true, to: (0, helpers_1.normalizeOutboundTarget)(raw) };
2637
+ const target = (0, helpers_1.normalizeOutboundTarget)(raw);
2638
+ // Тот же барьер, что у `handleAction`: доставка ядра (`--deliver`,
2639
+ // анонсы субагентов) идёт этим путём и мимо той проверки. Отказ
2640
+ // здесь возвращается результатом, а не броском: бросок в этом хуке
2641
+ // роняет весь gateway (грабли 06.08.2026, выше).
2642
+ if (!(0, send_scope_1.isChatSendable)(target, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
2643
+ const reason = (0, send_scope_1.isPhoneNumberTarget)(target)
2644
+ ? "phone-number target"
2645
+ : "chat outside send scope";
2646
+ actionLog.warn("clawgram outbound resolveTarget refused", {
2647
+ accountId: ctx.accountId,
2648
+ target,
2649
+ reason,
2650
+ });
2651
+ return { ok: false, error: new Error(`clawgram: not-allowed-chat ${target}`) };
2652
+ }
2653
+ return { ok: true, to: target };
2463
2654
  }
2464
2655
  catch (err) {
2465
2656
  return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
@@ -2495,6 +2686,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2495
2686
  const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
2496
2687
  targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
2497
2688
  text: ctx.text,
2689
+ to: ctx.to,
2690
+ operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
2498
2691
  });
2499
2692
  if (suppressedNotice) {
2500
2693
  actionLog.warn("clawgram suppressing system notice in group", {
@@ -2584,6 +2777,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2584
2777
  if (!file) {
2585
2778
  throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
2586
2779
  }
2780
+ // Ниже — проверки, которые у `sendText` были, а здесь не было ни
2781
+ // одной: путь доставки медиа писался отдельно и обзавёлся только
2782
+ // своими границами (находка A6-18).
2783
+ const mediaTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
2784
+ // Область отправки: файл наружу — такое же исходящее, как текст.
2785
+ // `resolveTarget` ядро зовёт не на каждом пути, поэтому проверяем и тут.
2786
+ if (!(0, send_scope_1.isChatSendable)(mediaTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
2787
+ actionLog.warn("clawgram outbound sendMedia refused", {
2788
+ accountId: ctx.accountId,
2789
+ target: mediaTarget,
2790
+ reason: (0, send_scope_1.isPhoneNumberTarget)(mediaTarget) ? "phone-number target" : "chat outside send scope",
2791
+ });
2792
+ return { skipped: "not-allowed" };
2793
+ }
2794
+ // Молчаливый ответ: подпись с токеном молчания означает «ничего не
2795
+ // говорить», и отправлять файл с ним в подписи — тем более.
2796
+ const mediaCaption = ctx.caption ?? ctx.text;
2797
+ if (mediaCaption?.trim() && (0, helpers_1.isSilentReplyText)(mediaCaption)) {
2798
+ actionLog.info("clawgram suppressing silent outbound media", {
2799
+ accountId: ctx.accountId,
2800
+ rawTo: ctx.to,
2801
+ });
2802
+ return { skipped: "silent" };
2803
+ }
2804
+ // Обращение в группе — то же, что у текста: адрес принадлежит
2805
+ // конкретному входящему сообщению, а не последнему говорившему.
2806
+ const mediaReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
2807
+ accountId: ctx.accountId,
2808
+ chatId: ctx.to,
2809
+ replyToId: ctx.replyToId,
2810
+ });
2811
+ // Чего здесь НЕТ намеренно:
2812
+ // — подавление эха хода (`hadTurnSendJustNow`): у текста дубль стоит
2813
+ // лишнего сообщения, а у медиа отказ стоит потерянного файла —
2814
+ // картинку агент готовил, и второй раз она не появится;
2815
+ // — подавление служебных сообщений ядра в группах: они текстовые,
2816
+ // медиа-доставка ими не бывает.
2587
2817
  const messageThreadId = parseOptionalThreadId(ctx.threadId);
2588
2818
  // Same normalization `sendText` does two functions up. Without it the
2589
2819
  // channel prefix reaches peer resolution and the send throws — which is
@@ -2593,7 +2823,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2593
2823
  const sent = await gram.sendMedia({
2594
2824
  target,
2595
2825
  file,
2596
- caption: ctx.caption ?? ctx.text,
2826
+ // Подпись получает то же обращение, что и текстовый ответ.
2827
+ caption: mediaCaption
2828
+ ? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
2829
+ : mediaCaption,
2597
2830
  // Captions follow the account reply format like every other reply:
2598
2831
  // they are the same agent prose, just attached to a file (2.15.0).
2599
2832
  parseMode: gram.replyParseMode,