clawgram 2.21.1 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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");
@@ -551,6 +660,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
551
660
  const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
552
661
  await gram.start();
553
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);
554
668
  const pairing = (0, channel_pairing_1.createChannelPairingController)({
555
669
  // The controller only reads core.channel.pairing, but its parameter is typed
556
670
  // as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
@@ -1033,6 +1147,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1033
1147
  });
1034
1148
  return;
1035
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
+ }
1036
1169
  const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
1037
1170
  const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
1038
1171
  accountId: route.accountId ?? accountId,
@@ -1086,9 +1219,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1086
1219
  // `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
1087
1220
  // spoken words are kept — a synthesis that did not happen
1088
1221
  // should degrade to readable text, not to markup.
1089
- const visibleFallbackText = fallbackText
1222
+ const rawFallback = fallbackText
1090
1223
  ? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
1091
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;
1092
1240
  if (!visibleFallbackText) {
1093
1241
  if (fallbackText) {
1094
1242
  log?.info?.("clawgram skipping silent transcript fallback", {
@@ -1570,10 +1718,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1570
1718
  // Both count, because a rehearsal flag that is silently ignored puts
1571
1719
  // a real message in a real chat — twice, so far (2.13.1).
1572
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);
1573
1726
  // `read` is what OpenClaw core dispatches (`openclaw message read`,
1574
- // MCP `messages_read`). `list` is accepted as a synonym so a caller that
1575
- // guessed the other obvious name is not silently refused.
1576
- if (action === "read" || action === "list") {
1727
+ // MCP `messages_read`); `list` resolves to it too.
1728
+ if (canonical === "read") {
1577
1729
  const listParams = (0, history_1.parseListMessagesParams)(params);
1578
1730
  const listAccountId = resolveRuntimeAccountId(cfg, accountId);
1579
1731
  if (!listAccountId) {
@@ -1622,9 +1774,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1622
1774
  // channel and unreachable to the agent. Same `readChats` scope as
1623
1775
  // history: this must not become a way to pull bytes out of a chat the
1624
1776
  // account was never allowed to read.
1625
- if (action === "fetch-media" || action === "fetchMedia" ||
1626
- action === "download-media" || action === "downloadMedia" ||
1627
- action === "getMedia" || action === "download-file") {
1777
+ if (canonical === "fetch-media") {
1628
1778
  const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
1629
1779
  const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
1630
1780
  if (!fetchAccountId) {
@@ -1665,11 +1815,21 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1665
1815
  // the shared directory is keyed by chat and message, and deleting
1666
1816
  // that path would pull the file out from under an earlier `both`
1667
1817
  // fetch of the same message that handed the caller a path.
1668
- 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");
1669
1828
  let fetchDir = sharedFetchDir;
1670
1829
  if (fetchParams.mode === "read") {
1671
- const { mkdtemp } = await import("node:fs/promises");
1672
- 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-"));
1673
1833
  }
1674
1834
  else {
1675
1835
  await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
@@ -1776,7 +1936,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1776
1936
  // Membership is a read, so the same `readChats` scope that gates history
1777
1937
  // gates it too: this cannot become a way to enumerate chats the account
1778
1938
  // was never allowed to read.
1779
- if (action === "participants" || action === "members" || action === "member-info") {
1939
+ if (canonical === "participants") {
1780
1940
  const participantsParams = (0, history_1.parseListParticipantsParams)(params);
1781
1941
  const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
1782
1942
  if (!participantsAccountId) {
@@ -1816,7 +1976,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1816
1976
  // id could only be lifted off an inbound message — so a topic nobody had
1817
1977
  // written in yet was unreachable, and one named in words was unfindable.
1818
1978
  // Titles say what a chat is working on, so the read scope gates them.
1819
- if (action === "topics" || action === "forumTopics" || action === "thread-list") {
1979
+ if (canonical === "topics") {
1820
1980
  const topicsParams = (0, topics_1.parseTopicsParams)(params);
1821
1981
  const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
1822
1982
  if (!topicsAccountId) {
@@ -1853,7 +2013,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1853
2013
  // Which chats this account is in. Not gated by `readChats` — the whole
1854
2014
  // point is to find chats that are not in it yet — so it has a gate of
1855
2015
  // its own, is metadata only, and never reports direct chats.
1856
- if (action === "dialogs" || action === "chats" || action === "channel-list") {
2016
+ if (canonical === "dialogs") {
1857
2017
  const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
1858
2018
  const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
1859
2019
  if (!dialogsAccountId) {
@@ -1889,7 +2049,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1889
2049
  // Where this account was recently added, and by whom. Reading the journal
1890
2050
  // has no scope check of its own: it only ever contains chats this account
1891
2051
  // was put into, which is exactly what the caller is allowed to learn.
1892
- if (action === "joins") {
2052
+ if (canonical === "joins") {
1893
2053
  const joinsParams = (0, joins_1.parseJoinsParams)(params);
1894
2054
  const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
1895
2055
  if (!joinsAccountId) {
@@ -1913,8 +2073,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1913
2073
  // Describing a chat is a read, so the same `readChats` scope that gates
1914
2074
  // history gates it too — this must not become a way to learn the title
1915
2075
  // and size of a chat the account was never allowed to read.
1916
- if (action === "chatInfo" || action === "getChatInfo" || action === "channel-info"
1917
- || action === "chatMetadata" || action === "getChatMetadata") {
2076
+ if (canonical === "chatInfo") {
1918
2077
  const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
1919
2078
  const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
1920
2079
  if (!chatInfoAccountId) {
@@ -1950,12 +2109,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1950
2109
  // A reaction is an outbound act on someone else's message, so it is
1951
2110
  // gated like sending rather than like reading — and it respects
1952
2111
  // `dryRun`, which reading does not need to.
1953
- if (action === "react") {
2112
+ if (canonical === "react") {
1954
2113
  const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
1955
2114
  const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
1956
2115
  if (!reactionAccountId) {
1957
2116
  throw new Error("clawgram: no configured account found");
1958
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
+ }
1959
2123
  actionLog.info("clawgram handleAction react", {
1960
2124
  accountId: reactionAccountId,
1961
2125
  dryRun: dryRun === true,
@@ -2000,7 +2164,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2000
2164
  // returns after the gate so a dry run exercises the same refusals a
2001
2165
  // real call would hit. People's ids stay out of the logs throughout;
2002
2166
  // the JSON result carries them to the caller, the journal does not.
2003
- const manageAction = MANAGE_ACTION_ALIASES[action];
2167
+ const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
2004
2168
  if (manageAction) {
2005
2169
  const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
2006
2170
  if (!manageAccountId) {
@@ -2211,13 +2375,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2211
2375
  // and arrives from older callers. A plain `send` carrying a file lands
2212
2376
  // here too — `openclaw message send --media` does exactly that, and
2213
2377
  // routing it to the text path dropped the file without a word.
2214
- if (action === "upload-file" || action === "sendAttachment" || (action === "send" && attachedFile)) {
2378
+ if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
2215
2379
  const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
2216
2380
  const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
2217
2381
  const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
2218
2382
  if (!uploadAccountId) {
2219
2383
  throw new Error("clawgram: no configured account found");
2220
2384
  }
2385
+ // Та же граница, что у `send`: файл наружу — такое же исходящее.
2386
+ if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
2387
+ refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
2388
+ }
2221
2389
  const file = attachedFile;
2222
2390
  if (!file) {
2223
2391
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
@@ -2307,6 +2475,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2307
2475
  if (!resolvedAccountId) {
2308
2476
  throw new Error("clawgram: no configured account found");
2309
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
+ }
2310
2484
  const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
2311
2485
  const currentMessageId = toolContext?.currentMessageId;
2312
2486
  const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
@@ -2460,7 +2634,23 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2460
2634
  if (!raw) {
2461
2635
  return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
2462
2636
  }
2463
- 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 };
2464
2654
  }
2465
2655
  catch (err) {
2466
2656
  return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
@@ -2496,6 +2686,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2496
2686
  const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
2497
2687
  targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
2498
2688
  text: ctx.text,
2689
+ to: ctx.to,
2690
+ operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
2499
2691
  });
2500
2692
  if (suppressedNotice) {
2501
2693
  actionLog.warn("clawgram suppressing system notice in group", {
@@ -2585,6 +2777,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2585
2777
  if (!file) {
2586
2778
  throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
2587
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
+ // медиа-доставка ими не бывает.
2588
2817
  const messageThreadId = parseOptionalThreadId(ctx.threadId);
2589
2818
  // Same normalization `sendText` does two functions up. Without it the
2590
2819
  // channel prefix reaches peer resolution and the send throws — which is
@@ -2594,7 +2823,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2594
2823
  const sent = await gram.sendMedia({
2595
2824
  target,
2596
2825
  file,
2597
- caption: ctx.caption ?? ctx.text,
2826
+ // Подпись получает то же обращение, что и текстовый ответ.
2827
+ caption: mediaCaption
2828
+ ? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
2829
+ : mediaCaption,
2598
2830
  // Captions follow the account reply format like every other reply:
2599
2831
  // they are the same agent prose, just attached to a file (2.15.0).
2600
2832
  parseMode: gram.replyParseMode,