clawgram 2.1.0 → 2.2.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
@@ -30,6 +30,8 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
30
30
  - **Forum topic routing** — correctly routes replies to the right forum topic thread
31
31
  - **@Mention detection** — respond only when mentioned in groups (text, caption, and ID-based mentions)
32
32
  - **Read receipts** — mark messages as read
33
+ - **Emoji reactions** — acknowledge a message with a reaction instead of a reply (`react` action)
34
+ - **Chat metadata** — title, type, member count, description, forum flag and pinned message (`chatInfo` action)
33
35
  - **User allowlist** — control which user has access to send messages for direct
34
36
  - **Chat allowlist** — control which chats the assistant can access
35
37
  - **Multi-account** — run multiple Telegram accounts simultaneously
@@ -584,8 +586,8 @@ that means in practice, and what the code does about it:
584
586
 
585
587
  | Concern | Where it lives | What the plugin does |
586
588
  | --- | --- | --- |
587
- | `apiHash`, `sessionString` | `openclaw.json` | 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 |
588
- | Proxy password | `accounts.*.proxy.password` | 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 |
589
+ | `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 |
590
+ | 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 |
589
591
  | 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 |
590
592
  | Read scope | `accounts.*.readChats` | History and membership reads are confined to the listed chats. Absent means no restriction; an empty array denies everything |
591
593
  | Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions |
@@ -593,6 +595,31 @@ that means in practice, and what the code does about it:
593
595
  Two static tests (`test/no-secret-logging.test.ts`) fail the build if a message body or a credential
594
596
  is ever added back to a log call, or if the auth flow prints the session string unprompted.
595
597
 
598
+ ### Keeping credentials out of the config file
599
+
600
+ `apiHash`, `sessionString`, `proxy.username` and `proxy.password` accept a
601
+ [SecretRef](https://docs.openclaw.ai/gateway/secrets) in place of a literal value:
602
+
603
+ ```json
604
+ {
605
+ "channels": {
606
+ "clawgram": {
607
+ "accounts": {
608
+ "default": {
609
+ "apiId": 12345678,
610
+ "apiHash": { "source": "file", "provider": "corp", "id": "/telegram/api-hash" },
611
+ "sessionString": { "source": "file", "provider": "corp", "id": "/telegram/session" }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ }
617
+ ```
618
+
619
+ References are resolved once per account at start-up. If one cannot be resolved the account fails to
620
+ start, naming the field but never the value — and the client refuses to be constructed while any
621
+ reference remains, so an unresolved secret can never travel to Telegram as a credential.
622
+
596
623
  Found a security issue? Open an issue at
597
624
  [github.com/d3pre5s/clawgram/issues](https://github.com/d3pre5s/clawgram/issues) — or, if it is
598
625
  sensitive, contact the maintainer directly instead of filing publicly.
@@ -617,6 +644,16 @@ or
617
644
  npm run clawgram-cli:auth
618
645
  ```
619
646
 
647
+ ## Releases
648
+
649
+ Every push and pull request is built and tested on Node 22 and 24. Releases are cut by pushing a
650
+ `v<version>` tag: CI then builds from a clean checkout and publishes to npm with
651
+ [provenance](https://docs.npmjs.com/generating-provenance-statements), so each published tarball is
652
+ verifiably tied to the commit it was built from. The tag and `package.json` must agree or the job
653
+ refuses to publish.
654
+
655
+ Changes per version: [CHANGELOG.md](CHANGELOG.md).
656
+
620
657
  ## License
621
658
 
622
659
  MIT
package/dist/channel.js CHANGED
@@ -16,6 +16,10 @@ const gramjs_client_1 = require("./gramjs-client");
16
16
  const normalize_1 = require("./normalize");
17
17
  const history_1 = require("./history");
18
18
  const joins_1 = require("./joins");
19
+ const reactions_1 = require("./reactions");
20
+ const chat_info_1 = require("./chat-info");
21
+ const secret_refs_1 = require("./secret-refs");
22
+ const secret_ref_runtime_1 = require("openclaw/plugin-sdk/secret-ref-runtime");
19
23
  const group_reply_address_1 = require("./group-reply-address");
20
24
  const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
21
25
  const helpers_1 = require("./helpers");
@@ -86,11 +90,15 @@ const createChannelPlugin = (runtimes) => {
86
90
  "When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
87
91
  "Explicit targets may be @username, numeric Telegram user id, phone/contact resolvable by Telegram, group chat ids, or clawgram:<target>.",
88
92
  "For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
93
+ "Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
94
+ "Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
89
95
  ],
90
96
  messageToolCapabilities: () => [
91
97
  "clawgram can reply in the current Telegram conversation when no explicit target is provided.",
92
98
  "clawgram can send text messages to direct chats and groups from the connected personal account.",
93
99
  "clawgram supports Telegram forum topics via the `threadId` parameter on group sends.",
100
+ "clawgram can add and clear emoji reactions on messages. A plain Telegram account holds one reaction per message, so a new emoji replaces the previous one.",
101
+ "clawgram can describe a chat via `chatInfo`: title, type (direct/group/supergroup/channel), member count, description, whether it is a forum, and the pinned message id.",
94
102
  ],
95
103
  },
96
104
  config: {
@@ -105,8 +113,8 @@ const createChannelPlugin = (runtimes) => {
105
113
  const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
106
114
  return {
107
115
  apiId: Number(account?.apiId),
108
- apiHash: String(account?.apiHash ?? ""),
109
- sessionString: String(account?.sessionString ?? ""),
116
+ apiHash: (0, secret_refs_1.readSecretInput)(account?.apiHash),
117
+ sessionString: (0, secret_refs_1.readSecretInput)(account?.sessionString),
110
118
  allowFrom: (0, helpers_1.resolveAllowFrom)(account?.allowFrom),
111
119
  groups: (0, helpers_1.resolveGroups)(account?.groups),
112
120
  readChats: readAccountReadChats(account),
@@ -127,7 +135,32 @@ const createChannelPlugin = (runtimes) => {
127
135
  await runtimes.get(accountId)?.stop().catch(() => undefined);
128
136
  runtimes.delete(accountId);
129
137
  }
130
- const gram = new gramjs_client_1.GramJsClientManager(account);
138
+ // Credentials may be SecretRefs rather than literals. Resolve them here,
139
+ // once per account start, and hand the client only resolved values.
140
+ // Failing loudly beats starting with a blank credential and getting an
141
+ // authentication error that says nothing about the real cause.
142
+ const secretRefs = (0, secret_refs_1.collectAccountSecretRefs)(account);
143
+ let resolvedAccount = account;
144
+ if (secretRefs.length > 0) {
145
+ // `source` is whatever the config says; OpenClaw validates it and
146
+ // reports an unknown source better than a local check would.
147
+ const values = await (0, secret_ref_runtime_1.resolveSecretRefValues)(secretRefs, {
148
+ config: cfg,
149
+ env: process.env,
150
+ });
151
+ const applied = (0, secret_refs_1.applyAccountSecrets)(account, values);
152
+ if (applied.missing.length > 0) {
153
+ // Field names only. The value is what we are protecting, and the
154
+ // reference itself names a location in the secret store.
155
+ throw new Error(`clawgram: could not resolve secret references for ${applied.missing.join(", ")}`);
156
+ }
157
+ log?.info?.("clawgram resolved secret references", {
158
+ accountId,
159
+ fields: secretRefs.length,
160
+ });
161
+ resolvedAccount = applied.account;
162
+ }
163
+ const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
131
164
  await gram.start();
132
165
  runtimes.set(accountId, gram);
133
166
  const pairing = (0, channel_pairing_1.createChannelPairingController)({
@@ -918,7 +951,7 @@ const createChannelPlugin = (runtimes) => {
918
951
  return null;
919
952
  }
920
953
  return {
921
- actions: ["send", "read", "participants", "joins"],
954
+ actions: ["send", "read", "participants", "joins", "react", "chatInfo"],
922
955
  capabilities: [],
923
956
  };
924
957
  },
@@ -1030,6 +1063,82 @@ const createChannelPlugin = (runtimes) => {
1030
1063
  joins: selected,
1031
1064
  });
1032
1065
  }
1066
+ // Describing a chat is a read, so the same `readChats` scope that gates
1067
+ // history gates it too — this must not become a way to learn the title
1068
+ // and size of a chat the account was never allowed to read.
1069
+ if (action === "chatInfo" || action === "getChatInfo"
1070
+ || action === "chatMetadata" || action === "getChatMetadata") {
1071
+ const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
1072
+ const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
1073
+ if (!chatInfoAccountId) {
1074
+ throw new Error("clawgram: no configured account found");
1075
+ }
1076
+ if (!(0, history_1.isChatReadable)(chatInfoParams.target, resolveAccountReadChats(cfg, chatInfoAccountId))) {
1077
+ actionLog.warn("clawgram chatInfo refused: chat outside read scope", {
1078
+ accountId: chatInfoAccountId,
1079
+ target: chatInfoParams.target,
1080
+ });
1081
+ throw new Error(`clawgram: not-allowed-chat ${chatInfoParams.target}`);
1082
+ }
1083
+ const chatInfoGram = runtimes.get(chatInfoAccountId);
1084
+ if (!chatInfoGram) {
1085
+ throw new Error(`clawgram: runtime not found for account ${chatInfoAccountId}`);
1086
+ }
1087
+ const { entity, full } = await chatInfoGram.getChatInfo(chatInfoParams.target);
1088
+ const info = (0, chat_info_1.describeChat)(entity, full);
1089
+ // Type and size only. The title of a private chat is as personal as
1090
+ // its contents and has no business in a debugging log.
1091
+ actionLog.info("clawgram handleAction chatInfo completed", {
1092
+ accountId: chatInfoAccountId,
1093
+ type: info.type,
1094
+ memberCount: info.memberCount ?? null,
1095
+ isForum: info.isForum ?? null,
1096
+ });
1097
+ return (0, core_1.jsonResult)({
1098
+ ok: true,
1099
+ accountId: chatInfoAccountId,
1100
+ chat: { ...info, chatId: info.chatId ?? chatInfoParams.target },
1101
+ });
1102
+ }
1103
+ // A reaction is an outbound act on someone else's message, so it is
1104
+ // gated like sending rather than like reading — and it respects
1105
+ // `dryRun`, which reading does not need to.
1106
+ if (action === "react") {
1107
+ const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
1108
+ const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
1109
+ if (!reactionAccountId) {
1110
+ throw new Error("clawgram: no configured account found");
1111
+ }
1112
+ actionLog.info("clawgram handleAction react", {
1113
+ accountId: reactionAccountId,
1114
+ dryRun: dryRun === true,
1115
+ target: reactionParams.target,
1116
+ messageId: reactionParams.messageId,
1117
+ remove: reactionParams.remove,
1118
+ });
1119
+ if (dryRun === true) {
1120
+ return (0, core_1.jsonResult)({
1121
+ ok: true,
1122
+ dryRun: true,
1123
+ accountId: reactionAccountId,
1124
+ chatId: reactionParams.target,
1125
+ messageId: reactionParams.messageId,
1126
+ removed: reactionParams.remove,
1127
+ });
1128
+ }
1129
+ const reactionGram = runtimes.get(reactionAccountId);
1130
+ if (!reactionGram) {
1131
+ throw new Error(`clawgram: runtime not found for account ${reactionAccountId}`);
1132
+ }
1133
+ await reactionGram.sendReaction(reactionParams);
1134
+ return (0, core_1.jsonResult)({
1135
+ ok: true,
1136
+ accountId: reactionAccountId,
1137
+ chatId: reactionParams.target,
1138
+ messageId: reactionParams.messageId,
1139
+ removed: reactionParams.remove,
1140
+ });
1141
+ }
1033
1142
  if (action !== "send") {
1034
1143
  throw new Error(`clawgram: unsupported message action ${action}`);
1035
1144
  }
@@ -1086,7 +1195,30 @@ const createChannelPlugin = (runtimes) => {
1086
1195
  chatId: to,
1087
1196
  replyToId,
1088
1197
  });
1089
- const text = (0, helpers_1.prefixReplyTextToAddress)((0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n"), groupReplyAddress);
1198
+ const requestedText = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
1199
+ // `NO_REPLY` is OpenClaw's "say nothing" sentinel. The inbound pipeline
1200
+ // and core both strip it, but an explicit `message.action` call is
1201
+ // neither path — and the SDK itself prompts agents to send a message
1202
+ // and *then* answer NO_REPLY, so the two are one slip apart. Posting
1203
+ // the token into a work chat looks like the assistant malfunctioning.
1204
+ //
1205
+ // Checked before the reply-address prefix on purpose: prefixing first
1206
+ // leaves "Name: " behind, which is not empty, and the token goes out.
1207
+ // That is precisely how it once reached the inbound path.
1208
+ if (requestedText.trim() && (0, helpers_1.isSilentReplyText)(requestedText)) {
1209
+ actionLog.info("clawgram suppressing silent send", {
1210
+ accountId: resolvedAccountId,
1211
+ to,
1212
+ });
1213
+ return (0, core_1.jsonResult)({
1214
+ ok: true,
1215
+ skipped: "silent",
1216
+ sent: false,
1217
+ to,
1218
+ accountId: resolvedAccountId,
1219
+ });
1220
+ }
1221
+ const text = (0, helpers_1.prefixReplyTextToAddress)(requestedText, groupReplyAddress);
1090
1222
  if (!text) {
1091
1223
  throw new Error("clawgram: message text is required");
1092
1224
  }
@@ -1161,6 +1293,17 @@ const createChannelPlugin = (runtimes) => {
1161
1293
  threadId: ctx.threadId ?? null,
1162
1294
  textLength: ctx.text.length,
1163
1295
  });
1296
+ // Core normalizes reply payloads and drops the silent token before a
1297
+ // channel is called, so this should never see one. "Should never" is
1298
+ // what the inbound path was assumed to be too, right until it posted a
1299
+ // token — and the check costs a string comparison.
1300
+ if (ctx.text.trim() && (0, helpers_1.isSilentReplyText)(ctx.text)) {
1301
+ actionLog.info("clawgram suppressing silent outbound send", {
1302
+ accountId: ctx.accountId,
1303
+ rawTo: ctx.to,
1304
+ });
1305
+ return { skipped: "silent" };
1306
+ }
1164
1307
  const gram = runtimes.get(ctx.accountId);
1165
1308
  if (!gram) {
1166
1309
  throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Chat metadata — what a chat *is*, as opposed to what was said in it.
4
+ *
5
+ * Without this the assistant can read a chat and list its members but cannot
6
+ * say which chat it is standing in: the title, whether it is a work supergroup
7
+ * or a one-to-one conversation, whether replies must be addressed to a forum
8
+ * topic. That had to come from a hand-maintained allowlist, which goes stale
9
+ * the moment a chat is renamed.
10
+ *
11
+ * Telegram splits this across two objects — the entity carries the title and
12
+ * the type flags, the full object carries the description, the member count
13
+ * and the pinned message — so both are taken and merged here. Parsing is pure
14
+ * so it can be tested without a Telegram client; the transport lives in
15
+ * `GramJsClientManager.getChatInfo`.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.parseChatInfoParams = parseChatInfoParams;
19
+ exports.describeChat = describeChat;
20
+ function parseChatInfoParams(params, toolContext) {
21
+ const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
22
+ const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
23
+ if (!target) {
24
+ throw new Error("clawgram: chatInfo requires a chatId");
25
+ }
26
+ return { target };
27
+ }
28
+ /**
29
+ * GramJS carries ids and counts as `big-integer` objects as often as native
30
+ * numbers — the shape that once made `senderId` come back silently undefined.
31
+ */
32
+ function readNumber(value) {
33
+ if (typeof value === "number") {
34
+ return Number.isFinite(value) ? value : undefined;
35
+ }
36
+ if (value === undefined || value === null) {
37
+ return undefined;
38
+ }
39
+ const parsed = Number(String(value));
40
+ return Number.isFinite(parsed) ? parsed : undefined;
41
+ }
42
+ function readString(value) {
43
+ if (typeof value !== "string") {
44
+ return undefined;
45
+ }
46
+ const trimmed = value.trim();
47
+ return trimmed === "" ? undefined : trimmed;
48
+ }
49
+ function readId(value) {
50
+ if (value === undefined || value === null) {
51
+ return undefined;
52
+ }
53
+ const asString = String(value).trim();
54
+ return asString === "" || asString === "[object Object]" ? undefined : asString;
55
+ }
56
+ function resolveType(entity) {
57
+ switch (entity?.className) {
58
+ case "User":
59
+ return "direct";
60
+ case "Chat":
61
+ return "group";
62
+ case "Channel":
63
+ return entity.broadcast === true ? "channel" : "supergroup";
64
+ default:
65
+ return "unknown";
66
+ }
67
+ }
68
+ /** A user has no title, so the displayed name is assembled from what exists. */
69
+ function resolveUserTitle(entity) {
70
+ const parts = [readString(entity?.firstName), readString(entity?.lastName)].filter(Boolean);
71
+ return parts.length > 0 ? parts.join(" ") : undefined;
72
+ }
73
+ function describeChat(entity, full) {
74
+ const raw = entity;
75
+ const fullChat = full;
76
+ const type = resolveType(raw);
77
+ const info = {
78
+ chatId: readId(raw?.id),
79
+ type,
80
+ title: type === "direct" ? resolveUserTitle(raw) : readString(raw?.title),
81
+ username: readString(raw?.username),
82
+ about: readString(fullChat?.about),
83
+ pinnedMessageId: readId(fullChat?.pinnedMsgId),
84
+ };
85
+ if (type === "direct") {
86
+ // Member count is meaningless for a two-person conversation, and reporting
87
+ // "1" or "2" would invite a reader to treat it as a group of that size.
88
+ return { ...info, isBot: raw?.bot === true };
89
+ }
90
+ if (type === "supergroup" || type === "channel") {
91
+ info.isForum = raw?.forum === true;
92
+ }
93
+ // The full object is fetched now; the entity may come from a cache that
94
+ // predates the last few joins, so prefer the fresher number.
95
+ info.memberCount = readNumber(fullChat?.participantsCount) ?? readNumber(raw?.participantsCount);
96
+ return info;
97
+ }
@@ -4,6 +4,7 @@ exports.GramJsClientManager = void 0;
4
4
  const telegram_1 = require("telegram");
5
5
  const sessions_1 = require("telegram/sessions");
6
6
  const proxy_config_1 = require("./proxy-config");
7
+ const secret_refs_1 = require("./secret-refs");
7
8
  const history_1 = require("./history");
8
9
  function toStringId(value) {
9
10
  if (value === null || value === undefined)
@@ -168,6 +169,15 @@ class GramJsClientManager {
168
169
  started = false;
169
170
  constructor(config) {
170
171
  this.config = config;
172
+ // Credentials may be written as SecretRefs; account start-up resolves them
173
+ // before constructing this. Refusing here rather than trusting the caller
174
+ // keeps an unresolved reference from being sent to Telegram as the literal
175
+ // string "[object Object]" — which comes back as a complaint about the
176
+ // credential, not about the secret that failed to resolve.
177
+ if ((0, secret_refs_1.hasUnresolvedSecretRef)(config)) {
178
+ throw new Error("clawgram: account credentials still contain unresolved secret references; "
179
+ + "they must be resolved before the client is created");
180
+ }
171
181
  const clientOptions = (0, proxy_config_1.buildTelegramClientOptions)(config.proxy);
172
182
  this.proxy = clientOptions.proxy;
173
183
  this.client = new telegram_1.TelegramClient(new sessions_1.StringSession(config.sessionString), config.apiId, config.apiHash, clientOptions);
@@ -288,6 +298,56 @@ class GramJsClientManager {
288
298
  ...replyParams,
289
299
  });
290
300
  }
301
+ /**
302
+ * Reads what a chat is: title, type, member count, description, pinned
303
+ * message.
304
+ *
305
+ * Telegram has no single "describe this chat" call — the full object comes
306
+ * from a different method per chat type, and none of them accepts the other's
307
+ * peer. The entity is resolved first precisely to find out which one to ask.
308
+ * A failing full request is not fatal: the entity alone already carries the
309
+ * title and the type, and a partial answer beats an error when the caller
310
+ * only wanted to know where it is.
311
+ */
312
+ async getChatInfo(target) {
313
+ const resolved = await this.resolvePeer(target);
314
+ const entity = await this.client.getEntity(resolved.peer);
315
+ const full = await (async () => {
316
+ switch (entity?.className) {
317
+ case "Channel":
318
+ return (await this.client.invoke(new telegram_1.Api.channels.GetFullChannel({
319
+ channel: entity,
320
+ }))).fullChat;
321
+ case "Chat":
322
+ return (await this.client.invoke(new telegram_1.Api.messages.GetFullChat({
323
+ chatId: entity.id,
324
+ }))).fullChat;
325
+ case "User":
326
+ return (await this.client.invoke(new telegram_1.Api.users.GetFullUser({
327
+ id: entity,
328
+ }))).fullUser;
329
+ default:
330
+ return undefined;
331
+ }
332
+ })().catch(() => undefined);
333
+ return { entity, full };
334
+ }
335
+ /**
336
+ * Adds or clears this account's reaction on a message.
337
+ *
338
+ * Telegram models "no reaction" as an empty reaction list rather than a
339
+ * separate call, so removal is the same request with nothing in it. A plain
340
+ * account may hold only one reaction per message, which is why removing a
341
+ * specific emoji and clearing collapse to the same thing here.
342
+ */
343
+ async sendReaction(args) {
344
+ const resolved = await this.resolvePeer(args.target);
345
+ await this.client.invoke(new telegram_1.Api.messages.SendReaction({
346
+ peer: resolved.peer,
347
+ msgId: args.messageId,
348
+ reaction: args.remove ? [] : [new telegram_1.Api.ReactionEmoji({ emoticon: args.emoji })],
349
+ }));
350
+ }
291
351
  /**
292
352
  * Reads a window of chat history.
293
353
  *
package/dist/history.js CHANGED
@@ -24,6 +24,7 @@ exports.normalizeHistoryMessage = normalizeHistoryMessage;
24
24
  exports.collectHistoryWindow = collectHistoryWindow;
25
25
  exports.HISTORY_DEFAULT_LIMIT = 100;
26
26
  exports.HISTORY_MAX_LIMIT = 500;
27
+ const media_1 = require("./media");
27
28
  /**
28
29
  * Matches `normalize.ts` and `gramjs-client.ts` deliberately.
29
30
  *
@@ -258,6 +259,7 @@ function normalizeHistoryMessage(msg, fallbackChatId) {
258
259
  sentAt: timestamp === undefined ? undefined : new Date(timestamp * 1000).toISOString(),
259
260
  replyToMessageId: toStringId(msg?.replyTo?.replyToMsgId) ?? toStringId(msg?.replyToMsgId),
260
261
  messageThreadId: toStringId(msg?.replyTo?.replyToTopId) ?? toStringId(msg?.replyToTopId),
262
+ media: (0, media_1.describeMedia)(msg?.media),
261
263
  isOutgoing: msg?.out === true,
262
264
  };
263
265
  }
package/dist/media.js ADDED
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ /**
3
+ * Attachment metadata for history reads.
4
+ *
5
+ * A message whose whole content is a screenshot used to arrive as an empty
6
+ * `text` — indistinguishable from a message that said nothing. That is a real
7
+ * loss for a reader summarizing a work chat, where the screenshot of the error
8
+ * *is* the report.
9
+ *
10
+ * Only metadata is produced. Nothing is downloaded: knowing that "spec.pdf,
11
+ * 240 KB" was posted is what a summary needs, and fetching the bytes of every
12
+ * attachment in a window would be a different feature with different costs.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.describeMedia = describeMedia;
16
+ /**
17
+ * GramJS carries numbers as `big-integer` objects as often as native numbers —
18
+ * the same shape that once made `senderId` silently undefined. Anything that
19
+ * stringifies to digits is accepted.
20
+ */
21
+ function readNumber(value) {
22
+ if (typeof value === "number") {
23
+ return Number.isFinite(value) ? value : undefined;
24
+ }
25
+ if (value === undefined || value === null) {
26
+ return undefined;
27
+ }
28
+ const parsed = Number(String(value));
29
+ return Number.isFinite(parsed) ? parsed : undefined;
30
+ }
31
+ function readAttributes(document) {
32
+ const attributes = document?.attributes;
33
+ return Array.isArray(attributes) ? attributes : [];
34
+ }
35
+ function findAttribute(document, className) {
36
+ return readAttributes(document).find((attribute) => attribute?.className === className);
37
+ }
38
+ const SIMPLE_KINDS = {
39
+ MessageMediaPhoto: "photo",
40
+ MessageMediaPoll: "poll",
41
+ MessageMediaGeo: "geo",
42
+ MessageMediaGeoLive: "geo",
43
+ MessageMediaContact: "contact",
44
+ MessageMediaWebPage: "webpage",
45
+ };
46
+ /**
47
+ * Documents are the ambiguous case: a voice note, a video, a sticker and a
48
+ * spreadsheet are all `MessageMediaDocument`, separated only by attributes.
49
+ */
50
+ function describeDocument(document) {
51
+ const mimeType = typeof document?.mimeType === "string" ? document.mimeType : undefined;
52
+ const size = readNumber(document?.size);
53
+ const fileName = findAttribute(document, "DocumentAttributeFilename")?.fileName;
54
+ const base = {
55
+ kind: "document",
56
+ fileName: typeof fileName === "string" ? fileName : undefined,
57
+ mimeType,
58
+ size,
59
+ };
60
+ const sticker = findAttribute(document, "DocumentAttributeSticker");
61
+ if (sticker) {
62
+ return { ...base, kind: "sticker", emoji: typeof sticker.alt === "string" ? sticker.alt : undefined };
63
+ }
64
+ const audio = findAttribute(document, "DocumentAttributeAudio");
65
+ if (audio) {
66
+ const duration = readNumber(audio.duration);
67
+ return {
68
+ ...base,
69
+ kind: audio.voice === true ? "voice" : "audio",
70
+ durationSeconds: duration === undefined ? undefined : Math.round(duration),
71
+ };
72
+ }
73
+ const video = findAttribute(document, "DocumentAttributeVideo");
74
+ if (video) {
75
+ const duration = readNumber(video.duration);
76
+ return {
77
+ ...base,
78
+ kind: "video",
79
+ durationSeconds: duration === undefined ? undefined : Math.round(duration),
80
+ };
81
+ }
82
+ return base;
83
+ }
84
+ function describeMedia(media) {
85
+ const raw = media;
86
+ const className = raw?.className;
87
+ if (!className || typeof className !== "string" || className === "MessageMediaEmpty") {
88
+ return undefined;
89
+ }
90
+ const simple = SIMPLE_KINDS[className];
91
+ if (simple) {
92
+ return { kind: simple };
93
+ }
94
+ if (className === "MessageMediaDocument") {
95
+ return describeDocument(raw.document);
96
+ }
97
+ // Telegram keeps adding media types. An unmodelled one still has to show up
98
+ // as "something was attached" — a blank message is the failure being fixed.
99
+ return { kind: "other", telegramType: className };
100
+ }
@@ -37,12 +37,29 @@ function resolveSocksType(value) {
37
37
  }
38
38
  return socksType;
39
39
  }
40
+ /** Shape check only; the real resolution happens in `secret-refs`. */
41
+ function asProxySecretRef(value) {
42
+ if (!value || typeof value !== "object")
43
+ return undefined;
44
+ const candidate = value;
45
+ return typeof candidate.source === "string"
46
+ && typeof candidate.provider === "string"
47
+ && typeof candidate.id === "string"
48
+ ? { source: candidate.source, provider: candidate.provider, id: candidate.id }
49
+ : undefined;
50
+ }
40
51
  function resolveProxyCredential(value, field) {
41
52
  if (value === undefined || value === null) {
42
53
  return undefined;
43
54
  }
55
+ // A SecretRef is resolved later, at account start-up. Rejecting it here
56
+ // would make the config unloadable before the resolver ever runs.
57
+ const ref = asProxySecretRef(value);
58
+ if (ref) {
59
+ return ref;
60
+ }
44
61
  if (typeof value !== "string") {
45
- throw new Error(`clawgram: proxy.${field} must be a string.`);
62
+ throw new Error(`clawgram: proxy.${field} must be a string or a secret reference.`);
46
63
  }
47
64
  return value.trim() ? value : undefined;
48
65
  }
@@ -95,11 +112,29 @@ function buildTelegramClientOptions(proxy) {
95
112
  connectionRetries: CONNECTION_RETRIES,
96
113
  };
97
114
  }
115
+ // Secret references are substituted before the client is built, and the
116
+ // account start-up refuses to continue while any remain — so by here the
117
+ // credentials are strings. The cast states that, and `assertProxyResolved`
118
+ // enforces it rather than trusting it.
119
+ assertProxyResolved(resolved);
98
120
  return {
99
121
  connectionRetries: CONNECTION_RETRIES,
100
122
  proxy: resolved,
101
123
  };
102
124
  }
125
+ /**
126
+ * Defence in depth. If an unresolved reference ever reached GramJS it would be
127
+ * sent as the literal string "[object Object]" — a credential-shaped value
128
+ * travelling to a proxy server.
129
+ */
130
+ function assertProxyResolved(proxy) {
131
+ for (const field of ["username", "password"]) {
132
+ const value = proxy[field];
133
+ if (value !== undefined && typeof value !== "string") {
134
+ throw new Error(`clawgram: proxy.${field} is still an unresolved secret reference.`);
135
+ }
136
+ }
137
+ }
103
138
  /** Credential-free proxy summary safe to log. */
104
139
  function describeProxy(proxy) {
105
140
  return proxy ? `socks${proxy.socksType}` : undefined;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ /**
3
+ * Emoji reactions — the `react` action of OpenClaw's message tool.
4
+ *
5
+ * Semantics follow the tool contract, and deliberately match what OpenClaw's
6
+ * own Telegram channel does: an empty `emoji` clears this account's reactions,
7
+ * and `remove: true` also clears but still requires a non-empty `emoji` so the
8
+ * tool call stays self-describing.
9
+ *
10
+ * Parsing lives here, apart from the network, so the argument handling can be
11
+ * tested without a Telegram connection.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.parseReactionParams = parseReactionParams;
15
+ /** Accepts the boolean and the string a JSON-ish caller may send for it. */
16
+ function readBooleanFlag(value) {
17
+ return value === true || value === "true";
18
+ }
19
+ /**
20
+ * Message ids are positive integers. A date arriving here would resolve to
21
+ * some unrelated message, so anything else is refused rather than coerced —
22
+ * the same reasoning as the history parser's id/date guard.
23
+ */
24
+ function parseMessageId(value) {
25
+ if (value === undefined || value === null || value === "") {
26
+ return undefined;
27
+ }
28
+ const parsed = Number(value);
29
+ if (!Number.isInteger(parsed) || parsed <= 0) {
30
+ throw new Error(`clawgram: react messageId must be a positive integer, got ${JSON.stringify(value)}`);
31
+ }
32
+ return parsed;
33
+ }
34
+ function parseReactionParams(params, toolContext) {
35
+ const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
36
+ const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
37
+ if (!target) {
38
+ throw new Error("clawgram: react requires a chatId");
39
+ }
40
+ const messageId = parseMessageId(params.messageId ?? params.msgId ?? params.message_id)
41
+ ?? parseMessageId(toolContext?.currentMessageId);
42
+ if (messageId === undefined) {
43
+ throw new Error("clawgram: react requires a messageId");
44
+ }
45
+ const rawEmoji = params.emoji;
46
+ if (rawEmoji !== undefined && rawEmoji !== null && typeof rawEmoji !== "string") {
47
+ throw new Error("clawgram: react emoji must be a string");
48
+ }
49
+ const removeFlag = readBooleanFlag(params.remove);
50
+ const emoji = typeof rawEmoji === "string" ? rawEmoji.trim() : undefined;
51
+ // Only two shapes are meaningful: add this emoji, or clear. A call with
52
+ // neither is a caller mistake, not an empty-string removal.
53
+ if (emoji === undefined) {
54
+ throw new Error("clawgram: react requires an emoji");
55
+ }
56
+ if (removeFlag && emoji === "") {
57
+ throw new Error("clawgram: react with remove requires an emoji");
58
+ }
59
+ return {
60
+ target,
61
+ messageId,
62
+ emoji,
63
+ remove: removeFlag || emoji === "",
64
+ };
65
+ }
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ /**
3
+ * SecretRef support for the account's credentials.
4
+ *
5
+ * `apiHash` and `sessionString` are bearer credentials for the whole Telegram
6
+ * account, and until now they could only live as plaintext in `openclaw.json` —
7
+ * a file that gets backed up, copied between machines and pasted into issues.
8
+ * Every other secret in this deployment is a reference resolved at start-up;
9
+ * these were the exception. Proxy credentials are here for the same reason.
10
+ *
11
+ * A reference is `{ source, provider, id }`, and OpenClaw resolves a batch of
12
+ * them into a map keyed by `source:provider:id`. Collecting and substituting is
13
+ * kept pure so it can be tested without a secret store; the resolution call
14
+ * itself lives in the channel's account start-up.
15
+ *
16
+ * Nothing here logs a value, and an unresolved reference is reported by field
17
+ * name only. A missing secret is a configuration error, and the error message
18
+ * for it must not become the leak it was meant to prevent.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.secretRefKey = secretRefKey;
22
+ exports.collectAccountSecretRefs = collectAccountSecretRefs;
23
+ exports.applyAccountSecrets = applyAccountSecrets;
24
+ exports.hasUnresolvedSecretRef = hasUnresolvedSecretRef;
25
+ exports.readSecretInput = readSecretInput;
26
+ /** Credential fields that accept a reference, in the order they are reported. */
27
+ const ACCOUNT_SECRET_FIELDS = ["apiHash", "sessionString"];
28
+ const PROXY_SECRET_FIELDS = ["username", "password"];
29
+ /**
30
+ * Must match OpenClaw's own keying, or every lookup misses and a correctly
31
+ * configured secret looks unresolvable.
32
+ */
33
+ function secretRefKey(ref) {
34
+ return `${ref.source}:${ref.provider}:${ref.id}`;
35
+ }
36
+ /**
37
+ * Shape check only. A partially written reference is treated as "not a
38
+ * reference" rather than as an error, so a typo cannot be mistaken for a
39
+ * resolvable secret and silently blank a credential.
40
+ */
41
+ function asSecretRef(value) {
42
+ if (!value || typeof value !== "object") {
43
+ return undefined;
44
+ }
45
+ const candidate = value;
46
+ const { source, provider, id } = candidate;
47
+ if (typeof source !== "string" || typeof provider !== "string" || typeof id !== "string") {
48
+ return undefined;
49
+ }
50
+ if (!source.trim() || !provider.trim() || !id.trim()) {
51
+ return undefined;
52
+ }
53
+ return { source, provider, id };
54
+ }
55
+ /** Every credential slot, as `[path, value]` pairs — account fields and proxy alike. */
56
+ function* eachSecretSlot(account) {
57
+ for (const field of ACCOUNT_SECRET_FIELDS) {
58
+ yield [field, account?.[field]];
59
+ }
60
+ for (const field of PROXY_SECRET_FIELDS) {
61
+ yield [`proxy.${field}`, account?.proxy?.[field]];
62
+ }
63
+ }
64
+ /**
65
+ * References this account needs resolved, deduplicated: two fields pointing at
66
+ * the same secret must not become two lookups.
67
+ */
68
+ function collectAccountSecretRefs(account) {
69
+ const byKey = new Map();
70
+ for (const [, value] of eachSecretSlot(account)) {
71
+ const ref = asSecretRef(value);
72
+ if (ref) {
73
+ byKey.set(secretRefKey(ref), ref);
74
+ }
75
+ }
76
+ return [...byKey.values()];
77
+ }
78
+ /**
79
+ * Substitutes resolved values into a copy of the account.
80
+ *
81
+ * A value that did not resolve is left as the reference object and its field
82
+ * named in `missing`. Writing the reference through as a string would produce
83
+ * "[object Object]", which Telegram rejects with a complaint about the
84
+ * credential itself — sending whoever reads it to check a secret that was
85
+ * never the problem.
86
+ */
87
+ function applyAccountSecrets(account, values) {
88
+ const missing = [];
89
+ const resolved = { ...account };
90
+ if (account?.proxy && typeof account.proxy === "object") {
91
+ resolved.proxy = { ...account.proxy };
92
+ }
93
+ for (const field of ACCOUNT_SECRET_FIELDS) {
94
+ const ref = asSecretRef(account?.[field]);
95
+ if (!ref) {
96
+ continue;
97
+ }
98
+ const value = values.get(secretRefKey(ref));
99
+ if (typeof value === "string") {
100
+ resolved[field] = value;
101
+ }
102
+ else {
103
+ missing.push(field);
104
+ }
105
+ }
106
+ for (const field of PROXY_SECRET_FIELDS) {
107
+ const ref = asSecretRef(account?.proxy?.[field]);
108
+ if (!ref) {
109
+ continue;
110
+ }
111
+ const value = values.get(secretRefKey(ref));
112
+ if (typeof value === "string") {
113
+ resolved.proxy[field] = value;
114
+ }
115
+ else {
116
+ missing.push(`proxy.${field}`);
117
+ }
118
+ }
119
+ return { account: resolved, missing };
120
+ }
121
+ /**
122
+ * True while any credential is still a reference. Used as a last gate before
123
+ * handing the account to GramJS, where an unresolved value would travel into a
124
+ * login attempt.
125
+ */
126
+ function hasUnresolvedSecretRef(account) {
127
+ for (const [, value] of eachSecretSlot(account)) {
128
+ if (asSecretRef(value)) {
129
+ return true;
130
+ }
131
+ }
132
+ return false;
133
+ }
134
+ /**
135
+ * Reads a credential straight out of the config.
136
+ *
137
+ * A reference is returned untouched, for resolution later; everything else is
138
+ * stringified exactly as the config reader always did. The point is the first
139
+ * half: `String(ref)` yields "[object Object]", which travels into a Telegram
140
+ * login and returns as a complaint about the credential rather than about the
141
+ * secret that failed to resolve.
142
+ */
143
+ function readSecretInput(value) {
144
+ const ref = asSecretRef(value);
145
+ if (ref) {
146
+ return ref;
147
+ }
148
+ return String(value ?? "");
149
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.1.0",
3
+ "version": "2.2.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": {
7
7
  "build": "tsc -p tsconfig.json",
8
8
  "build:test": "tsc -p tsconfig.test.json",
9
9
  "test": "npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
10
+ "prepublishOnly": "npm run build && npm test",
10
11
  "clawgram-cli": "node dist/clawgram-cli.js",
11
12
  "clawgram-cli:hello": "node dist/clawgram-cli.js --hello",
12
13
  "clawgram-cli:auth": "node dist/clawgram-cli.js --auth"