clawgram 2.24.0 → 2.25.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
@@ -256,10 +256,11 @@ loud where it does occur.
256
256
  | `apiHash` | string | required | Telegram API hash |
257
257
  | `sessionString` | string | `""` | Authenticated StringSession |
258
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 |
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, empty or containing `*` means no operator is identified and the notices are dropped everywhere — they stay in the run diagnostics, the job's `lastError` and the gateway log. `allowFrom` is **not** a fallback (2.25.0; it was until then, which made every allowed sender an operator) |
260
260
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
261
261
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
262
262
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
263
+ | `sendChats` | string[] | unset | Chats the assistant may **send to** — `send`, `upload-file`, `react` and core's own delivery path (`--deliver`, sub-agent announcements). Absent = every chat; `[]` = none. Phone-number targets are refused regardless (2.18.0; core delivery covered since 2.25.0) |
263
264
  | `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
264
265
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
265
266
  | `reactionModel` | string | unset | Model ref or alias for the emoji pick on a silent mention. Unset = the agent's own model. Needs `plugins.entries.clawgram.llm.allowModelOverride: true` in the gateway config; without it the override is refused and the pick quietly falls back to the default model |
package/dist/channel.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createChannelPlugin = exports.canonicalAction = exports.CORE_ACTION_SYNONYMS = void 0;
7
+ exports.resolveAccountOperatorIds = resolveAccountOperatorIds;
7
8
  const core_1 = require("openclaw/plugin-sdk/core");
8
9
  const node_os_1 = __importDefault(require("node:os"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -127,8 +128,10 @@ function resolveAccountSendChats(cfg, accountId) {
127
128
  }
128
129
  /** One refusal for every outbound action, so the three read the same. */
129
130
  function refuseOutboundOutsideScope(action, accountId, target) {
130
- const reason = (0, send_scope_1.isPhoneNumberTarget)(target) ? "phone-number target" : "chat outside send scope";
131
- actionLog.warn(`clawgram ${action} refused: ${reason}`, { accountId, target });
131
+ const phone = (0, send_scope_1.isPhoneNumberTarget)(target);
132
+ const reason = phone ? "phone-number target" : "chat outside send scope";
133
+ // Телефонный номер — персональные данные: в журнал идёт вид цели, не значение (B5-09).
134
+ actionLog.warn(`clawgram ${action} refused: ${reason}`, { accountId, ...(phone ? { targetKind: "phone" } : { target }) });
132
135
  throw new Error(`clawgram: not-allowed-chat ${target}`);
133
136
  }
134
137
  /**
@@ -149,8 +152,11 @@ function refuseOutboundOutsideScope(action, accountId, target) {
149
152
  */
150
153
  function resolveAccountOperatorIds(cfg, accountId) {
151
154
  const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
152
- const explicit = account?.operatorIds;
153
- const raw = explicit !== undefined && explicit !== null ? explicit : account?.allowFrom;
155
+ // Только явный список. Умолчание «operatorIds = allowFrom» делало
156
+ // оператором каждого допущенного собеседника и телеметрию с путями
157
+ // secret-store получал любой из них (D2-03, A5-11). Не назван — не
158
+ // назван: уведомления подавляются везде.
159
+ const raw = account?.operatorIds;
154
160
  if (raw === undefined || raw === null)
155
161
  return [];
156
162
  const entries = Array.isArray(raw) ? raw : [raw];
@@ -253,7 +259,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
253
259
  messageToolHints: () => [
254
260
  "Use clawgram to send Telegram replies from the connected personal account.",
255
261
  "When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
256
- "Explicit targets may be @username, numeric Telegram user id, phone/contact resolvable by Telegram, group chat ids, or clawgram:<target>.",
262
+ "Explicit targets may be @username, numeric Telegram user id, group chat ids, or clawgram:<target>.",
257
263
  "For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
258
264
  "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.",
259
265
  "Use the `channel-info` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id. Name the chat with `chatId` and do not pass `target`: core refuses it for this action, and the descriptive spelling `chatInfo` is not callable from this tool at all.",
package/dist/chunk.js ADDED
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TELEGRAM_CAPTION_LIMIT = exports.TELEGRAM_TEXT_LIMIT = void 0;
4
+ exports.chunkTelegramText = chunkTelegramText;
5
+ /**
6
+ * Telegram limits: 4096 characters per message, 1024 per media caption.
7
+ *
8
+ * Core chunks only the replies it dispatches itself; a `send` issued through
9
+ * the message tool — the way the guard scripts deliver reports — reaches
10
+ * GramJS whole, and GramJS does not split either, so a long report failed
11
+ * with MESSAGE_TOO_LONG and the agent saw "✉️ Message failed" while the
12
+ * human saw nothing (audit B5-03).
13
+ *
14
+ * Splitting prefers paragraph breaks, then line breaks, then a hard cut at
15
+ * the limit. It runs on the text as the agent wrote it — before HTML
16
+ * rendering — so a chunk boundary can only fall between the agent's own
17
+ * lines, never inside an entity GramJS produced.
18
+ */
19
+ exports.TELEGRAM_TEXT_LIMIT = 4096;
20
+ exports.TELEGRAM_CAPTION_LIMIT = 1024;
21
+ function chunkTelegramText(text, limit = exports.TELEGRAM_TEXT_LIMIT) {
22
+ const chars = Array.from(text);
23
+ if (chars.length <= limit)
24
+ return [text];
25
+ const chunks = [];
26
+ let rest = text;
27
+ while (Array.from(rest).length > limit) {
28
+ const window = Array.from(rest).slice(0, limit).join("");
29
+ let cut = window.lastIndexOf("\n\n");
30
+ if (cut < limit / 2)
31
+ cut = window.lastIndexOf("\n");
32
+ if (cut < limit / 2)
33
+ cut = window.lastIndexOf(" ");
34
+ if (cut < limit / 2)
35
+ cut = window.length;
36
+ const piece = rest.slice(0, cut).replace(/\s+$/u, "");
37
+ chunks.push(piece);
38
+ rest = rest.slice(cut).replace(/^\s+/u, "");
39
+ }
40
+ if (rest)
41
+ chunks.push(rest);
42
+ return chunks;
43
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GramJsClientManager = void 0;
4
4
  exports.buildParticipantsQuery = buildParticipantsQuery;
5
5
  exports.buildVoiceNoteParams = buildVoiceNoteParams;
6
+ const chunk_1 = require("./chunk");
6
7
  const telegram_1 = require("telegram");
7
8
  const sessions_1 = require("telegram/sessions");
8
9
  // Deep import, but the documented one: GramJS ships its SRP helper here and
@@ -313,7 +314,8 @@ class GramJsClientManager {
313
314
  // но теперь виден в логе: если он в логе частый, значит кэш не спасает и
314
315
  // адресация идёт не тем ключом (A6-14).
315
316
  peerLog.info("clawgram peer resolve falling back to dialog scan", {
316
- target: raw,
317
+ // Цель может быть телефоном или @handle человека — в журнал идёт только её форма (B5-09).
318
+ targetShape: String(raw).startsWith("+") ? "phone" : String(raw).startsWith("@") ? "handle" : /^-?\d+/.test(String(raw)) ? "id" : "other",
317
319
  kind: kind ?? null,
318
320
  });
319
321
  const dialogs = await this.client.getDialogs({ limit: 200 }).catch(() => []);
@@ -418,6 +420,17 @@ class GramJsClientManager {
418
420
  const resolved = await this.resolvePeer(args.target, { kind: args.targetKind });
419
421
  const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
420
422
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
423
+ // Длиннее лимита Telegram — несколько сообщений подряд, а не
424
+ // MESSAGE_TOO_LONG (B5-03). Нарезка — по тексту агента, до рендера.
425
+ const chunks = (0, chunk_1.chunkTelegramText)(args.text, chunk_1.TELEGRAM_TEXT_LIMIT);
426
+ if (chunks.length > 1) {
427
+ let last;
428
+ for (const [index, chunk] of chunks.entries()) {
429
+ last = await this.sendText({ ...args, text: chunk,
430
+ ...(index > 0 ? { replyToMessageId: undefined } : {}) });
431
+ }
432
+ return last;
433
+ }
421
434
  return this.client.sendMessage(resolved.peer, {
422
435
  // In html mode the text is rendered first: the agent writes markdown,
423
436
  // Telegram HTML, or both, and GramJS's HTML parser alone would ship
@@ -757,6 +770,16 @@ class GramJsClientManager {
757
770
  const resolved = await this.resolvePeer(args.target);
758
771
  const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
759
772
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
773
+ // Подпись длиннее 1024 — файл с первой частью, остальное текстом следом
774
+ // (B5-03); раньше вся подпись уезжала целиком и падала на лимите.
775
+ const captionChunks = args.caption ? (0, chunk_1.chunkTelegramText)(args.caption, chunk_1.TELEGRAM_CAPTION_LIMIT) : [];
776
+ if (captionChunks.length > 1) {
777
+ const sent = await this.sendMedia({ ...args, caption: captionChunks[0] });
778
+ for (const chunk of captionChunks.slice(1)) {
779
+ await this.sendText({ target: args.target, text: chunk, parseMode: args.parseMode, messageThreadId });
780
+ }
781
+ return sent;
782
+ }
760
783
  return this.client.sendFile(resolved.peer, {
761
784
  file: args.file,
762
785
  // Captions are agent prose too — the outbound path sends `caption ??
@@ -101,30 +101,6 @@ async function handleInboundEvent(event, ctx) {
101
101
  }
102
102
  return;
103
103
  }
104
- const directReplyTarget = normalized.chatType === "direct"
105
- ? undefined
106
- : await (0, helpers_1.resolveReplyTarget)(rawMessage);
107
- const senderProfile = normalized.chatType === "direct"
108
- ? await (0, helpers_1.resolveSenderProfileWithTimeout)(rawMessage, {
109
- senderId: normalized.senderId,
110
- client,
111
- }, 1500)
112
- : await (0, helpers_1.resolveSenderProfile)(rawMessage, {
113
- senderId: normalized.senderId,
114
- client,
115
- });
116
- const replyTarget = normalized.chatType === "direct"
117
- ? normalized.chatId
118
- : await (0, helpers_1.resolveChatTarget)(rawMessage);
119
- if (replyTarget) {
120
- normalized.replyTarget = replyTarget;
121
- }
122
- if (!normalized.senderUsername && senderProfile.username) {
123
- normalized.senderUsername = senderProfile.username;
124
- }
125
- if (!normalized.senderDisplay && senderProfile.display) {
126
- normalized.senderDisplay = senderProfile.display;
127
- }
128
104
  if (normalized.isOutgoing) {
129
105
  if (normalized.chatType === "direct") {
130
106
  log?.info?.("clawgram skipping outgoing direct event", {
@@ -145,6 +121,61 @@ async function handleInboundEvent(event, ctx) {
145
121
  });
146
122
  return;
147
123
  }
124
+ // Ворота — ДО сети. До 2.25.0 на каждое сообщение из любой группы,
125
+ // где сидит аккаунт, — включая группы вне `groups` — плагин делал
126
+ // до семи запросов к Telegram (профиль отправителя, адрес ответа,
127
+ // цель чата) и только потом отбрасывал сообщение как чужое.
128
+ // Посторонний, флудящий в такой группе, тратил соединение и
129
+ // rate-limit аккаунта (B5-04, остаток A5-06). Группа вне конфига
130
+ // и выключенная группа заканчиваются здесь, без единого вызова.
131
+ const earlyScopes = (0, helpers_1.resolveAccountScopes)(cfg, accountId);
132
+ const earlyGroupConfig = normalized.chatType === "group"
133
+ ? (0, helpers_1.resolveGroupConfig)(earlyScopes.groups, normalized.chatId)
134
+ : undefined;
135
+ if (normalized.chatType === "group") {
136
+ if (!earlyGroupConfig) {
137
+ log?.info?.("clawgram skipping group not present in groups config", {
138
+ accountId,
139
+ chatId: normalized.chatId,
140
+ messageId: normalized.messageId,
141
+ });
142
+ return;
143
+ }
144
+ if (earlyGroupConfig.enabled === false) {
145
+ log?.info?.("clawgram skipping disabled group", {
146
+ accountId,
147
+ chatId: normalized.chatId,
148
+ messageId: normalized.messageId,
149
+ });
150
+ return;
151
+ }
152
+ }
153
+ // Профиль отправителя нужен воротам только когда allowFrom
154
+ // называет кого-то по @handle, а сообщение handle не принесло;
155
+ // числовые id сверяются без сети.
156
+ const gateAllowFrom = normalized.chatType === "group"
157
+ ? earlyGroupConfig?.allowFrom
158
+ : earlyScopes.allowFrom;
159
+ const allowFromNeedsHandle = Array.isArray(gateAllowFrom)
160
+ && gateAllowFrom.some((entry) => String(entry).trim().startsWith("@"));
161
+ const needsProfile = !normalized.senderUsername && allowFromNeedsHandle;
162
+ const senderProfile = needsProfile
163
+ ? (normalized.chatType === "direct"
164
+ ? await (0, helpers_1.resolveSenderProfileWithTimeout)(rawMessage, {
165
+ senderId: normalized.senderId,
166
+ client,
167
+ }, 1500)
168
+ : await (0, helpers_1.resolveSenderProfile)(rawMessage, {
169
+ senderId: normalized.senderId,
170
+ client,
171
+ }))
172
+ : {};
173
+ if (!normalized.senderUsername && senderProfile.username) {
174
+ normalized.senderUsername = senderProfile.username;
175
+ }
176
+ if (!normalized.senderDisplay && senderProfile.display) {
177
+ normalized.senderDisplay = senderProfile.display;
178
+ }
148
179
  let text = normalized.text?.trim();
149
180
  // Whether this sender may reach the agent at all — decided before
150
181
  // the attachment is fetched.
@@ -180,6 +211,16 @@ async function handleInboundEvent(event, ctx) {
180
211
  // voice note and a screenshot alike, the attachment *is* the
181
212
  // message. A caption is kept and the reading appended, because
182
213
  // "look at this" plus the picture is one thought, not two.
214
+ // Адрес ответа и цель чата — сеть, и нужны только тому, кому
215
+ // отвечают: считаются после ворот (B5-04).
216
+ const directReplyTarget = normalized.chatType === "direct" ? undefined
217
+ : senderMayReachAgent ? await (0, helpers_1.resolveReplyTarget)(rawMessage) : undefined;
218
+ const replyTarget = normalized.chatType === "direct"
219
+ ? normalized.chatId
220
+ : senderMayReachAgent ? await (0, helpers_1.resolveChatTarget)(rawMessage) : undefined;
221
+ if (replyTarget) {
222
+ normalized.replyTarget = replyTarget;
223
+ }
183
224
  const attachment = senderMayReachAgent ? await (0, attachments_1.readInboundAttachment)({
184
225
  gram,
185
226
  event,
@@ -315,7 +356,10 @@ async function handleInboundEvent(event, ctx) {
315
356
  messageId: normalized.messageId,
316
357
  senderId,
317
358
  username: normalized.senderUsername,
318
- allowFrom: groupConfig.allowFrom,
359
+ // Сам список — id владельца и допущенных — в журнал не идёт:
360
+ // посторонний управлял бы числом его копий в journald (B5-09).
361
+ allowFromCount: Array.isArray(groupConfig.allowFrom) ? groupConfig.allowFrom.length : 0,
362
+ allowFromHasWildcard: Array.isArray(groupConfig.allowFrom) && groupConfig.allowFrom.some((e) => String(e).trim() === "*"),
319
363
  });
320
364
  return;
321
365
  }
@@ -706,7 +750,8 @@ async function handleInboundEvent(event, ctx) {
706
750
  accountId,
707
751
  senderId,
708
752
  senderUsername: normalized.senderUsername,
709
- allowFrom: directAllowFrom,
753
+ allowFromCount: Array.isArray(directAllowFrom) ? directAllowFrom.length : 0,
754
+ allowFromHasWildcard: Array.isArray(directAllowFrom) && directAllowFrom.some((e) => String(e).trim() === "*"),
710
755
  });
711
756
  return;
712
757
  }
@@ -820,6 +865,26 @@ async function handleInboundEvent(event, ctx) {
820
865
  });
821
866
  return;
822
867
  }
868
+ // Тот же фильтр, что у группового ответа и у outbound.sendText:
869
+ // личный ответ идёт третьим путём, и закрытие A5-11 его не
870
+ // покрывало — «⚠️ 🛠️ Bash failed: cat /opt/openclaw-secrets/…»
871
+ // уходил любому из allowFrom, чей ход уронил инструмент (B5-01).
872
+ const directNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
873
+ targetKind: "user",
874
+ text: visibleText,
875
+ to: normalized.chatId,
876
+ operatorIds: (0, system_notice_1.operatorIdsFor)(accountId),
877
+ });
878
+ if (directNotice) {
879
+ log?.warn?.("clawgram suppressing system notice in direct reply", {
880
+ accountId,
881
+ chatId: normalized.chatId,
882
+ messageId: normalized.messageId,
883
+ noticeKind: directNotice,
884
+ textLength: visibleText.length,
885
+ });
886
+ return;
887
+ }
823
888
  await sendTextToConversation({
824
889
  text: visibleText,
825
890
  replyToMessageId: payload.replyToId ? Number(payload.replyToId) : undefined,
package/dist/outbound.js CHANGED
@@ -89,6 +89,19 @@ function createOutbound(runtimes) {
89
89
  });
90
90
  return { skipped: "silent" };
91
91
  }
92
+ // Область отправки — и здесь. Путь доставки ядра (`--deliver`,
93
+ // анонсы субагентов) зовёт sendText напрямую, минуя resolveTarget и
94
+ // handleAction, где барьер уже стоял: третий из трёх исходящих путей
95
+ // был открыт для любого адресата и телефонного номера (D2-01, A5-12).
96
+ const scopedTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
97
+ if (!(0, send_scope_1.isChatSendable)(scopedTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
98
+ actionLog.warn("clawgram outbound sendText refused", {
99
+ accountId: ctx.accountId,
100
+ target: scopedTarget,
101
+ reason: (0, send_scope_1.isPhoneNumberTarget)(scopedTarget) ? "phone-number target" : "chat outside send scope",
102
+ });
103
+ return { skipped: "not-allowed" };
104
+ }
92
105
  // Core's operational chatter (tool-error warnings, fallback notices)
93
106
  // stays out of group chats: it is telemetry for the operator, not a
94
107
  // reply to the room, and it has already been seen carrying shell
@@ -36,14 +36,6 @@ function buildAccountPayload(auth) {
36
36
  sessionString: auth.sessionString,
37
37
  };
38
38
  }
39
- /** Первый конфиг закрыт: см. одноимённую функцию в cli-core.ts. */
40
- function buildAccountConfigFragment(auth) {
41
- return {
42
- ...buildAccountPayload(auth),
43
- allowFrom: auth.selfId ? [auth.selfId] : [],
44
- readChats: [],
45
- };
46
- }
47
39
  /**
48
40
  * Credentials already moved into the secret store must survive re-auth.
49
41
  *
@@ -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.24.0",
5
+ "version": "2.25.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.24.0",
3
+ "version": "2.25.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
+ "verify:proxy": "node scripts/verify/gramjs-wiring.cjs && node scripts/verify/auth-preserves-proxy.cjs",
7
8
  "build": "tsc -p tsconfig.json",
8
9
  "build:test": "tsc -p tsconfig.test.json",
9
10
  "test": "npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",