clawgram 2.21.1 → 2.23.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
@@ -18,9 +18,9 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
18
18
  > it can read private and group conversations within its configured scope and send messages that are
19
19
  > indistinguishable from ones you typed yourself. Recipients cannot tell an assistant reply from a
20
20
  > human one. Treat the account as compromised-if-leaked: `apiHash` and `sessionString` are bearer
21
- > credentials for everything that account can reach. Scope reads with `readChats`, gate senders with
22
- > `allowFrom`, and prefer a dedicated account over your primary one — see
23
- > [Security and privacy](#security-and-privacy).
21
+ > credentials for everything that account can reach. Scope reads with `readChats`, scope sends with
22
+ > `sendChats`, gate senders with `allowFrom`, and prefer a dedicated account over your primary one —
23
+ > see [Security and privacy](#security-and-privacy).
24
24
 
25
25
 
26
26
  ## Features
@@ -184,6 +184,13 @@ openclaw gateway restart
184
184
  openclaw gateway restart
185
185
  ```
186
186
 
187
+ **What `--auth` writes is closed, on purpose.** The first config allows only the
188
+ account that authorised (`allowFrom: [<your id>]`), seeds no group entry, and
189
+ writes `readChats: []`. Until 2.21.1 it seeded `allowFrom: ["*"]` with an
190
+ enabled wildcard group, so a freshly authorised account answered any Telegram
191
+ user and read the history of every chat it belonged to. Widen both lists
192
+ deliberately; the flow prints who can reach the agent before it exits.
193
+
187
194
  One restart after installation is enough. From 2.17.0 the plugin declares
188
195
  `channels.clawgram` as a hot-reloadable prefix, so later edits under it —
189
196
  `allowFrom`, `groups`, `readChats`, proxy — are picked up by the Gateway's
@@ -249,6 +256,7 @@ loud where it does occur.
249
256
  | `apiHash` | string | required | Telegram API hash |
250
257
  | `sessionString` | string | `""` | Authenticated StringSession |
251
258
  | `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 |
259
+ | `operatorIds` | string[] | — | Who receives core's operational telemetry in a DM (tool-failure warnings, fallback notices). They quote shell commands and secret-store paths, so they go only to these ids. Absent falls back to `allowFrom` when it is a concrete list; a wildcard or an empty list means no operator is identified and the notices are dropped everywhere — they stay in the run diagnostics and the gateway log |
252
260
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
253
261
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
254
262
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
@@ -809,10 +817,27 @@ nothing is not an error — it says which nothing it was:
809
817
  A reading that fails while the download succeeded still returns `ok: true`, with `readError` beside
810
818
  the path: the bytes are already there and can still be forwarded.
811
819
 
812
- **Fetched files live in the system temp directory** (`clawgram-fetched/`), named after the chat and
813
- message they came from, and are pruned after 24 hours by the next fetch. Nothing else removes them,
814
- and nothing sends them anywhere putting a fetched file in a chat is an ordinary `upload-file`,
815
- with whatever confirmation the deployment requires for that.
820
+ **Fetched files live under the OpenClaw state directory** (`$OPENCLAW_STATE_DIR/tmp/clawgram-fetched/`,
821
+ falling back to the system temp directory when that variable is unset), named after the chat and
822
+ message they came from, and are pruned an hour later by the next fetch. The directory is created
823
+ 0700 and each file written 0600: these are private-chat images and voice notes, and until 2.21.1
824
+ they sat in a shared `/tmp` at 0644 for a day, readable by every local account on the host. Nothing
825
+ else removes them, and nothing sends them anywhere — putting a fetched file in a chat is an ordinary
826
+ `upload-file`, with whatever confirmation the deployment requires for that.
827
+
828
+ ## Dependencies are pinned, and the lock ships
829
+
830
+ `installDependencies: true` tells OpenClaw to run an install in the plugin
831
+ directory, so what lands on the host is whatever the registry serves that day —
832
+ and the MTProto client sees `apiHash`, `sessionString`, the proxy credentials
833
+ and every message. Since 2.21.1 the two runtime dependencies are pinned to
834
+ exact versions and `npm-shrinkwrap.json` is part of the published tarball, so an
835
+ install reproduces the tree the tests ran against rather than resolving a caret
836
+ range. `npm ci --ignore-scripts` verifies it.
837
+
838
+ The transitive `ip-address` is held at 10.7.0 through `overrides`: everything at
839
+ or below 10.3.0 carries three advisories about SSRF and trust-boundary bypass,
840
+ and it sits under the SOCKS proxy path this channel uses.
816
841
 
817
842
  ## Security and privacy
818
843
 
@@ -825,9 +850,10 @@ that means in practice, and what the code does about it:
825
850
  | 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 |
826
851
  | 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 |
827
852
  | 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 |
853
+ | Send scope | `accounts.*.sendChats` | `send`, `upload-file`, `react` and core's delivery path are confined to the listed chats. Same shape as `readChats`: absent means no restriction, `[]` denies everything, `["*"]` allows every chat. **A phone number is refused whatever the list says** — messaging a raw number starts a conversation with someone who never contacted the account. Without the list, an injected turn can write to strangers from the owner's account or move a work chat's content into a DM one send at a time (2.22.0) |
828
854
  | 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) |
829
855
  | 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 |
830
- | Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions |
856
+ | Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions. **Write ids, not handles:** an `@username` entry is a claim about a handle, and a released handle can be taken by someone else, after which the entry admits a stranger. The account logs a warning at start-up for every handle entry (2.22.0) |
831
857
 
832
858
  Two static tests (`test/no-secret-logging.test.ts`) fail the build if a message body or a credential
833
859
  is ever added back to a log call, or if the auth flow prints the session string unprompted.
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ // Имена действий: что принимается и во что разрешается.
3
+ //
4
+ // Вынесено из channel.ts, который был единственным файлом на 3274 строки при
5
+ // следующем по величине 1165 (находка A6-11). Здесь нет ни рантайма, ни
6
+ // конфига — только словарь, поэтому читать и править его можно, не открывая
7
+ // диспетчер.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.MANAGE_ACTIONS = exports.CORE_ACTION_SYNONYMS = void 0;
10
+ exports.canonicalAction = canonicalAction;
11
+ /**
12
+ * Every accepted spelling of an action, mapped to its canonical name.
13
+ *
14
+ * One table, not three. The synonyms used to live in
15
+ * `CORE_ACTION_SYNONYMS`, again in `MANAGE_ACTION_ALIASES`, and a third time
16
+ * as `action === "…" || …` chains inside the dispatcher — and the dispatcher
17
+ * read only the chains. A name could therefore be added to a table and to the
18
+ * advertised list and still reach nothing, with the suite none the wiser:
19
+ * it only ever dispatched the native spellings (finding A6-10).
20
+ *
21
+ * `canonicalAction` is now the only place a name is resolved, and
22
+ * `CORE_ACTION_SYNONYMS` below is derived from this table rather than kept
23
+ * beside it.
24
+ */
25
+ const ACTION_ALIASES = {
26
+ send: "send",
27
+ read: "read",
28
+ // `list` is accepted so a caller that guessed the other obvious name is not
29
+ // silently refused.
30
+ list: "read",
31
+ react: "react",
32
+ joins: "joins",
33
+ "upload-file": "upload-file",
34
+ sendAttachment: "upload-file",
35
+ "fetch-media": "fetch-media",
36
+ fetchMedia: "fetch-media",
37
+ "download-media": "fetch-media",
38
+ downloadMedia: "fetch-media",
39
+ getMedia: "fetch-media",
40
+ "download-file": "fetch-media",
41
+ participants: "participants",
42
+ members: "participants",
43
+ "member-info": "participants",
44
+ topics: "topics",
45
+ forumTopics: "topics",
46
+ "thread-list": "topics",
47
+ dialogs: "dialogs",
48
+ chats: "dialogs",
49
+ "channel-list": "dialogs",
50
+ chatInfo: "chatInfo",
51
+ getChatInfo: "chatInfo",
52
+ "channel-info": "chatInfo",
53
+ chatMetadata: "chatInfo",
54
+ getChatMetadata: "chatInfo",
55
+ // Chat management. `kick` was already accepted; the rest were advertised
56
+ // under names core does not know and were therefore never callable from the
57
+ // tool at all — 2.19.4 gives them core's nearest name. `transferOwnership`
58
+ // and `inviteLink` have no counterpart in that vocabulary and stay
59
+ // gateway-only, as does `joins`.
60
+ createGroup: "createGroup",
61
+ createChat: "createGroup",
62
+ "create-group": "createGroup",
63
+ "channel-create": "createGroup",
64
+ addMembers: "addMembers",
65
+ addMember: "addMembers",
66
+ "add-members": "addMembers",
67
+ addParticipant: "addMembers",
68
+ removeMember: "removeMember",
69
+ removeMembers: "removeMember",
70
+ "remove-member": "removeMember",
71
+ kick: "removeMember",
72
+ promoteAdmin: "promoteAdmin",
73
+ promote: "promoteAdmin",
74
+ "promote-admin": "promoteAdmin",
75
+ setAdmin: "promoteAdmin",
76
+ "role-add": "promoteAdmin",
77
+ demoteAdmin: "demoteAdmin",
78
+ demote: "demoteAdmin",
79
+ "demote-admin": "demoteAdmin",
80
+ "role-remove": "demoteAdmin",
81
+ transferOwnership: "transferOwnership",
82
+ transferOwner: "transferOwnership",
83
+ "transfer-ownership": "transferOwnership",
84
+ inviteLink: "inviteLink",
85
+ exportInviteLink: "inviteLink",
86
+ "invite-link": "inviteLink",
87
+ };
88
+ /** The canonical action for a spelling; an unknown name stays itself. */
89
+ function canonicalAction(action) {
90
+ return ACTION_ALIASES[action] ?? action;
91
+ }
92
+ /**
93
+ * Core's own name for a clawgram action, and the only thing that makes the
94
+ * action reachable from the agent's `message` tool.
95
+ *
96
+ * Core keys its target policy by `CHANNEL_MESSAGE_ACTION_NAMES`, and an action
97
+ * outside that vocabulary is simultaneously "requires a target" and "does not
98
+ * accept a target" — there is no call that satisfies both. Declaring `chatId`
99
+ * through `messageActionTargetAliases` looks like the fix and is not: core
100
+ * resolves the channel with `getBootstrapChannelPlugin`, which only knows
101
+ * bundled channels, so a plugin channel's declaration is never read. Measured
102
+ * on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
103
+ * from the same caller, on the same chat.
104
+ *
105
+ * Every name on the right maps to core target mode `"none"` except
106
+ * `channel-info`, which is `"channelId"`: the chat arrives in
107
+ * `params.channelId`, a spelling no parser here read until 2.21.0 — so the
108
+ * call fell through to the current chat and answered about the wrong one.
109
+ * `readChatTargetParam` is the single list of accepted spellings now.
110
+ *
111
+ * These spellings are derived from `ACTION_ALIASES` rather than kept beside
112
+ * it; that core actually knows each of them is asserted against the installed
113
+ * core in `core-action-synonyms.test.ts`.
114
+ */
115
+ const CORE_VOCABULARY_SPELLINGS = [
116
+ "thread-list", "channel-list", "channel-info", "member-info", "download-file",
117
+ "channel-create", "addParticipant", "kick", "role-add", "role-remove",
118
+ ];
119
+ exports.CORE_ACTION_SYNONYMS = Object.fromEntries(CORE_VOCABULARY_SPELLINGS.map((name) => [name, ACTION_ALIASES[name]]));
120
+ /** Canonical actions that go through the chat-management gate. */
121
+ exports.MANAGE_ACTIONS = new Set([
122
+ "createGroup", "addMembers", "removeMember",
123
+ "promoteAdmin", "demoteAdmin", "transferOwnership", "inviteLink",
124
+ ]);
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ // Входящее вложение: скачать, понять, отдать текстом.
3
+ //
4
+ // Вынесено из channel.ts — файла на 3145 строк при следующем по величине
5
+ // 1165 (находка A6-11). Ни диспетчера, ни конфигурации канала здесь нет:
6
+ // только путь «файл → текст», общий у входящего контура и у `fetch-media`.
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.INBOUND_MEDIA_MAX_BYTES = void 0;
12
+ exports.understandAttachmentFile = understandAttachmentFile;
13
+ exports.readInboundAttachment = readInboundAttachment;
14
+ const node_os_1 = __importDefault(require("node:os"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const node_fs_1 = require("node:fs");
17
+ const media_1 = require("./media");
18
+ const state_dir_1 = require("./state-dir");
19
+ /** Attachments above this are left unread: a long recording or a huge image is
20
+ * a different conversation from a spoken line or a screenshot, and the
21
+ * transfer is not free. */
22
+ exports.INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
23
+ /**
24
+ * Locates the agent directory that image understanding needs.
25
+ *
26
+ * Image models are called with the agent's own credentials, so the pipeline
27
+ * refuses to run without this path — audio does not need it, which is why
28
+ * voice notes worked before images did. The platform exposes no resolver to
29
+ * plugins, so the documented layout is reconstructed here and checked before
30
+ * use: a wrong guess would fail the read anyway, and returning undefined lets
31
+ * the caller degrade instead of throwing.
32
+ */
33
+ function resolveAgentDirForMedia(cfg) {
34
+ const stateDir = (0, state_dir_1.resolveStateDir)();
35
+ const configuredId = cfg?.agents?.defaults?.id;
36
+ const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
37
+ const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
38
+ return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
39
+ }
40
+ /**
41
+ * Turns a downloaded attachment into text.
42
+ *
43
+ * Shared by the inbound path and by `fetch-media`: the backend choice lives in
44
+ * `runtime.mediaUnderstanding`, and both callers have to make exactly the same
45
+ * call — an image read on arrival and the same image read on request must not
46
+ * become two different readings because two call sites drifted.
47
+ */
48
+ async function understandAttachmentFile(params) {
49
+ const media = params.runtime?.mediaUnderstanding;
50
+ if (!media)
51
+ return undefined;
52
+ const result = params.understanding === "transcript"
53
+ ? await media.transcribeAudioFile({
54
+ filePath: params.filePath,
55
+ cfg: params.cfg,
56
+ mime: params.mimeType,
57
+ })
58
+ : await media.describeImageFile({
59
+ filePath: params.filePath,
60
+ cfg: params.cfg,
61
+ mime: params.mimeType,
62
+ agentDir: resolveAgentDirForMedia(params.cfg),
63
+ });
64
+ const text = typeof result?.text === "string" ? result.text.trim() : "";
65
+ return text || undefined;
66
+ }
67
+ async function readInboundAttachment(params) {
68
+ const media = params.runtime?.mediaUnderstanding;
69
+ const message = params.event?.message;
70
+ if (!media || !message) {
71
+ return undefined;
72
+ }
73
+ let downloaded;
74
+ try {
75
+ downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
76
+ client: params.gram.getClient(),
77
+ message,
78
+ maxBytes: exports.INBOUND_MEDIA_MAX_BYTES,
79
+ tmpDir: node_os_1.default.tmpdir(),
80
+ });
81
+ }
82
+ catch (err) {
83
+ params.log?.info?.("clawgram attachment download failed", {
84
+ accountId: params.accountId,
85
+ chatId: params.chatId,
86
+ messageId: params.messageId,
87
+ error: String(err),
88
+ });
89
+ return undefined;
90
+ }
91
+ if (!downloaded) {
92
+ return undefined;
93
+ }
94
+ try {
95
+ const read = await understandAttachmentFile({
96
+ runtime: params.runtime,
97
+ cfg: params.cfg,
98
+ filePath: downloaded.path,
99
+ mimeType: downloaded.mimeType,
100
+ understanding: downloaded.understanding,
101
+ });
102
+ if (!read) {
103
+ params.log?.info?.("clawgram attachment read empty", {
104
+ accountId: params.accountId,
105
+ chatId: params.chatId,
106
+ messageId: params.messageId,
107
+ understanding: downloaded.understanding,
108
+ });
109
+ return undefined;
110
+ }
111
+ params.log?.info?.("clawgram attachment read", {
112
+ accountId: params.accountId,
113
+ chatId: params.chatId,
114
+ messageId: params.messageId,
115
+ understanding: downloaded.understanding,
116
+ characters: read.length,
117
+ });
118
+ return { text: read, understanding: downloaded.understanding };
119
+ }
120
+ catch (err) {
121
+ params.log?.info?.("clawgram attachment read failed", {
122
+ accountId: params.accountId,
123
+ chatId: params.chatId,
124
+ messageId: params.messageId,
125
+ understanding: downloaded.understanding,
126
+ error: String(err),
127
+ });
128
+ return undefined;
129
+ }
130
+ finally {
131
+ void (async () => {
132
+ try {
133
+ const { rm } = await import("node:fs/promises");
134
+ const { dirname } = await import("node:path");
135
+ await rm(dirname(downloaded.path), { recursive: true, force: true });
136
+ }
137
+ catch {
138
+ // Leaving a temp file behind is not worth failing a delivered message.
139
+ }
140
+ })();
141
+ }
142
+ }