clawgram 2.20.0 → 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,20 +845,24 @@ 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
- const wasReplyToSelf = await (0, helpers_1.isReplyToSelfMessage)(rawMessage, selfId);
856
+ // One fetch serves two needs: the reply-to-self gate below and
857
+ // the parent's text for the agent (ReplyToBody), which a plain
858
+ // reply does not carry on its own.
859
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
860
+ const wasReplyToSelf = replyParent.isSelf;
805
861
  const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
806
862
  facts: {
807
863
  canDetectMention: true,
808
864
  wasMentioned,
809
- hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(text),
865
+ hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(addressableText),
810
866
  },
811
867
  policy: {
812
868
  isGroup: true,
@@ -827,7 +883,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
827
883
  wasMentioned,
828
884
  wasReplyToSelf,
829
885
  shouldSkip: mentionDecision.shouldSkip,
830
- text,
886
+ textLength: text.length,
831
887
  });
832
888
  if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
833
889
  log?.info?.("clawgram skipping group message without mention", {
@@ -875,6 +931,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
875
931
  // bare text, and the fragment the person pointed at is lost.
876
932
  ReplyToQuoteText: normalized.replyQuoteText,
877
933
  ReplyToIsQuote: normalized.replyIsQuote,
934
+ // A plain reply has no highlight; core then falls back to the
935
+ // parent's body, which only exists if the channel fetched it.
936
+ ReplyToBody: replyParent.body,
937
+ ReplyToSender: replyParent.sender,
878
938
  MessageThreadId: normalized.messageThreadId,
879
939
  NativeChannelId: normalized.chatId,
880
940
  // Trusted per-group prompt block from `groups.<id>.systemPrompt`.
@@ -911,7 +971,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
911
971
  ctx: ctxPayload,
912
972
  updateLastRoute: {
913
973
  sessionKey: route.sessionKey,
914
- channel: constants_1.CHANNEL_ID,
974
+ channel: constants_2.CHANNEL_ID,
915
975
  to: conversationRouteTarget,
916
976
  accountId: route.accountId ?? accountId,
917
977
  },
@@ -954,7 +1014,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
954
1014
  accountId,
955
1015
  chatId: normalized.chatId,
956
1016
  messageId: normalized.messageId,
957
- payloadText: outboundText,
1017
+ payloadTextLength: outboundText.length,
958
1018
  payloadReplyToId: payload.replyToId ?? null,
959
1019
  });
960
1020
  if (!outboundText) {
@@ -1080,7 +1140,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1080
1140
  chatId: normalized.chatId,
1081
1141
  messageId: normalized.messageId,
1082
1142
  routeSessionKey: route.sessionKey,
1083
- fallbackText: visibleFallbackText,
1143
+ fallbackTextLength: visibleFallbackText.length,
1084
1144
  });
1085
1145
  await sendTextToConversation({
1086
1146
  text: (0, helpers_1.prefixReplyTextToAddress)(visibleFallbackText, groupReplyAddress),
@@ -1179,6 +1239,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1179
1239
  });
1180
1240
  return;
1181
1241
  }
1242
+ // Same fetch as the group path. In a DM the parent is as often
1243
+ // the agent's own message as the person's — the owner answers a
1244
+ // notice she sent — and neither text is available any other way.
1245
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
1182
1246
  await gram.withTyping(conversationTarget, async () => {
1183
1247
  await (0, direct_dm_1.dispatchInboundDirectDmWithRuntime)({
1184
1248
  cfg,
@@ -1210,6 +1274,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1210
1274
  // direct messages too, and the fragment is not part of the text.
1211
1275
  ReplyToQuoteText: normalized.replyQuoteText,
1212
1276
  ReplyToIsQuote: normalized.replyIsQuote,
1277
+ ReplyToBody: replyParent.body,
1278
+ ReplyToSender: replyParent.sender,
1213
1279
  NativeChannelId: normalized.chatId,
1214
1280
  },
1215
1281
  deliver: async (payload) => {
@@ -1324,7 +1390,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1324
1390
  },
1325
1391
  },
1326
1392
  messaging: {
1327
- targetPrefixes: [constants_1.CHANNEL_ID, "tguserbot", "telegram", "tg"],
1393
+ targetPrefixes: [constants_2.CHANNEL_ID, "tguserbot", "telegram", "tg"],
1328
1394
  normalizeTarget(raw) {
1329
1395
  const normalized = (0, helpers_1.normalizeOutboundTarget)(raw);
1330
1396
  return normalized || undefined;
@@ -1396,7 +1462,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1396
1462
  return (0, core_1.buildChannelOutboundSessionRoute)({
1397
1463
  cfg: params.cfg,
1398
1464
  agentId: params.agentId,
1399
- channel: constants_1.CHANNEL_ID,
1465
+ channel: constants_2.CHANNEL_ID,
1400
1466
  accountId,
1401
1467
  peer: {
1402
1468
  kind: (0, helpers_1.routeKindFromChatType)(chatType),
@@ -1492,7 +1558,13 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1492
1558
  "download-file": { aliases: ["chatId"] },
1493
1559
  },
1494
1560
  extractToolSend: ({ args }) => (0, tool_send_1.extractToolSend)(args, "sendMessage"),
1495
- 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;
1496
1568
  // Core passes the flag beside `params`; callers write it inside.
1497
1569
  // Both count, because a rehearsal flag that is silently ignored puts
1498
1570
  // a real message in a real chat — twice, so far (2.13.1).
@@ -2149,6 +2221,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2149
2221
  if (!file) {
2150
2222
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
2151
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);
2152
2227
  const captionText = (0, helpers_1.readMessageText)(params) || ((0, param_readers_1.readStringParam)(params, "caption") ?? "");
2153
2228
  // A caption is optional, but the silent-reply sentinel must never
2154
2229
  // reach Telegram as one — same reasoning as the `send` path below.
@@ -2252,8 +2327,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2252
2327
  currentMessageId: String(currentMessageId),
2253
2328
  toolContextCurrentChannelId: currentChannelId || null,
2254
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.
2255
2332
  return (0, core_1.jsonResult)({
2256
2333
  ok: true,
2334
+ ...(dryRun ? { dryRun: true } : {}),
2257
2335
  suppressedDuplicate: true,
2258
2336
  to,
2259
2337
  accountId: resolvedAccountId,
@@ -2264,7 +2342,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2264
2342
  // `replyToId`, and until 2026-08-10 that fell through to the most
2265
2343
  // recent sender: in an interleaved chat the owner's report went out
2266
2344
  // addressed to a colleague who had asked something else entirely.
2267
- 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)({
2268
2349
  accountId: resolvedAccountId,
2269
2350
  chatId: to,
2270
2351
  replyToId: replyToId ?? currentMessageId,
@@ -2483,6 +2564,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2483
2564
  if (!gram) {
2484
2565
  throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2485
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);
2486
2572
  actionLog.info("clawgram outbound sendMedia", {
2487
2573
  accountId: ctx.accountId,
2488
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;
@@ -36,6 +39,7 @@ exports.prefixReplyTextToAddress = prefixReplyTextToAddress;
36
39
  exports.resolveReplyTarget = resolveReplyTarget;
37
40
  exports.resolveChatTarget = resolveChatTarget;
38
41
  exports.isReplyToSelfMessage = isReplyToSelfMessage;
42
+ exports.resolveReplyParent = resolveReplyParent;
39
43
  exports.resolveSenderProfile = resolveSenderProfile;
40
44
  exports.resolveSenderProfileWithTimeout = resolveSenderProfileWithTimeout;
41
45
  exports.normalizeParseMode = normalizeParseMode;
@@ -193,14 +197,74 @@ function readLatestAssistantFallbackFromTranscript(sessionKey, storePath, notBef
193
197
  }
194
198
  return undefined;
195
199
  }
196
- function resolveActionTarget(params, toolContext) {
197
- const explicitTo = (0, core_1.readStringParam)(params, "to") ?? (0, core_1.readStringParam)(params, "target");
198
- if (explicitTo?.trim()) {
199
- 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() ?? "";
200
216
  }
201
- const contextTarget = toolContext?.currentChannelId?.trim();
202
- if (contextTarget) {
203
- 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;
204
268
  }
205
269
  throw new Error("clawgram: message target is required");
206
270
  }
@@ -258,19 +322,32 @@ function readMessageText(params) {
258
322
  }
259
323
  return "";
260
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
+ */
261
339
  function resolveAllowFrom(value) {
262
340
  if (value === "*") {
263
341
  return ["*"];
264
342
  }
265
343
  if (typeof value === "string" || typeof value === "number") {
266
344
  const entry = String(value).trim();
267
- return entry ? [entry] : ["*"];
345
+ return entry ? [entry] : [];
268
346
  }
269
347
  if (!Array.isArray(value)) {
270
348
  return ["*"];
271
349
  }
272
- const entries = value.map((entry) => String(entry).trim()).filter(Boolean);
273
- return entries.length > 0 ? entries : ["*"];
350
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
274
351
  }
275
352
  /**
276
353
  * Three rungs, widest first: `open` wakes on every message, `mention` on the
@@ -306,6 +383,28 @@ function resolveGroupPromptSettings(groupConfig) {
306
383
  }
307
384
  return settings;
308
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
+ }
309
408
  function resolveGroups(value) {
310
409
  if (!value || typeof value !== "object" || Array.isArray(value)) {
311
410
  return {};
@@ -549,27 +648,58 @@ async function resolveChatTarget(message) {
549
648
  }
550
649
  return message?.inputChat ?? message?._inputChat ?? message?.chat ?? message?._chat ?? message?.peerId;
551
650
  }
552
- async function isReplyToSelfMessage(message, selfId) {
553
- if (!selfId) {
554
- return false;
555
- }
651
+ /**
652
+ * The message a reply points at, fetched once.
653
+ *
654
+ * Telegram does not put the parent's text into the reply; a highlight
655
+ * (`quoteText`) is the only fragment that travels with it, and most replies
656
+ * have none. Core renders `[Replying to: …]` from the highlight or, failing
657
+ * that, from `ReplyToBody` — so without this fetch a plain reply reaches the
658
+ * agent as a bare parent id. The case that exposed it: the owner answered, in
659
+ * a DM, the agent's own notice about an unknown sender; the notice had been
660
+ * sent from another session, DMs are outside `readChats`, and the agent had
661
+ * no way to learn what "reply to #1011" referred to.
662
+ *
663
+ * Failure degrades to "no context" on purpose: a parent that cannot be
664
+ * fetched (deleted, flood-waited, transport hiccup) must not cost the
665
+ * message itself.
666
+ */
667
+ async function resolveReplyParent(message, input) {
556
668
  const replyToMessageId = message?.replyTo?.replyToMsgId ?? message?.replyToMsgId;
557
669
  if (!replyToMessageId) {
558
- return false;
670
+ return { isSelf: false };
559
671
  }
560
672
  const replied = typeof message?.getReplyMessage === "function"
561
673
  ? await message.getReplyMessage().catch(() => undefined)
562
674
  : undefined;
563
675
  if (!replied) {
564
- return false;
565
- }
566
- if (replied.out === true) {
567
- return true;
676
+ return { isSelf: false };
568
677
  }
569
678
  const replySenderId = replied.senderId ??
570
679
  replied.fromId?.userId ??
571
680
  replied.fromId?.channelId;
572
- return replySenderId !== undefined && String(replySenderId) === selfId;
681
+ const isSelf = replied.out === true ||
682
+ (input.selfId !== undefined && replySenderId !== undefined && String(replySenderId) === input.selfId);
683
+ const rawText = typeof replied.message === "string" ? replied.message :
684
+ typeof replied.text === "string" ? replied.text :
685
+ "";
686
+ const body = rawText.trim() ? rawText : undefined;
687
+ const source = replied.sender ?? replied._sender;
688
+ const sender = isSelf
689
+ ? (input.selfLabel ?? (input.selfId !== undefined ? input.selfId : undefined))
690
+ : toDisplayName({
691
+ firstName: typeof source?.firstName === "string" ? source.firstName : undefined,
692
+ lastName: typeof source?.lastName === "string" ? source.lastName : undefined,
693
+ username: undefined,
694
+ fallback: resolveActiveUsername(source) ? `@${resolveActiveUsername(source)}` : (replySenderId !== undefined ? String(replySenderId) : undefined),
695
+ });
696
+ return { isSelf, body, sender: sender || undefined };
697
+ }
698
+ async function isReplyToSelfMessage(message, selfId) {
699
+ if (!selfId) {
700
+ return false;
701
+ }
702
+ return (await resolveReplyParent(message, { selfId })).isSelf;
573
703
  }
574
704
  async function resolveSenderProfile(message, input) {
575
705
  const pickProfile = (source) => {
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.0",
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.0",
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": {