clawgram 2.20.2 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,7 +94,7 @@ Follow the steps in the console
94
94
  ```bash
95
95
  Starting Clawgram authorization...
96
96
  Please enter your apiId: 12345678
97
- Please enter your apiHash: c4b9c0fde16342afe52907847df27596
97
+ Please enter your apiHash: EXAMPLE0123456789abcdef01234567
98
98
  [2026-05-10T16:01:24.570] [INFO] - [Running gramJS version 2.26.21]
99
99
  [2026-05-10T16:01:24.578] [INFO] - [Connecting to x.x.x.x:80/TCPFull...]
100
100
  [2026-05-10T16:01:25.804] [INFO] - [Connection to x.x.x.x:80/TCPFull complete!]
@@ -191,6 +191,23 @@ config watcher and restart only this channel (a few seconds of MTProto
191
191
  reconnect), not the whole Gateway.
192
192
 
193
193
 
194
+ ## Upgrading to 2.21.0
195
+
196
+ One config key changed meaning: **`allowFrom: []` now denies everyone.** It used
197
+ to resolve to the wildcard, so emptying the list to shut an account off opened it
198
+ to every Telegram user instead — the opposite of the request, and the opposite of
199
+ `readChats: []` and `manageChats: []`, which have always been documented as deny.
200
+
201
+ An absent key still means everyone, so an install that never set `allowFrom` is
202
+ unaffected; only an explicitly empty list changes. If you relied on `[]` to admit
203
+ everyone, write `["*"]`. The account logs `clawgram allowFrom is empty: no direct
204
+ message will be accepted` at start, so the new state is never silent.
205
+
206
+ This is a behaviour change to a config key and by the usual rule it would cost a
207
+ major bump. It ships as a minor deliberately: the plugin currently has one
208
+ operator, the value is almost certainly nowhere, and the warning makes the case
209
+ loud where it does occur.
210
+
194
211
  ## Configuration Reference
195
212
 
196
213
  ### JSON Reference
@@ -231,7 +248,7 @@ reconnect), not the whole Gateway.
231
248
  | `apiId` | number | required | Telegram API ID |
232
249
  | `apiHash` | string | required | Telegram API hash |
233
250
  | `sessionString` | string | `""` | Authenticated StringSession |
234
- | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames for direct messages only |
251
+ | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames for direct messages only. Three states: absent means everyone, `[]` denies everyone (a warning is logged at account start), a list allows those senders |
235
252
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
236
253
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
237
254
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
@@ -245,7 +262,7 @@ Group config fields:
245
262
  |---|---|---|---|
246
263
  | `enabled` | boolean | `true` | Enables or disables replies in the group |
247
264
  | `groupPolicy` | `"open"` \| `"mention"` \| `"tag"` | `"mention"` | What wakes the agent here — see [What wakes the agent in a group](#what-wakes-the-agent-in-a-group) |
248
- | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group |
265
+ | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group. `[]` denies everyone, same as the account-level key |
249
266
  | `tools` | object | unset | `{ allow?, alsoAllow?, deny? }` — tool policy for this group; see [Per-group tools, skills and system prompt](#per-group-tools-skills-and-system-prompt) |
250
267
  | `toolsBySender` | object | unset | Per-sender tool policy inside this group, keys `id:<id>`, `username:<handle>`, `name:<display>` or `*` |
251
268
  | `skills` | string[] | unset | Skill allowlist for this group; `[]` = no skills here, unset = the agent's skills |
@@ -807,7 +824,7 @@ that means in practice, and what the code does about it:
807
824
  | `apiHash`, `sessionString` | `openclaw.json`, or a secret store | Written there by `--auth`. Never logged. Since 2.1.0 the session string is not printed after login either — only shown, behind an explicit warning, if you decline the automatic config write. Since 2.2.0 both accept a **SecretRef** instead of a literal, so the credential need not sit in the config file at all |
808
825
  | Proxy password | `accounts.*.proxy.password`, or a secret store | Also accepts a SecretRef since 2.2.0. Marked `sensitive` in `uiHints`; diagnostics say `socks4`/`socks5` and nothing more. An invalid proxy fails the account rather than falling back to a direct connection, which would leak the host IP to Telegram |
809
826
  | Message bodies | channel logs | **Not logged.** Outbound sends record recipient, ids and `textLength`. Until 2.1.0 the full outbound text was written to the channel log — if you ran 2.0.x, treat those journal entries as containing private correspondence |
810
- | Read scope | `accounts.*.readChats` | History, membership and attachment fetches are confined to the listed chats. Absent means no restriction; an empty array denies everything |
827
+ | Read scope | `accounts.*.readChats` | History, membership and attachment fetches are confined to the listed chats. Absent means no restriction; an empty array denies everything. Telegram's own service chat (`777000`, where login codes arrive) is refused unconditionally, including under a wildcard |
811
828
  | Manage scope | `accounts.*.manageChats` | Creating groups, changing membership, admin rights, ownership and invite links are confined to the listed chats — and **off entirely** when the key is absent or empty (opposite default to `readChats`, because these actions change chats rather than read them) |
812
829
  | 2FA password | `accounts.*.twoFaPassword`, or a secret store | Read only by `transferOwnership`, exchanged for an SRP proof in-process. Accepts a SecretRef since 2.12.0; `sensitive` in `uiHints`; on the forbidden-log-keys list the static tests enforce |
813
830
  | Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions |
package/dist/channel.js CHANGED
@@ -63,6 +63,7 @@ const inbound_envelope_1 = require("openclaw/plugin-sdk/inbound-envelope");
63
63
  const inbound_reply_dispatch_1 = require("openclaw/plugin-sdk/inbound-reply-dispatch");
64
64
  const channel_pairing_1 = require("openclaw/plugin-sdk/channel-pairing");
65
65
  const events_1 = require("telegram/events");
66
+ const constants_1 = require("./constants");
66
67
  const gramjs_client_1 = require("./gramjs-client");
67
68
  const normalize_1 = require("./normalize");
68
69
  const history_1 = require("./history");
@@ -81,7 +82,7 @@ const group_reply_address_1 = require("./group-reply-address");
81
82
  const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
82
83
  const helpers_1 = require("./helpers");
83
84
  const proxy_config_1 = require("./proxy-config");
84
- const constants_1 = require("./constants");
85
+ const constants_2 = require("./constants");
85
86
  const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
86
87
  /** Reads the configured reaction level for an account, tolerating a missing config. */
87
88
  function readAccountReactionLevel(cfg, accountId) {
@@ -89,7 +90,7 @@ function readAccountReactionLevel(cfg, accountId) {
89
90
  if (!resolvedAccountId) {
90
91
  return undefined;
91
92
  }
92
- return cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
93
+ return cfg?.channels?.[constants_2.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
93
94
  }
94
95
  /**
95
96
  * Model ref for the emoji pick, when the account names one.
@@ -104,7 +105,7 @@ function readAccountReactionModel(cfg, accountId) {
104
105
  if (!resolvedAccountId) {
105
106
  return undefined;
106
107
  }
107
- const raw = cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionModel;
108
+ const raw = cfg?.channels?.[constants_2.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionModel;
108
109
  return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
109
110
  }
110
111
  /**
@@ -192,8 +193,10 @@ function readAccountManageChats(account) {
192
193
  * from the same caller, on the same chat.
193
194
  *
194
195
  * Every name on the right maps to core target mode `"none"` except
195
- * `channel-info`, which is `"to"` and therefore arrives with the chat in
196
- * `params.to` a spelling all of these parsers already accept.
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.
197
200
  */
198
201
  exports.CORE_ACTION_SYNONYMS = {
199
202
  "thread-list": "topics",
@@ -480,8 +483,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
480
483
  apiId: Number(account?.apiId),
481
484
  apiHash: (0, secret_refs_1.readSecretInput)(account?.apiHash),
482
485
  sessionString: (0, secret_refs_1.readSecretInput)(account?.sessionString),
483
- allowFrom: (0, helpers_1.resolveAllowFrom)(account?.allowFrom),
484
- groups: (0, helpers_1.resolveGroups)(account?.groups),
486
+ ...(0, helpers_1.resolveAccountScopes)(cfg, accountId),
485
487
  readChats: readAccountReadChats(account),
486
488
  enabled: account?.enabled,
487
489
  accountId,
@@ -505,6 +507,16 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
505
507
  if (!channelRuntime) {
506
508
  throw new Error("clawgram: channelRuntime is required");
507
509
  }
510
+ // An empty allowlist denies everyone (2.21.0). That is the right
511
+ // default for a scope, but "the agent answers nobody" is indis-
512
+ // tinguishable from "the channel is broken" in a log, so say it out
513
+ // loud once per account start.
514
+ if (account.allowFrom.length === 0) {
515
+ log?.warn?.("clawgram allowFrom is empty: no direct message will be accepted", {
516
+ accountId,
517
+ hint: 'set allowFrom to ["*"] to accept everyone, or list the senders',
518
+ });
519
+ }
508
520
  if (runtimes.has(accountId)) {
509
521
  log?.warn?.("clawgram stale runtime detected, reconnecting", { accountId });
510
522
  await runtimes.get(accountId)?.stop().catch(() => undefined);
@@ -640,13 +652,41 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
640
652
  return;
641
653
  }
642
654
  let text = normalized.text?.trim();
655
+ // Whether this sender may reach the agent at all — decided before
656
+ // the attachment is fetched.
657
+ //
658
+ // Reading an attachment downloads up to 25 MB and then spends a
659
+ // transcription or vision call on it. That used to happen for
660
+ // every photo and voice note from anyone in any group the account
661
+ // sits in, and only afterwards was the sender checked against
662
+ // `allowFrom`. A stranger could therefore spend the owner's model
663
+ // budget at will. None of these checks depend on the message text,
664
+ // so they cost nothing to run first.
665
+ const inboundSenderId = normalized.senderId ?? normalized.chatId;
666
+ const inboundScopes = (0, helpers_1.resolveAccountScopes)(cfg, accountId);
667
+ const inboundGroupConfig = normalized.chatType === "group"
668
+ ? (0, helpers_1.resolveGroupConfig)(inboundScopes.groups, normalized.chatId)
669
+ : undefined;
670
+ const senderMayReachAgent = normalized.chatType === "group"
671
+ ? Boolean(inboundGroupConfig
672
+ && inboundGroupConfig.enabled !== false
673
+ && (0, helpers_1.isSenderAllowed)({
674
+ allowFrom: inboundGroupConfig.allowFrom,
675
+ senderId: inboundSenderId,
676
+ senderUsername: normalized.senderUsername,
677
+ }))
678
+ : (0, helpers_1.isSenderAllowed)({
679
+ allowFrom: inboundScopes.allowFrom,
680
+ senderId: inboundSenderId,
681
+ senderUsername: normalized.senderUsername,
682
+ });
643
683
  // An attachment carries no text of its own, and dropping it as
644
684
  // "empty" is how the assistant used to go silent on being spoken
645
685
  // to or shown something. Read it into the body instead: for a
646
686
  // voice note and a screenshot alike, the attachment *is* the
647
687
  // message. A caption is kept and the reading appended, because
648
688
  // "look at this" plus the picture is one thought, not two.
649
- const attachment = await readInboundAttachment({
689
+ const attachment = senderMayReachAgent ? await readInboundAttachment({
650
690
  gram,
651
691
  event,
652
692
  cfg,
@@ -655,12 +695,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
655
695
  accountId,
656
696
  chatId: normalized.chatId,
657
697
  messageId: normalized.messageId,
658
- });
698
+ }) : undefined;
659
699
  if (attachment) {
660
700
  const marker = attachment.understanding === "transcript" ? "голосовое" : "изображение";
661
701
  const read = `[${marker}] ${attachment.text}`;
662
702
  text = text ? `${text}\n\n${read}` : read;
663
703
  }
704
+ // What the mention gate is allowed to read.
705
+ //
706
+ // A transcript is the sender's own speech, so "Тина, посмотри"
707
+ // said aloud addresses the agent exactly as typing it would. A
708
+ // description is not: it is a vision model reading somebody
709
+ // else's content, and a screenshot of a chat where a third party
710
+ // wrote "@tina_bot" is not an address to her. Feeding the whole
711
+ // body to the gate made every such screenshot wake her up.
712
+ const addressableText = (0, helpers_1.resolveAddressableText)({
713
+ messageText: normalized.text,
714
+ bodyText: text,
715
+ understanding: attachment?.understanding,
716
+ });
664
717
  if (!text) {
665
718
  log?.info?.("clawgram skipping empty inbound text", {
666
719
  accountId,
@@ -671,7 +724,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
671
724
  }
672
725
  const senderId = normalized.senderId ?? normalized.chatId;
673
726
  const isTelegramServiceDirect = normalized.chatType === "direct" &&
674
- (normalized.chatId === "777000" || senderId === "777000");
727
+ (normalized.chatId === constants_1.TELEGRAM_SERVICE_CHAT_ID || senderId === constants_1.TELEGRAM_SERVICE_CHAT_ID);
675
728
  const isSavedMessagesDirect = normalized.chatType === "direct" &&
676
729
  Boolean(selfId) &&
677
730
  normalized.chatId === selfId &&
@@ -734,14 +787,13 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
734
787
  }
735
788
  throw lastError;
736
789
  };
737
- const accountConfig = cfg?.channels?.["clawgram"]?.accounts?.[accountId] ??
738
- cfg?.channels?.["clawgram"] ??
739
- {};
740
- const directAllowFrom = (0, helpers_1.resolveAllowFrom)(accountConfig?.allowFrom ?? account?.allowFrom);
741
- const groups = (0, helpers_1.resolveGroups)(accountConfig?.groups ?? account?.groups);
790
+ // Resolved once, above, before the attachment fetch that depends on
791
+ // the answer — and by the same resolver `resolveAccount` uses, so the
792
+ // gate applied here is the one the account was started with.
793
+ const { allowFrom: directAllowFrom } = inboundScopes;
742
794
  const dmPolicy = "open";
743
795
  if (normalized.chatType === "group") {
744
- const groupConfig = (0, helpers_1.resolveGroupConfig)(groups, normalized.chatId);
796
+ const groupConfig = inboundGroupConfig;
745
797
  if (!groupConfig) {
746
798
  log?.info?.("clawgram skipping group not present in groups config", {
747
799
  accountId,
@@ -793,12 +845,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
793
845
  // almost never. Only the `@` counts, and it is the same fact the
794
846
  // stricter rung of the ladder is named after.
795
847
  const wasMentioned = groupConfig.groupPolicy === "tag"
796
- ? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text, message: rawMessage })
848
+ ? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text: addressableText, message: rawMessage })
797
849
  : (0, helpers_1.hasTelegramMention)({
798
850
  cfg,
799
851
  agentId: route.agentId,
800
852
  selfUsername,
801
- text,
853
+ text: addressableText,
802
854
  message: rawMessage,
803
855
  });
804
856
  // One fetch serves two needs: the reply-to-self gate below and
@@ -810,7 +862,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
810
862
  facts: {
811
863
  canDetectMention: true,
812
864
  wasMentioned,
813
- hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(text),
865
+ hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(addressableText),
814
866
  },
815
867
  policy: {
816
868
  isGroup: true,
@@ -831,7 +883,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
831
883
  wasMentioned,
832
884
  wasReplyToSelf,
833
885
  shouldSkip: mentionDecision.shouldSkip,
834
- text,
886
+ textLength: text.length,
835
887
  });
836
888
  if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
837
889
  log?.info?.("clawgram skipping group message without mention", {
@@ -919,7 +971,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
919
971
  ctx: ctxPayload,
920
972
  updateLastRoute: {
921
973
  sessionKey: route.sessionKey,
922
- channel: constants_1.CHANNEL_ID,
974
+ channel: constants_2.CHANNEL_ID,
923
975
  to: conversationRouteTarget,
924
976
  accountId: route.accountId ?? accountId,
925
977
  },
@@ -962,7 +1014,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
962
1014
  accountId,
963
1015
  chatId: normalized.chatId,
964
1016
  messageId: normalized.messageId,
965
- payloadText: outboundText,
1017
+ payloadTextLength: outboundText.length,
966
1018
  payloadReplyToId: payload.replyToId ?? null,
967
1019
  });
968
1020
  if (!outboundText) {
@@ -1088,7 +1140,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1088
1140
  chatId: normalized.chatId,
1089
1141
  messageId: normalized.messageId,
1090
1142
  routeSessionKey: route.sessionKey,
1091
- fallbackText: visibleFallbackText,
1143
+ fallbackTextLength: visibleFallbackText.length,
1092
1144
  });
1093
1145
  await sendTextToConversation({
1094
1146
  text: (0, helpers_1.prefixReplyTextToAddress)(visibleFallbackText, groupReplyAddress),
@@ -1338,7 +1390,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1338
1390
  },
1339
1391
  },
1340
1392
  messaging: {
1341
- targetPrefixes: [constants_1.CHANNEL_ID, "tguserbot", "telegram", "tg"],
1393
+ targetPrefixes: [constants_2.CHANNEL_ID, "tguserbot", "telegram", "tg"],
1342
1394
  normalizeTarget(raw) {
1343
1395
  const normalized = (0, helpers_1.normalizeOutboundTarget)(raw);
1344
1396
  return normalized || undefined;
@@ -1410,7 +1462,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1410
1462
  return (0, core_1.buildChannelOutboundSessionRoute)({
1411
1463
  cfg: params.cfg,
1412
1464
  agentId: params.agentId,
1413
- channel: constants_1.CHANNEL_ID,
1465
+ channel: constants_2.CHANNEL_ID,
1414
1466
  accountId,
1415
1467
  peer: {
1416
1468
  kind: (0, helpers_1.routeKindFromChatType)(chatType),
@@ -1506,7 +1558,13 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1506
1558
  "download-file": { aliases: ["chatId"] },
1507
1559
  },
1508
1560
  extractToolSend: ({ args }) => (0, tool_send_1.extractToolSend)(args, "sendMessage"),
1509
- handleAction: async ({ action, params, cfg, accountId, dryRun: dryRunFlag, toolContext }) => {
1561
+ handleAction: async ({ action, params, cfg, accountId, dryRun: dryRunFlag, toolContext,
1562
+ // Core scopes every action to the media roots the agent may read, and
1563
+ // bundled channels enforce them. This one used to take `filePath`
1564
+ // verbatim, so a path naming the secret store or the config holding
1565
+ // `sessionString` was uploaded like any attachment.
1566
+ mediaLocalRoots, mediaAccess, }) => {
1567
+ const allowedMediaRoots = mediaLocalRoots ?? mediaAccess?.localRoots;
1510
1568
  // Core passes the flag beside `params`; callers write it inside.
1511
1569
  // Both count, because a rehearsal flag that is silently ignored puts
1512
1570
  // a real message in a real chat — twice, so far (2.13.1).
@@ -2163,6 +2221,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2163
2221
  if (!file) {
2164
2222
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
2165
2223
  }
2224
+ // Before anything else about the message is considered: an
2225
+ // out-of-scope path is refused, not sent and then regretted.
2226
+ (0, media_1.assertLocalMediaWithinRoots)(file, allowedMediaRoots);
2166
2227
  const captionText = (0, helpers_1.readMessageText)(params) || ((0, param_readers_1.readStringParam)(params, "caption") ?? "");
2167
2228
  // A caption is optional, but the silent-reply sentinel must never
2168
2229
  // reach Telegram as one — same reasoning as the `send` path below.
@@ -2266,8 +2327,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2266
2327
  currentMessageId: String(currentMessageId),
2267
2328
  toolContextCurrentChannelId: currentChannelId || null,
2268
2329
  });
2330
+ // A dry run reports the suppression instead of impersonating it: the
2331
+ // caller asked what would happen, and what would happen is nothing.
2269
2332
  return (0, core_1.jsonResult)({
2270
2333
  ok: true,
2334
+ ...(dryRun ? { dryRun: true } : {}),
2271
2335
  suppressedDuplicate: true,
2272
2336
  to,
2273
2337
  accountId: resolvedAccountId,
@@ -2278,7 +2342,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2278
2342
  // `replyToId`, and until 2026-08-10 that fell through to the most
2279
2343
  // recent sender: in an interleaved chat the owner's report went out
2280
2344
  // addressed to a colleague who had asked something else entirely.
2281
- const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
2345
+ // A dry run peeks: consuming the address here left the real send with
2346
+ // no greeting, so a rehearsal silently changed the message that went
2347
+ // out afterwards.
2348
+ const groupReplyAddress = (dryRun ? group_reply_address_1.peekGroupReplyAddress : group_reply_address_1.consumeGroupReplyAddress)({
2282
2349
  accountId: resolvedAccountId,
2283
2350
  chatId: to,
2284
2351
  replyToId: replyToId ?? currentMessageId,
@@ -2497,6 +2564,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2497
2564
  if (!gram) {
2498
2565
  throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2499
2566
  }
2567
+ // Same rule as the action path: a local file outside the declared
2568
+ // roots is refused before anything is uploaded.
2569
+ const outboundRoots = ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots;
2570
+ (0, media_1.assertLocalMediaWithinRoots)(ctx.filePath, outboundRoots);
2571
+ (0, media_1.assertLocalMediaWithinRoots)(ctx.mediaUrl, outboundRoots);
2500
2572
  actionLog.info("clawgram outbound sendMedia", {
2501
2573
  accountId: ctx.accountId,
2502
2574
  rawTo: ctx.to,
package/dist/chat-info.js CHANGED
@@ -19,7 +19,7 @@ exports.parseChatInfoParams = parseChatInfoParams;
19
19
  exports.describeChat = describeChat;
20
20
  const helpers_1 = require("./helpers");
21
21
  function parseChatInfoParams(params, toolContext) {
22
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
22
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
23
23
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
24
24
  if (!target) {
25
25
  throw new Error("clawgram: chatInfo requires a chatId");
package/dist/cli-core.js CHANGED
@@ -10,6 +10,7 @@ const telegram_1 = require("telegram");
10
10
  const sessions_1 = require("telegram/sessions");
11
11
  const config_runtime_1 = require("openclaw/plugin-sdk/config-runtime");
12
12
  const constants_1 = require("./constants");
13
+ const proxy_config_1 = require("./proxy-config");
13
14
  const update_config_1 = require("./update-config");
14
15
  function printRestartNotice() {
15
16
  console.log("");
@@ -114,12 +115,29 @@ function buildConfigFragment(accountId, auth) {
114
115
  },
115
116
  };
116
117
  }
117
- async function runTelegramAuthorization(prompt) {
118
+ /**
119
+ * The proxy the account this login is for already uses, if any.
120
+ *
121
+ * The login handshake carries the phone number, the code and the 2FA password,
122
+ * and it used to go out from the host's real IP even on a deployment whose
123
+ * whole point is that Telegram never sees it: this flow built its own client
124
+ * and read no proxy at all. An unresolved SecretRef throws out of
125
+ * `buildTelegramClientOptions` rather than silently falling back to a direct
126
+ * connection — the same fail-closed rule the channel follows.
127
+ */
128
+ function resolveAuthProxy(config, accountId) {
129
+ return config?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[accountId]?.proxy;
130
+ }
131
+ async function runTelegramAuthorization(prompt, proxy) {
118
132
  const apiId = await prompt.askPositiveInteger("Please enter your apiId: ");
119
133
  const apiHash = await prompt.askRequired("Please enter your apiHash: ");
120
- const client = new telegram_1.TelegramClient(new sessions_1.StringSession(""), apiId, apiHash, {
121
- connectionRetries: 5,
122
- });
134
+ const clientOptions = (0, proxy_config_1.buildTelegramClientOptions)(proxy);
135
+ if (clientOptions.proxy) {
136
+ // Scheme only. The host, port and credentials are exactly what must not
137
+ // reach a terminal, a screen share or CI scrollback.
138
+ console.log(`Connecting through the account's ${(0, proxy_config_1.describeProxy)(clientOptions.proxy)} proxy.`);
139
+ }
140
+ const client = new telegram_1.TelegramClient(new sessions_1.StringSession(""), apiId, apiHash, clientOptions);
123
141
  try {
124
142
  await client.start({
125
143
  phoneNumber: async () => await prompt.askRequired("Please enter your number: "),
@@ -143,7 +161,8 @@ async function runTelegramUserbotAuth(config) {
143
161
  const prompt = createPrompt();
144
162
  try {
145
163
  console.log("Starting Clawgram authorization...");
146
- const auth = await runTelegramAuthorization(prompt);
164
+ const authAccountId = resolveDefaultAccountId(config);
165
+ const auth = await runTelegramAuthorization(prompt, resolveAuthProxy(config, authAccountId));
147
166
  console.log("Telegram authorization completed successfully.");
148
167
  console.log("");
149
168
  // The session string is a bearer secret for the whole Telegram account.
@@ -152,7 +171,7 @@ async function runTelegramUserbotAuth(config) {
152
171
  // automatic config write and therefore has to paste it by hand.
153
172
  console.log(`Session string received (${auth.sessionString.length} chars) — kept out of this output.`);
154
173
  console.log("");
155
- const defaultAccountId = resolveDefaultAccountId(config);
174
+ const defaultAccountId = authAccountId;
156
175
  const rawAccountId = await prompt.ask(`Enter account id for config [${defaultAccountId}]: `);
157
176
  const accountId = rawAccountId.trim() || defaultAccountId;
158
177
  const shouldUpdateConfig = await prompt.askYesNo("Update OpenClaw config automatically? [y/N]: ", false);
package/dist/constants.js CHANGED
@@ -1,7 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CLI_COMMAND = exports.CHANNEL_ID = void 0;
3
+ exports.TELEGRAM_SERVICE_CHAT_ID = exports.CLI_COMMAND = exports.CHANNEL_ID = void 0;
4
4
  const CHANNEL_ID = "clawgram";
5
5
  exports.CHANNEL_ID = CHANNEL_ID;
6
6
  const CLI_COMMAND = "clawgram";
7
7
  exports.CLI_COMMAND = CLI_COMMAND;
8
+ /**
9
+ * Telegram's own service account: login codes, password-reset notices and
10
+ * termination warnings arrive from it. Its messages are dropped on the way in
11
+ * and refused on the way out — a login code reaching the agent is an account
12
+ * takeover, not a privacy nuisance, so no `readChats` entry may enable it.
13
+ */
14
+ const TELEGRAM_SERVICE_CHAT_ID = "777000";
15
+ exports.TELEGRAM_SERVICE_CHAT_ID = TELEGRAM_SERVICE_CHAT_ID;
@@ -21,6 +21,7 @@ exports.parseFetchMediaParams = parseFetchMediaParams;
21
21
  exports.sanitizeFileName = sanitizeFileName;
22
22
  exports.fetchedMediaFileName = fetchedMediaFileName;
23
23
  const history_1 = require("./history");
24
+ const helpers_1 = require("./helpers");
24
25
  /**
25
26
  * A caller that guessed a neighbouring word is not refused: these are all
26
27
  * unambiguous, and an error over vocabulary costs a turn to say nothing.
@@ -52,7 +53,7 @@ function parseFetchMediaMode(value) {
52
53
  return mode;
53
54
  }
54
55
  function parseFetchMediaParams(params) {
55
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat;
56
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params);
56
57
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
57
58
  if (!target) {
58
59
  throw new Error("clawgram: fetch-media requires a chatId");
@@ -209,6 +209,7 @@ class GramJsClientManager {
209
209
  client;
210
210
  proxy;
211
211
  started = false;
212
+ connected = false;
212
213
  constructor(config) {
213
214
  this.config = config;
214
215
  // Credentials may be written as SecretRefs; account start-up resolves them
@@ -231,10 +232,24 @@ class GramJsClientManager {
231
232
  async start() {
232
233
  if (this.started)
233
234
  return;
234
- await this.client.connect();
235
- const authorized = await this.client.checkAuthorization();
236
- if (!authorized) {
237
- throw new Error("GramJS client connected, but session is not authorized.");
235
+ // `connect()` starts the update loop before anything is known about the
236
+ // session, so a revoked or expired one used to throw with `started` still
237
+ // false — and `stop()`, which returned early on exactly that flag, then
238
+ // left a connected client retrying forever. That is the same leak 2.17.1
239
+ // fixed for the ordinary teardown path, reached through the failure path
240
+ // instead.
241
+ try {
242
+ await this.client.connect();
243
+ this.connected = true;
244
+ const authorized = await this.client.checkAuthorization();
245
+ if (!authorized) {
246
+ throw new Error("GramJS client connected, but session is not authorized.");
247
+ }
248
+ }
249
+ catch (error) {
250
+ await this.client.destroy().catch(() => undefined);
251
+ this.connected = false;
252
+ throw error;
238
253
  }
239
254
  this.started = true;
240
255
  }
@@ -254,10 +269,15 @@ class GramJsClientManager {
254
269
  * before a restart, ~4.5/min after one.
255
270
  */
256
271
  async stop() {
257
- if (!this.started)
272
+ // Keyed on `connected`, not `started`: a client whose `connect()` succeeded
273
+ // and whose authorization then failed never set `started`, and the old
274
+ // guard let it keep its update loop. A client that never connected is
275
+ // still left alone, and a second `stop()` still destroys nothing.
276
+ if (!this.started && !this.connected)
258
277
  return;
259
- await this.client.destroy();
278
+ await this.client.destroy().catch(() => undefined);
260
279
  this.started = false;
280
+ this.connected = false;
261
281
  }
262
282
  getClient() {
263
283
  return this.client;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.rememberGroupReplyAddress = rememberGroupReplyAddress;
4
4
  exports.consumeGroupReplyAddress = consumeGroupReplyAddress;
5
+ exports.peekGroupReplyAddress = peekGroupReplyAddress;
5
6
  exports.resetGroupReplyAddresses = resetGroupReplyAddresses;
6
7
  exports.buildGroupReplyAddress = buildGroupReplyAddress;
7
8
  const helpers_1 = require("./helpers");
@@ -45,7 +46,7 @@ function rememberGroupReplyAddress(input) {
45
46
  * to "who am I replying to", so the fallback is gone — callers pass the message
46
47
  * the turn is actually answering, and no message means no greeting.
47
48
  */
48
- function consumeGroupReplyAddress(input) {
49
+ function readGroupReplyAddress(input, consume) {
49
50
  const key = buildGroupReplyAddressKey(input);
50
51
  if (!key) {
51
52
  return undefined;
@@ -54,12 +55,27 @@ function consumeGroupReplyAddress(input) {
54
55
  if (!stored) {
55
56
  return undefined;
56
57
  }
57
- groupReplyAddresses.delete(key);
58
+ if (consume) {
59
+ groupReplyAddresses.delete(key);
60
+ }
58
61
  if (stored.expiresAt < Date.now()) {
59
62
  return undefined;
60
63
  }
61
64
  return stored.address;
62
65
  }
66
+ function consumeGroupReplyAddress(input) {
67
+ return readGroupReplyAddress(input, true);
68
+ }
69
+ /**
70
+ * The same lookup without the delete, for a dry run.
71
+ *
72
+ * A rehearsal that consumed the address changed what the real send would do:
73
+ * the greeting was gone by the time the message actually went out. A dry run
74
+ * has to answer "what would happen" without being the thing that happens.
75
+ */
76
+ function peekGroupReplyAddress(input) {
77
+ return readGroupReplyAddress(input, false);
78
+ }
63
79
  /** Test seam: the map is module state, and suites must not leak into each other. */
64
80
  function resetGroupReplyAddresses() {
65
81
  groupReplyAddresses.clear();
package/dist/helpers.js CHANGED
@@ -3,7 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SILENT_REPLY_TOKEN = void 0;
6
+ exports.SILENT_REPLY_TOKEN = exports.CHAT_TARGET_PARAM_KEYS = void 0;
7
+ exports.resolveAddressableText = resolveAddressableText;
8
+ exports.readChatTargetParam = readChatTargetParam;
9
+ exports.resolveAccountScopes = resolveAccountScopes;
7
10
  exports.normalizeOutboundTarget = normalizeOutboundTarget;
8
11
  exports.resolveConfiguredAccountId = resolveConfiguredAccountId;
9
12
  exports.inferOutboundTargetKind = inferOutboundTargetKind;
@@ -194,14 +197,74 @@ function readLatestAssistantFallbackFromTranscript(sessionKey, storePath, notBef
194
197
  }
195
198
  return undefined;
196
199
  }
197
- function resolveActionTarget(params, toolContext) {
198
- const explicitTo = (0, core_1.readStringParam)(params, "to") ?? (0, core_1.readStringParam)(params, "target");
199
- if (explicitTo?.trim()) {
200
- return explicitTo.trim();
200
+ /**
201
+ * The part of an inbound message the mention gate may read.
202
+ *
203
+ * A voice transcript is the sender's own speech: "Тина, посмотри" said aloud
204
+ * addresses the agent exactly as typing it would, and gating on the caption
205
+ * alone would make her deaf to being spoken to. A vision description is not
206
+ * speech — it is a model reading somebody else's content, so a screenshot of a
207
+ * chat in which a third party wrote `@tina_bot`, or a photo of a poster
208
+ * bearing the name, used to count as an address and wake her up.
209
+ *
210
+ * The body handed to the agent is unaffected; only what may count as being
211
+ * addressed is narrowed.
212
+ */
213
+ function resolveAddressableText(input) {
214
+ if (input.understanding === "description") {
215
+ return input.messageText?.trim() ?? "";
201
216
  }
202
- const contextTarget = toolContext?.currentChannelId?.trim();
203
- if (contextTarget) {
204
- return contextTarget;
217
+ return input.bodyText?.trim() ?? input.messageText?.trim() ?? "";
218
+ }
219
+ /**
220
+ * The chat named by a call, in the spellings this channel accepts.
221
+ *
222
+ * Five parsers spelled this chain out separately and none of them read
223
+ * `channelId` — which is the key core fills for its `channelId`-mode actions
224
+ * (`channel-info`, the name `chatInfo` answers to). A call that named another
225
+ * chat that way fell through to the current one and was answered about the
226
+ * wrong chat, silently. One list, one order, every caller.
227
+ */
228
+ exports.CHAT_TARGET_PARAM_KEYS = ["chatId", "channelId", "target", "to", "chat"];
229
+ function readChatTargetParam(params, toolContext) {
230
+ // Strings only, exactly like the SDK's `readStringParam` and like every
231
+ // parser here before the chain was shared: a numeric `chatId` is refused
232
+ // rather than coerced, because `-1001234567890` loses precision as a JSON
233
+ // number long before it reaches Telegram.
234
+ const named = [];
235
+ for (const key of exports.CHAT_TARGET_PARAM_KEYS) {
236
+ const value = params?.[key];
237
+ const trimmed = typeof value === "string" ? value.trim() : "";
238
+ if (trimmed && !named.includes(trimmed)) {
239
+ named.push(trimmed);
240
+ }
241
+ }
242
+ // Two keys naming two different chats is a caller error, and picking one by
243
+ // key order would send a message to a chat nobody asked for — the failure
244
+ // this whole resolver exists to prevent. Say so instead of guessing.
245
+ if (named.length > 1) {
246
+ throw new Error(`clawgram: conflicting chat targets (${named.join(", ")})`);
247
+ }
248
+ return named[0] ?? toolContext?.currentChannelId?.trim() ?? "";
249
+ }
250
+ /**
251
+ * Which chat an outbound action is for.
252
+ *
253
+ * `chatId` and `channelId` are read alongside `to`/`target` because the read
254
+ * actions accept them and this plugin's own tool hints tell the agent to name
255
+ * a chat that way. Until 2.21.0 the send path ignored them: a `send` carrying
256
+ * `chatId` fell through to the current chat, so a message meant for another
257
+ * chat was delivered to the one the turn came from — the wrong audience, with
258
+ * no error anywhere.
259
+ *
260
+ * A named chat always wins over the context. The context fallback exists for
261
+ * a send that names nothing, which is the ordinary "answer where you were
262
+ * asked" case.
263
+ */
264
+ function resolveActionTarget(params, toolContext) {
265
+ const target = readChatTargetParam(params, toolContext);
266
+ if (target) {
267
+ return target;
205
268
  }
206
269
  throw new Error("clawgram: message target is required");
207
270
  }
@@ -259,19 +322,32 @@ function readMessageText(params) {
259
322
  }
260
323
  return "";
261
324
  }
325
+ /**
326
+ * Three states, and the empty one is the whole point:
327
+ *
328
+ * - absent (or a shape this cannot read) → `["*"]`, the historical default that
329
+ * keeps a fresh install usable;
330
+ * - `"*"` or a non-empty list → itself;
331
+ * - `[]`, `""`, or a list of blanks → `[]`, which denies everyone.
332
+ *
333
+ * The last used to return `["*"]`: an operator who emptied the list to shut the
334
+ * account off opened it to every Telegram user instead, and the sibling scopes
335
+ * (`readChats: []`, `manageChats: []`) already read an empty list as deny.
336
+ * `startAccount` logs a warning when this returns empty, because "nobody can
337
+ * reach the agent" must not be a silent state.
338
+ */
262
339
  function resolveAllowFrom(value) {
263
340
  if (value === "*") {
264
341
  return ["*"];
265
342
  }
266
343
  if (typeof value === "string" || typeof value === "number") {
267
344
  const entry = String(value).trim();
268
- return entry ? [entry] : ["*"];
345
+ return entry ? [entry] : [];
269
346
  }
270
347
  if (!Array.isArray(value)) {
271
348
  return ["*"];
272
349
  }
273
- const entries = value.map((entry) => String(entry).trim()).filter(Boolean);
274
- return entries.length > 0 ? entries : ["*"];
350
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
275
351
  }
276
352
  /**
277
353
  * Three rungs, widest first: `open` wakes on every message, `mention` on the
@@ -307,6 +383,28 @@ function resolveGroupPromptSettings(groupConfig) {
307
383
  }
308
384
  return settings;
309
385
  }
386
+ /**
387
+ * The sender scopes for one account: who may write, and what each group allows.
388
+ *
389
+ * `allowFrom` and `groups` are declared both per account and at the channel
390
+ * level, and the manifest validates them in both places — but only the account
391
+ * copies were read, so a channel-level allowlist passed validation and then
392
+ * admitted everyone. Worse, the config was read twice by two different paths
393
+ * (`resolveAccount` and the inbound handler), which is how they could disagree
394
+ * at all. One function serves both now.
395
+ *
396
+ * The account's own value wins, including a deliberate `[]`; the channel level
397
+ * is the default beneath it. Groups merge per key rather than replacing, so a
398
+ * channel-wide default and a per-account override can coexist.
399
+ */
400
+ function resolveAccountScopes(cfg, accountId) {
401
+ const channel = cfg?.channels?.clawgram;
402
+ const account = channel?.accounts?.[accountId];
403
+ return {
404
+ allowFrom: resolveAllowFrom(account?.allowFrom ?? channel?.allowFrom),
405
+ groups: { ...resolveGroups(channel?.groups), ...resolveGroups(account?.groups) },
406
+ };
407
+ }
310
408
  function resolveGroups(value) {
311
409
  if (!value || typeof value !== "object" || Array.isArray(value)) {
312
410
  return {};
package/dist/history.js CHANGED
@@ -28,6 +28,7 @@ exports.HISTORY_DEFAULT_LIMIT = 100;
28
28
  exports.HISTORY_MAX_LIMIT = 500;
29
29
  const media_1 = require("./media");
30
30
  const helpers_1 = require("./helpers");
31
+ const constants_1 = require("./constants");
31
32
  /**
32
33
  * Matches `normalize.ts` and `gramjs-client.ts` deliberately.
33
34
  *
@@ -107,7 +108,7 @@ function parseMessageId(value, field) {
107
108
  return numeric;
108
109
  }
109
110
  function parseListMessagesParams(params) {
110
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat;
111
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params);
111
112
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
112
113
  if (!target) {
113
114
  throw new Error("clawgram: list requires a chatId");
@@ -190,7 +191,7 @@ function parseParticipantsFilter(params) {
190
191
  * not silently turn into an unbounded response.
191
192
  */
192
193
  function parseListParticipantsParams(params) {
193
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat;
194
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params);
194
195
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
195
196
  if (!target) {
196
197
  throw new Error("clawgram: participants requires a chatId");
@@ -252,6 +253,14 @@ function normalizeChatKey(value) {
252
253
  * rather than a sentence in a prompt that a model may be argued out of.
253
254
  */
254
255
  function isChatReadable(target, readChats) {
256
+ // The inbound path has always dropped Telegram's service chat so login codes
257
+ // never reach the model; the read path did not, so `read chatId=777000`
258
+ // handed them over whenever `readChats` was absent (the default `--auth`
259
+ // writes) or held `*`. The deny is unconditional on purpose: no deployment
260
+ // has a reason to let the agent read its own login codes, and a config entry
261
+ // that enabled it would be an account-takeover switch.
262
+ if (normalizeChatKey(target) === constants_1.TELEGRAM_SERVICE_CHAT_ID)
263
+ return false;
255
264
  if (readChats === undefined || readChats === null)
256
265
  return true;
257
266
  const entries = (Array.isArray(readChats) ? readChats : [readChats])
package/dist/manage.js CHANGED
@@ -32,6 +32,7 @@ exports.resolveCreatedChannelId = resolveCreatedChannelId;
32
32
  exports.summarizeMissingInvitees = summarizeMissingInvitees;
33
33
  exports.readInviteLink = readInviteLink;
34
34
  const history_1 = require("./history");
35
+ const helpers_1 = require("./helpers");
35
36
  function readString(value) {
36
37
  if (typeof value !== "string") {
37
38
  return undefined;
@@ -47,7 +48,7 @@ function readUserRef(value) {
47
48
  return readString(value);
48
49
  }
49
50
  function readTarget(params, toolContext, action) {
50
- const raw = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
51
+ const raw = (0, helpers_1.readChatTargetParam)(params, toolContext);
51
52
  const target = readString(raw);
52
53
  if (!target) {
53
54
  throw new Error(`clawgram: ${action} requires a chatId`);
package/dist/media.js CHANGED
@@ -11,12 +11,19 @@
11
11
  * 240 KB" was posted is what a summary needs, and fetching the bytes of every
12
12
  * attachment in a window would be a different feature with different costs.
13
13
  */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
14
17
  Object.defineProperty(exports, "__esModule", { value: true });
15
18
  exports.describeMedia = describeMedia;
16
19
  exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
17
20
  exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
18
21
  exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
19
22
  exports.pruneFetchedMedia = pruneFetchedMedia;
23
+ exports.isLocalMediaPath = isLocalMediaPath;
24
+ exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = __importDefault(require("node:path"));
20
27
  /**
21
28
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
22
29
  * the same shape that once made `senderId` silently undefined. Anything that
@@ -237,3 +244,52 @@ function extensionFor(media, understanding) {
237
244
  return "m4a";
238
245
  return "ogg";
239
246
  }
247
+ /**
248
+ * Whether an outbound `file` is a local path rather than a URL.
249
+ *
250
+ * GramJS accepts both, and only a path can reach the host filesystem.
251
+ */
252
+ function isLocalMediaPath(file) {
253
+ if (typeof file !== "string" || !file.trim()) {
254
+ return false;
255
+ }
256
+ return !/^[a-z][a-z0-9+.-]*:/i.test(file.trim());
257
+ }
258
+ /**
259
+ * Refuses an outbound local file that sits outside the roots the host declared.
260
+ *
261
+ * Core hands every action the `mediaLocalRoots` the agent is scoped to, and
262
+ * bundled channels enforce them; this one passed `filePath` straight to
263
+ * `sendFile`, so a prompt-injected agent could name `/opt/openclaw-secrets/
264
+ * secrets.json` — or the config holding `sessionString` — and have it uploaded
265
+ * to any peer. Symlinks are resolved first, because a link inside a root
266
+ * pointing out of it is the obvious way around a prefix check.
267
+ *
268
+ * When the host declares no roots the path is left alone: the gateway RPC and
269
+ * the TTS contour both send files core never scoped, and refusing them here
270
+ * would break sending altogether rather than narrow it.
271
+ */
272
+ function assertLocalMediaWithinRoots(file, roots) {
273
+ if (!isLocalMediaPath(file) || !roots || roots.length === 0) {
274
+ return;
275
+ }
276
+ const realPath = (candidate) => {
277
+ try {
278
+ return (0, node_fs_1.realpathSync)(candidate);
279
+ }
280
+ catch {
281
+ return node_path_1.default.resolve(candidate);
282
+ }
283
+ };
284
+ const target = realPath(file.trim());
285
+ const allowed = roots.some((root) => {
286
+ const resolvedRoot = realPath(root);
287
+ return target === resolvedRoot || target.startsWith(`${resolvedRoot}${node_path_1.default.sep}`);
288
+ });
289
+ if (!allowed) {
290
+ // The path is the agent's own input, not a secret, and naming it is what
291
+ // makes the refusal actionable. The roots are not listed: they describe
292
+ // the host's layout.
293
+ throw new Error(`clawgram: ${file} is outside the media roots this agent may read`);
294
+ }
295
+ }
package/dist/reactions.js CHANGED
@@ -13,6 +13,7 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.resolveAgentReactionGuidance = resolveAgentReactionGuidance;
15
15
  exports.parseReactionParams = parseReactionParams;
16
+ const helpers_1 = require("./helpers");
16
17
  /**
17
18
  * How freely the agent may react, from the account's `reactionLevel`.
18
19
  *
@@ -69,7 +70,7 @@ function parseMessageId(value) {
69
70
  return parsed;
70
71
  }
71
72
  function parseReactionParams(params, toolContext) {
72
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
73
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
73
74
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
74
75
  if (!target) {
75
76
  throw new Error("clawgram: react requires a chatId");
package/dist/topics.js CHANGED
@@ -16,10 +16,11 @@ exports.normalizeForumTopics = normalizeForumTopics;
16
16
  * client; the transport lives in `GramJsClientManager.listTopics`.
17
17
  */
18
18
  const normalize_js_1 = require("./normalize.js");
19
+ const helpers_1 = require("./helpers");
19
20
  exports.TOPICS_DEFAULT_LIMIT = 100;
20
21
  exports.TOPICS_MAX_LIMIT = 500;
21
22
  function parseTopicsParams(params) {
22
- const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat;
23
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params);
23
24
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
24
25
  if (!target) {
25
26
  throw new Error("clawgram: topics requires a chatId");
@@ -351,10 +351,26 @@ function buildUpdatedConfigText(raw, accountId, auth) {
351
351
  }
352
352
  return replaceObjectPropertyValue(raw, channelsProperty, updatedChannels, format);
353
353
  }
354
+ /**
355
+ * The config this writes holds `apiHash` and `sessionString` in plaintext, so
356
+ * the permissions of the file it replaces are part of the credential's
357
+ * protection. `writeFile`'s own `mode` is masked by the process umask (0644
358
+ * under the usual 022), which is how a 0600 config came back world-readable
359
+ * after `--auth`; the explicit `chmod` on the temp file is what actually
360
+ * fixes it, and it happens before the rename so the config is never briefly
361
+ * readable at the wrong mode.
362
+ */
363
+ const SECRET_FILE_MODE = 0o600;
364
+ async function readFileMode(filePath) {
365
+ const stats = await node_fs_1.promises.stat(filePath).catch(() => null);
366
+ return stats ? stats.mode & 0o777 : SECRET_FILE_MODE;
367
+ }
354
368
  async function writeConfigAtomically(configPath, raw) {
355
369
  const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
370
+ const mode = await readFileMode(configPath);
356
371
  try {
357
- await node_fs_1.promises.writeFile(tempPath, raw, "utf8");
372
+ await node_fs_1.promises.writeFile(tempPath, raw, { encoding: "utf8", mode });
373
+ await node_fs_1.promises.chmod(tempPath, mode);
358
374
  await node_fs_1.promises.rename(tempPath, configPath);
359
375
  }
360
376
  catch (error) {
@@ -367,8 +383,12 @@ async function createConfigBackup(configPath) {
367
383
  if (raw === null) {
368
384
  return null;
369
385
  }
386
+ // The backup is a verbatim copy of a credential file and it is never cleaned
387
+ // up, so it inherits the original's mode rather than the umask default.
388
+ const mode = await readFileMode(configPath);
370
389
  const backupPath = buildConfigBackupPath(configPath);
371
- await node_fs_1.promises.writeFile(backupPath, raw, "utf8");
390
+ await node_fs_1.promises.writeFile(backupPath, raw, { encoding: "utf8", mode });
391
+ await node_fs_1.promises.chmod(backupPath, mode);
372
392
  return backupPath;
373
393
  }
374
394
  async function updateConfigFileDirectly(configPath, accountId, auth) {
@@ -2,7 +2,7 @@
2
2
  "id": "clawgram",
3
3
  "name": "Clawgram",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
- "version": "2.20.2",
5
+ "version": "2.21.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
@@ -57,7 +57,7 @@
57
57
  "tag"
58
58
  ],
59
59
  "default": "mention",
60
- "description": "What wakes the agent in this chat (2.18.0). `mention` \u2014 the name, an @mention or a reply to it; `open` \u2014 every message, the agent decides for itself whether words are owed; `tag` \u2014 only an explicit @username or a reply, for chats where the name occurs in conversation constantly."
60
+ "description": "What wakes the agent in this chat (2.18.0). `mention` the name, an @mention or a reply to it; `open` every message, the agent decides for itself whether words are owed; `tag` only an explicit @username or a reply, for chats where the name occurs in conversation constantly."
61
61
  },
62
62
  "allowFrom": {
63
63
  "type": "array",
@@ -364,7 +364,7 @@
364
364
  "tag"
365
365
  ],
366
366
  "default": "mention",
367
- "description": "What wakes the agent in this chat (2.18.0). `mention` \u2014 the name, an @mention or a reply to it; `open` \u2014 every message; `tag` \u2014 only an explicit @username or a reply."
367
+ "description": "What wakes the agent in this chat (2.18.0). `mention` the name, an @mention or a reply to it; `open` every message; `tag` only an explicit @username or a reply."
368
368
  },
369
369
  "allowFrom": {
370
370
  "type": "array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.20.2",
3
+ "version": "2.21.0",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {