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 +21 -4
- package/dist/channel.js +99 -27
- package/dist/chat-info.js +1 -1
- package/dist/cli-core.js +25 -6
- package/dist/constants.js +9 -1
- package/dist/fetch-media.js +2 -1
- package/dist/gramjs-client.js +26 -6
- package/dist/group-reply-address.js +18 -2
- package/dist/helpers.js +109 -11
- package/dist/history.js +11 -2
- package/dist/manage.js +2 -1
- package/dist/media.js +56 -0
- package/dist/reactions.js +2 -1
- package/dist/topics.js +2 -1
- package/dist/update-config.js +22 -2
- package/openclaw.plugin.json +3 -3
- package/package.json +1 -1
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:
|
|
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
|
|
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?.[
|
|
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?.[
|
|
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 `"
|
|
196
|
-
* `params.
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
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
|
|
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 =
|
|
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;
|
package/dist/fetch-media.js
CHANGED
|
@@ -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 =
|
|
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");
|
package/dist/gramjs-client.js
CHANGED
|
@@ -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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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");
|
package/dist/update-config.js
CHANGED
|
@@ -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) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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`
|
|
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`
|
|
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