clawgram 2.24.0 → 2.26.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 |
@@ -713,6 +714,35 @@ Bindings
713
714
  ```
714
715
 
715
716
 
717
+ ## Actions and the names that reach them
718
+
719
+ Two grammars name the same actions. The **native** names (`read`, `participants`, `topics`,
720
+ `dialogs`, `chatInfo`, `fetch-media`, `createGroup`, …) are what the gateway RPC, the tests and
721
+ this README use. The agent's `message` tool, however, only dispatches names from **core's own
722
+ vocabulary** (`CHANNEL_MESSAGE_ACTION_NAMES`), so every action worth reaching from a prompt also
723
+ answers to core's nearest name. `src/actions.ts` is the single source of both lists
724
+ (`ACTION_ALIASES`, `CORE_VOCABULARY_SPELLINGS`); that core really knows each name is asserted
725
+ against the installed core in `test/core-action-synonyms.test.ts`, so a core release that drops
726
+ one fails the suite rather than the chat.
727
+
728
+ | Does | Native name | Callable from the `message` tool as | Gate |
729
+ |---|---|---|---|
730
+ | send text | `send` | `send` | `sendChats` |
731
+ | send a file | `upload-file` | `upload-file`, `sendAttachment` | `sendChats`, media roots |
732
+ | react to a message | `react` | `react` | `sendChats` |
733
+ | read history | `read` (also `list`) | `read` | `readChats` |
734
+ | fetch an attachment | `fetch-media` | `download-file` | `readChats` |
735
+ | list members | `participants` | `member-info` | `readChats` |
736
+ | list forum topics | `topics` | `thread-list` | `readChats` |
737
+ | list chats | `dialogs` | `channel-list` | `discoverChats` |
738
+ | describe a chat | `chatInfo` | `channel-info` (the chat arrives in `channelId`) | `readChats` |
739
+ | where the account was added | `joins` | — (gateway RPC only) | — |
740
+ | chat management | see the table below | see the table below | `manageChats` |
741
+
742
+ Names outside core's vocabulary (`joins`, `transferOwnership`, `inviteLink`) are reachable through
743
+ the gateway RPC only. A name core does not know fails as "requires a target" and "does not accept a
744
+ target" at once — there is no call that satisfies both, which is why this table exists.
745
+
716
746
  ## Chat management
717
747
 
718
748
  Since 2.12.0 the assistant can assemble a chat, not only speak in it: create a supergroup, add and
@@ -727,15 +757,15 @@ management to those chats, `["*"]` allows every chat. A non-empty list also unlo
727
757
  (the chat being created is not in any list yet). All actions honour `dryRun`, and the gate is
728
758
  checked before the dry-run answer, so a dry run exercises the same refusals a real call would hit.
729
759
 
730
- | Action | Parameters | Notes |
731
- |---|---|---|
732
- | `createGroup` | `title`, `about?`, `users?` | Creates a **supergroup** (megagroup) — granular admin rights, bans and ownership transfer only exist there. Initial members are invited right after creation; who could not be added is returned in `missing` |
733
- | `addMembers` | `chatId`, `users` | Adds to supergroups in one call, to basic groups one by one. Ids Telegram refused (privacy settings) come back in `missing` instead of failing the call |
734
- | `removeMember` | `chatId`, `user`, `ban?` | Soft kick by default — the person may be re-invited later. `ban: true` keeps them out until unbanned |
735
- | `promoteAdmin` | `chatId`, `user`, `rank?`, `rights?` | Grants a deliberate default set (change info, delete messages, ban, invite, pin, calls, topics). `addAdmins` and `anonymous` stay **off** unless explicitly set in `rights` |
736
- | `demoteAdmin` | `chatId`, `user` | Strips every admin right |
737
- | `transferOwnership` | `chatId`, `user` | Supergroups only. Requires `twoFaPassword` (below); Telegram's own rules surface as errors — see the fine print |
738
- | `inviteLink` | `chatId`, `expireDate?`, `usageLimit?`, `title?`, `requestNeeded?` | The path for people whose privacy settings refuse a direct add. `expireDate` takes unix seconds or an ISO date |
760
+ | Action | Callable from the `message` tool as | Parameters | Notes |
761
+ |---|---|---|---|
762
+ | `createGroup` | `channel-create` | `title`, `about?`, `users?` | Creates a **supergroup** (megagroup) — granular admin rights, bans and ownership transfer only exist there. Initial members are invited right after creation; who could not be added is returned in `missing` |
763
+ | `addMembers` | `addParticipant` | `chatId`, `users` | Adds to supergroups in one call, to basic groups one by one. Ids Telegram refused (privacy settings) come back in `missing` instead of failing the call |
764
+ | `removeMember` | `kick` | `chatId`, `user`, `ban?` | Soft kick by default — the person may be re-invited later. `ban: true` keeps them out until unbanned |
765
+ | `promoteAdmin` | `role-add` | `chatId`, `user`, `rank?`, `rights?` | Grants a deliberate default set (change info, delete messages, ban, invite, pin, calls, topics). `addAdmins` and `anonymous` stay **off** unless explicitly set in `rights` |
766
+ | `demoteAdmin` | `role-remove` | `chatId`, `user` | Strips every admin right |
767
+ | `transferOwnership` | — (gateway RPC only) | `chatId`, `user` | Supergroups only. Requires `twoFaPassword` (below); Telegram's own rules surface as errors — see the fine print |
768
+ | `inviteLink` | — (gateway RPC only) | `chatId`, `expireDate?`, `usageLimit?`, `title?`, `requestNeeded?` | The path for people whose privacy settings refuse a direct add. `expireDate` takes unix seconds or an ISO date |
739
769
 
740
770
  User references in `users`/`user` are `@username` or a numeric Telegram id. A `@username` always
741
771
  resolves; a bare numeric id only when the account has already seen the user (shared chat, dialog,
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rememberAccount = rememberAccount;
4
+ exports.forgetAccount = forgetAccount;
5
+ exports.sendScopeFor = sendScopeFor;
6
+ exports.operatorIdsFor = operatorIdsFor;
7
+ exports.requireRuntime = requireRuntime;
8
+ const records = new Map();
9
+ function rememberAccount(accountId, record) {
10
+ records.set(accountId, { sendChats: record.sendChats, operatorIds: [...record.operatorIds] });
11
+ }
12
+ function forgetAccount(accountId) {
13
+ records.delete(accountId);
14
+ }
15
+ function sendScopeFor(accountId) {
16
+ return records.get(accountId)?.sendChats;
17
+ }
18
+ function operatorIdsFor(accountId) {
19
+ return records.get(accountId)?.operatorIds ?? [];
20
+ }
21
+ /**
22
+ * The connected runtime for an account, or a refusal naming it.
23
+ *
24
+ * One helper instead of the copies of this three-liner that used to sit in
25
+ * each dispatch branch (A6-11 removed six; D2-11 the remaining seven).
26
+ */
27
+ function requireRuntime(runtimes, accountId) {
28
+ const gram = runtimes.get(accountId);
29
+ if (!gram) {
30
+ throw new Error(`clawgram: runtime not found for account ${accountId}`);
31
+ }
32
+ return gram;
33
+ }
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"));
@@ -54,15 +55,14 @@ const fetch_media_1 = require("./fetch-media");
54
55
  const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
55
56
  const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
56
57
  const tool_send_1 = require("openclaw/plugin-sdk/tool-send");
57
- const channel_pairing_1 = require("openclaw/plugin-sdk/channel-pairing");
58
58
  const events_1 = require("telegram/events");
59
59
  const gramjs_client_1 = require("./gramjs-client");
60
60
  const history_1 = require("./history");
61
61
  const send_scope_1 = require("./send-scope");
62
+ const account_registry_1 = require("./account-registry");
62
63
  const joins_1 = require("./joins");
63
64
  const reactions_1 = require("./reactions");
64
65
  const manage_1 = require("./manage");
65
- const system_notice_1 = require("./system-notice");
66
66
  const state_dir_1 = require("./state-dir");
67
67
  const chat_info_1 = require("./chat-info");
68
68
  const topics_1 = require("./topics");
@@ -75,6 +75,12 @@ const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
75
75
  const helpers_1 = require("./helpers");
76
76
  const proxy_config_1 = require("./proxy-config");
77
77
  const constants_1 = require("./constants");
78
+ const actions_1 = require("./actions");
79
+ Object.defineProperty(exports, "CORE_ACTION_SYNONYMS", { enumerable: true, get: function () { return actions_1.CORE_ACTION_SYNONYMS; } });
80
+ Object.defineProperty(exports, "canonicalAction", { enumerable: true, get: function () { return actions_1.canonicalAction; } });
81
+ const outbound_1 = require("./outbound");
82
+ const inbound_pipeline_1 = require("./inbound-pipeline");
83
+ const attachments_1 = require("./attachments");
78
84
  const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
79
85
  /**
80
86
  * Read scope as configured for the account. Left `undefined` when the key is
@@ -82,20 +88,11 @@ const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
82
88
  * the first means no restriction, the second denies everything.
83
89
  */
84
90
  function readAccountReadChats(account) {
85
- const raw = account?.readChats;
86
- if (raw === undefined || raw === null)
87
- return undefined;
88
- const entries = Array.isArray(raw) ? raw : [raw];
89
- return entries.map((entry) => String(entry).trim()).filter(Boolean);
91
+ return (0, history_1.normalizeScopeList)(account?.readChats);
90
92
  }
91
93
  function resolveAccountReadChats(cfg, accountId) {
92
94
  return readAccountReadChats(cfg?.channels?.["clawgram"]?.accounts?.[accountId]);
93
95
  }
94
- /**
95
- * Outbound scope as configured. Handed to `isChatSendable` raw: an absent
96
- * value means "unrestricted" and an empty list means "deny", and only the
97
- * raw value tells those apart — same shape as `readChats`.
98
- */
99
96
  /**
100
97
  * Хэндл в `allowFrom` — обещание, которое Telegram не держит.
101
98
  *
@@ -122,14 +119,19 @@ function warnAboutHandleAllowlistEntries(cfg, accountId) {
122
119
  why: "a released handle can be taken by someone else; numeric ids do not change hands",
123
120
  });
124
121
  }
122
+ /**
123
+ * Outbound scope as configured. Handed to `isChatSendable` raw: an absent
124
+ * value means "unrestricted" and an empty list means "deny", and only the
125
+ * raw value tells those apart — same shape as `readChats`.
126
+ */
125
127
  function resolveAccountSendChats(cfg, accountId) {
126
128
  return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.sendChats;
127
129
  }
128
130
  /** One refusal for every outbound action, so the three read the same. */
129
131
  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 });
132
- throw new Error(`clawgram: not-allowed-chat ${target}`);
132
+ const refusal = (0, send_scope_1.describeSendRefusal)(target);
133
+ actionLog.warn(`clawgram ${action} refused: ${refusal.reason}`, { accountId, ...refusal.logFields });
134
+ throw refusal.error;
133
135
  }
134
136
  /**
135
137
  * Management scope as configured. Handed to `isChatManageable` raw: unlike
@@ -149,8 +151,11 @@ function refuseOutboundOutsideScope(action, accountId, target) {
149
151
  */
150
152
  function resolveAccountOperatorIds(cfg, accountId) {
151
153
  const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
152
- const explicit = account?.operatorIds;
153
- const raw = explicit !== undefined && explicit !== null ? explicit : account?.allowFrom;
154
+ // Только явный список. Умолчание «operatorIds = allowFrom» делало
155
+ // оператором каждого допущенного собеседника и телеметрию с путями
156
+ // secret-store получал любой из них (D2-03, A5-11). Не назван — не
157
+ // назван: уведомления подавляются везде.
158
+ const raw = account?.operatorIds;
154
159
  if (raw === undefined || raw === null)
155
160
  return [];
156
161
  const entries = Array.isArray(raw) ? raw : [raw];
@@ -164,31 +169,8 @@ function resolveAccountManageChats(cfg, accountId) {
164
169
  }
165
170
  /** Same normalization `readChats` gets, for the resolved-account copy. */
166
171
  function readAccountManageChats(account) {
167
- const raw = account?.manageChats;
168
- if (raw === undefined || raw === null)
169
- return undefined;
170
- const entries = Array.isArray(raw) ? raw : [raw];
171
- return entries.map((entry) => String(entry).trim()).filter(Boolean);
172
+ return (0, history_1.normalizeScopeList)(account?.manageChats);
172
173
  }
173
- const actions_1 = require("./actions");
174
- Object.defineProperty(exports, "CORE_ACTION_SYNONYMS", { enumerable: true, get: function () { return actions_1.CORE_ACTION_SYNONYMS; } });
175
- Object.defineProperty(exports, "canonicalAction", { enumerable: true, get: function () { return actions_1.canonicalAction; } });
176
- const outbound_1 = require("./outbound");
177
- const inbound_pipeline_1 = require("./inbound-pipeline");
178
- /**
179
- * Turns an inbound attachment into text the agent can read.
180
- *
181
- * The work is deliberately delegated: `runtime.mediaUnderstanding` already
182
- * knows which backend this installation uses for speech and for images, so
183
- * the channel stays out of that choice — a local model today, something else
184
- * tomorrow, without touching this file.
185
- *
186
- * Failure is not an error worth dropping the message over. An attachment that
187
- * could not be read still happened, and the assistant is better off saying
188
- * "you sent something I could not read" than staying silent, which is
189
- * indistinguishable from being offline.
190
- */
191
- const attachments_1 = require("./attachments");
192
174
  const createChannelPlugin = (runtimes, pluginRuntime) => {
193
175
  const resolveRuntimeAccountId = (cfg, preferred) => {
194
176
  const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
@@ -200,20 +182,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
200
182
  }
201
183
  return configured ?? runtimes.keys().next().value;
202
184
  };
203
- /**
204
- * The connected runtime for an account, or a refusal naming it.
205
- *
206
- * One helper instead of the eleven copies of this three-liner that used to
207
- * sit inside each dispatch branch — the same repetition that made every new
208
- * action cost a scaffold (finding A6-11).
209
- */
210
- const requireRuntimeFor = (id) => {
211
- const gram = runtimes.get(id);
212
- if (!gram) {
213
- throw new Error(`clawgram: runtime not found for account ${id}`);
214
- }
215
- return gram;
216
- };
185
+ /** The connected runtime for an account, or a refusal naming it (A6-11, D2-11). */
186
+ const requireRuntimeFor = (id) => (0, account_registry_1.requireRuntime)(runtimes, id);
217
187
  return {
218
188
  id: "clawgram",
219
189
  meta: {
@@ -253,7 +223,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
253
223
  messageToolHints: () => [
254
224
  "Use clawgram to send Telegram replies from the connected personal account.",
255
225
  "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>.",
226
+ "Explicit targets may be @username, numeric Telegram user id, group chat ids, or clawgram:<target>.",
257
227
  "For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
258
228
  "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
229
  "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.",
@@ -358,18 +328,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
358
328
  const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
359
329
  await gram.start();
360
330
  runtimes.set(accountId, gram);
361
- (0, system_notice_1.rememberOperatorIds)(accountId, resolveAccountOperatorIds(cfg, accountId));
362
- // Область отправки туда же и по той же причине: в `outbound.*`
363
- // конфига нет, а барьер нужен и на пути доставки ядра (A5-12).
364
- (0, send_scope_1.rememberSendScope)(accountId, resolveAccountSendChats(cfg, accountId));
365
- warnAboutHandleAllowlistEntries(cfg, accountId);
366
- const pairing = (0, channel_pairing_1.createChannelPairingController)({
367
- // The controller only reads core.channel.pairing, but its parameter is typed
368
- // as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
369
- core: { channel: channelRuntime },
370
- channel: "clawgram",
371
- accountId,
331
+ // Что `outbound.*` должен знать об аккаунте без конфига: область
332
+ // отправки (A5-12) и операторы (A5-11). Одна запись, снимается при
333
+ // остановке аккаунта (D2-11).
334
+ (0, account_registry_1.rememberAccount)(accountId, {
335
+ sendChats: resolveAccountSendChats(cfg, accountId),
336
+ operatorIds: resolveAccountOperatorIds(cfg, accountId),
372
337
  });
338
+ warnAboutHandleAllowlistEntries(cfg, accountId);
373
339
  const me = await gram.getMe();
374
340
  const selfId = me?.id ? String(me.id) : undefined;
375
341
  const selfUsername = (0, helpers_1.resolveActiveUsername)(me);
@@ -388,7 +354,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
388
354
  const client = gram.getClient();
389
355
  const eventBuilder = new events_1.NewMessage({});
390
356
  const eventHandler = async (event) => (0, inbound_pipeline_1.handleInboundEvent)(event, {
391
- accountId, cfg, channelRuntime, client, gram, log, pairing,
357
+ accountId, cfg, channelRuntime, client, gram, log,
392
358
  pluginRuntime, runtimes, selfId, selfLabel, selfUsername,
393
359
  });
394
360
  client.addEventHandler(eventHandler, eventBuilder);
@@ -425,6 +391,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
425
391
  await (0, channel_runtime_1.waitUntilAbort)(ctx.abortSignal, async () => {
426
392
  client.removeEventHandler(eventHandler, eventBuilder);
427
393
  client.removeEventHandler(joinEventHandler, joinEventBuilder);
394
+ (0, account_registry_1.forgetAccount)(accountId);
428
395
  const runtime = runtimes.get(accountId);
429
396
  if (!runtime) {
430
397
  return;
@@ -612,8 +579,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
612
579
  // bundled channels enforce them. This one used to take `filePath`
613
580
  // verbatim, so a path naming the secret store or the config holding
614
581
  // `sessionString` was uploaded like any attachment.
615
- mediaLocalRoots, mediaAccess, }) => {
582
+ mediaLocalRoots, mediaReadFile, mediaAccess, }) => {
616
583
  const allowedMediaRoots = mediaLocalRoots ?? mediaAccess?.localRoots;
584
+ const readMedia = mediaReadFile ?? mediaAccess?.readFile;
617
585
  // Core passes the flag beside `params`; callers write it inside.
618
586
  // Both count, because a rehearsal flag that is silently ignored puts
619
587
  // a real message in a real chat — twice, so far (2.13.1).
@@ -640,10 +608,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
640
608
  });
641
609
  throw new Error(`clawgram: not-allowed-chat ${listParams.target}`);
642
610
  }
643
- const listGram = runtimes.get(listAccountId);
644
- if (!listGram) {
645
- throw new Error(`clawgram: runtime not found for account ${listAccountId}`);
646
- }
611
+ const listGram = requireRuntimeFor(listAccountId);
647
612
  const history = await listGram.listMessages(listParams);
648
613
  // Metadata only. Message text is the user's correspondence and has no
649
614
  // business in a log that is read while debugging something else.
@@ -687,10 +652,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
687
652
  });
688
653
  throw new Error(`clawgram: not-allowed-chat ${fetchParams.target}`);
689
654
  }
690
- const fetchGram = runtimes.get(fetchAccountId);
691
- if (!fetchGram) {
692
- throw new Error(`clawgram: runtime not found for account ${fetchAccountId}`);
693
- }
655
+ const fetchGram = requireRuntimeFor(fetchAccountId);
694
656
  // Fetching is a read: a dry run answers for real, the same way `read`
695
657
  // does. Nothing leaves the machine — the file lands in a temp
696
658
  // directory this channel prunes — so a rehearsal that reported
@@ -1018,10 +980,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1018
980
  removed: reactionParams.remove,
1019
981
  });
1020
982
  }
1021
- const reactionGram = runtimes.get(reactionAccountId);
1022
- if (!reactionGram) {
1023
- throw new Error(`clawgram: runtime not found for account ${reactionAccountId}`);
1024
- }
983
+ const reactionGram = requireRuntimeFor(reactionAccountId);
1025
984
  await reactionGram.sendReaction(reactionParams);
1026
985
  return (0, core_1.jsonResult)({
1027
986
  ok: true,
@@ -1233,13 +1192,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1233
1192
  if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
1234
1193
  refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
1235
1194
  }
1236
- const file = attachedFile;
1237
- if (!file) {
1195
+ if (!attachedFile) {
1238
1196
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
1239
1197
  }
1240
1198
  // Before anything else about the message is considered: an
1241
1199
  // out-of-scope path is refused, not sent and then regretted.
1242
- (0, media_1.assertLocalMediaWithinRoots)(file, allowedMediaRoots);
1200
+ (0, media_1.assertLocalMediaWithinRoots)(attachedFile, allowedMediaRoots);
1243
1201
  const captionText = (0, helpers_1.readMessageText)(params) || ((0, param_readers_1.readStringParam)(params, "caption") ?? "");
1244
1202
  // A caption is optional, but the silent-reply sentinel must never
1245
1203
  // reach Telegram as one — same reasoning as the `send` path below.
@@ -1266,10 +1224,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1266
1224
  accountId: uploadAccountId,
1267
1225
  });
1268
1226
  }
1269
- const uploadGram = runtimes.get(uploadAccountId);
1270
- if (!uploadGram) {
1271
- throw new Error(`clawgram: runtime not found for account ${uploadAccountId}`);
1272
- }
1227
+ const uploadGram = requireRuntimeFor(uploadAccountId);
1228
+ // Read last, through core's scoped reader when it gave one: a dry
1229
+ // run or a refusal above must not open the file.
1230
+ const file = await (0, media_1.loadOutboundMedia)(attachedFile, allowedMediaRoots, readMedia);
1273
1231
  const uploaded = await uploadGram.sendMedia({
1274
1232
  target: uploadTo,
1275
1233
  file,
@@ -1407,10 +1365,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1407
1365
  accountId: resolvedAccountId,
1408
1366
  });
1409
1367
  }
1410
- const gram = runtimes.get(resolvedAccountId);
1411
- if (!gram) {
1412
- throw new Error(`clawgram: runtime not found for account ${resolvedAccountId}`);
1413
- }
1368
+ const gram = requireRuntimeFor(resolvedAccountId);
1414
1369
  const sent = await gram.sendText({
1415
1370
  target: to,
1416
1371
  text,
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,8 +3,10 @@ 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");
9
+ const uploads_1 = require("telegram/client/uploads");
8
10
  // Deep import, but the documented one: GramJS ships its SRP helper here and
9
11
  // the package has no `exports` field to forbid it.
10
12
  const Password_1 = require("telegram/Password");
@@ -62,29 +64,6 @@ function uniqueCandidates(values) {
62
64
  }
63
65
  return result;
64
66
  }
65
- function parseTargetWithThread(rawTarget) {
66
- const raw = rawTarget.trim();
67
- const topicMatch = /^(.+?):topic:(\d+)$/.exec(raw);
68
- if (topicMatch) {
69
- return {
70
- raw,
71
- chatId: topicMatch[1],
72
- messageThreadId: Number.parseInt(topicMatch[2], 10),
73
- };
74
- }
75
- const colonMatch = /^(.+):(\d+)$/.exec(raw);
76
- if (colonMatch && /^-?\d+$/.test(colonMatch[1])) {
77
- return {
78
- raw,
79
- chatId: colonMatch[1],
80
- messageThreadId: Number.parseInt(colonMatch[2], 10),
81
- };
82
- }
83
- return {
84
- raw,
85
- chatId: raw,
86
- };
87
- }
88
67
  /**
89
68
  * Membership query for `getParticipants`.
90
69
  *
@@ -313,7 +292,8 @@ class GramJsClientManager {
313
292
  // но теперь виден в логе: если он в логе частый, значит кэш не спасает и
314
293
  // адресация идёт не тем ключом (A6-14).
315
294
  peerLog.info("clawgram peer resolve falling back to dialog scan", {
316
- target: raw,
295
+ // Цель может быть телефоном или @handle человека — в журнал идёт только её форма (B5-09).
296
+ targetShape: String(raw).startsWith("+") ? "phone" : String(raw).startsWith("@") ? "handle" : /^-?\d+/.test(String(raw)) ? "id" : "other",
317
297
  kind: kind ?? null,
318
298
  });
319
299
  const dialogs = await this.client.getDialogs({ limit: 200 }).catch(() => []);
@@ -356,7 +336,7 @@ class GramJsClientManager {
356
336
  peer: entity
357
337
  };
358
338
  }
359
- const parsedTarget = parseTargetWithThread(rawTarget);
339
+ const parsedTarget = (0, history_1.parseTargetWithThread)(rawTarget);
360
340
  const raw = parsedTarget.raw;
361
341
  const chatLookupTarget = parsedTarget.chatId;
362
342
  const kind = options?.kind;
@@ -418,6 +398,17 @@ class GramJsClientManager {
418
398
  const resolved = await this.resolvePeer(args.target, { kind: args.targetKind });
419
399
  const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
420
400
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
401
+ // Длиннее лимита Telegram — несколько сообщений подряд, а не
402
+ // MESSAGE_TOO_LONG (B5-03). Нарезка — по тексту агента, до рендера.
403
+ const chunks = (0, chunk_1.chunkTelegramText)(args.text, chunk_1.TELEGRAM_TEXT_LIMIT);
404
+ if (chunks.length > 1) {
405
+ let last;
406
+ for (const [index, chunk] of chunks.entries()) {
407
+ last = await this.sendText({ ...args, text: chunk,
408
+ ...(index > 0 ? { replyToMessageId: undefined } : {}) });
409
+ }
410
+ return last;
411
+ }
421
412
  return this.client.sendMessage(resolved.peer, {
422
413
  // In html mode the text is rendered first: the agent writes markdown,
423
414
  // Telegram HTML, or both, and GramJS's HTML parser alone would ship
@@ -757,8 +748,22 @@ class GramJsClientManager {
757
748
  const resolved = await this.resolvePeer(args.target);
758
749
  const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
759
750
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
751
+ // Подпись длиннее 1024 — файл с первой частью, остальное текстом следом
752
+ // (B5-03); раньше вся подпись уезжала целиком и падала на лимите.
753
+ const captionChunks = args.caption ? (0, chunk_1.chunkTelegramText)(args.caption, chunk_1.TELEGRAM_CAPTION_LIMIT) : [];
754
+ if (captionChunks.length > 1) {
755
+ const sent = await this.sendMedia({ ...args, caption: captionChunks[0] });
756
+ for (const chunk of captionChunks.slice(1)) {
757
+ await this.sendText({ target: args.target, text: chunk, parseMode: args.parseMode, messageThreadId });
758
+ }
759
+ return sent;
760
+ }
760
761
  return this.client.sendFile(resolved.peer, {
761
- file: args.file,
762
+ // Bytes core read through its scoped reader keep the file's own name;
763
+ // a bare Buffer would reach Telegram as "unnamed" (B5-14).
764
+ file: typeof args.file === "string"
765
+ ? args.file
766
+ : new uploads_1.CustomFile(args.file.fileName, args.file.buffer.length, "", args.file.buffer),
762
767
  // Captions are agent prose too — the outbound path sends `caption ??
763
768
  // text` — so they render exactly like sendText does. Before 2.15.0
764
769
  // captions carried no mode at all, which meant GramJS's default
package/dist/helpers.js CHANGED
@@ -834,8 +834,11 @@ function normalizeParseMode(raw) {
834
834
  * raw markup.
835
835
  */
836
836
  function resolveReplyParseMode(cfg, accountId) {
837
- const channel = cfg?.channels?.["clawgram"];
838
- const account = channel?.accounts?.[accountId] ?? channel;
837
+ // Account level only: the schema has never allowed `replyParseMode` on the
838
+ // channel itself, so the old fallback to `channels.clawgram.replyParseMode`
839
+ // read a key `openclaw config validate` rejects — a setting that could not
840
+ // exist was read, and a test pinned it (audit B5-10).
841
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
839
842
  return normalizeParseMode(account?.replyParseMode);
840
843
  }
841
844
  /**
package/dist/history.js CHANGED
@@ -20,6 +20,8 @@ exports.normalizeParticipants = normalizeParticipants;
20
20
  exports.parseListParticipantsParams = parseListParticipantsParams;
21
21
  exports.buildHistoryQuery = buildHistoryQuery;
22
22
  exports.normalizeChatKey = normalizeChatKey;
23
+ exports.normalizeScopeList = normalizeScopeList;
24
+ exports.parseTargetWithThread = parseTargetWithThread;
23
25
  exports.chatKeyCandidates = chatKeyCandidates;
24
26
  exports.isChatReadable = isChatReadable;
25
27
  exports.isWithinWindow = isWithinWindow;
@@ -234,6 +236,20 @@ function buildHistoryQuery(args) {
234
236
  function normalizeChatKey(value) {
235
237
  return String(value ?? "").trim().replace(/^@/, "").toLowerCase();
236
238
  }
239
+ /**
240
+ * A configured chat scope (`readChats`, `sendChats`, `manageChats`) as a list
241
+ * of chat keys — or `undefined` when the key is absent, because every gate
242
+ * tells "not configured" from "configured empty" by that difference.
243
+ *
244
+ * Four copies of this normalizer used to live in three files, two of them
245
+ * trimming only and two lowercasing, so the same entry could pass one gate
246
+ * and fail another (audit B5-13). One now.
247
+ */
248
+ function normalizeScopeList(raw) {
249
+ if (raw === undefined || raw === null)
250
+ return undefined;
251
+ return (Array.isArray(raw) ? raw : [raw]).map(normalizeChatKey).filter(Boolean);
252
+ }
237
253
  /**
238
254
  * Все написания одной цели, по которым её ищут в списке доступа.
239
255
  *
@@ -247,11 +263,44 @@ function normalizeChatKey(value) {
247
263
  * `-1001234:topic:5` сегодня работает как область в одну тему, и сведение
248
264
  * всего к чату молча расширило бы её на весь чат.
249
265
  */
266
+ /**
267
+ * One target address, split into the chat and the forum topic it may name.
268
+ *
269
+ * Two spellings carry a topic: `-1001234:topic:5` and the short `-1001234:5`
270
+ * (a numeric chat, a colon, a number). This is the parser the resolver in
271
+ * `gramjs-client` uses, and since B5-08 the only one: the gates used to strip
272
+ * `:topic:N` with a regex of their own and did not know the short form, so
273
+ * `-1001234:5` passed the resolver as a topic of a listed chat and failed the
274
+ * gate as an unknown one.
275
+ */
276
+ function parseTargetWithThread(rawTarget) {
277
+ const raw = rawTarget.trim();
278
+ const topicMatch = /^(.+?):topic:(\d+)$/.exec(raw);
279
+ if (topicMatch) {
280
+ return {
281
+ raw,
282
+ chatId: topicMatch[1],
283
+ messageThreadId: Number.parseInt(topicMatch[2], 10),
284
+ };
285
+ }
286
+ const colonMatch = /^(.+):(\d+)$/.exec(raw);
287
+ if (colonMatch && /^-?\d+$/.test(colonMatch[1])) {
288
+ return {
289
+ raw,
290
+ chatId: colonMatch[1],
291
+ messageThreadId: Number.parseInt(colonMatch[2], 10),
292
+ };
293
+ }
294
+ return {
295
+ raw,
296
+ chatId: raw,
297
+ };
298
+ }
250
299
  function chatKeyCandidates(target) {
251
300
  const raw = String(target ?? "").trim();
252
301
  const withoutChannel = raw.replace(/^(?:clawgram|tguserbot|telegram|tg):/i, "");
253
302
  const withoutKind = withoutChannel.replace(/^(?:user|channel|group|conversation|room|dm):/i, "");
254
- const chatOnly = withoutKind.replace(/:topic:\d+$/i, "");
303
+ const chatOnly = parseTargetWithThread(withoutKind).chatId;
255
304
  const candidates = [raw, withoutKind, chatOnly]
256
305
  .map(normalizeChatKey)
257
306
  .filter(Boolean);
@@ -280,11 +329,9 @@ function isChatReadable(target, readChats) {
280
329
  const candidates = chatKeyCandidates(target);
281
330
  if (candidates.includes(constants_1.TELEGRAM_SERVICE_CHAT_ID))
282
331
  return false;
283
- if (readChats === undefined || readChats === null)
332
+ const entries = normalizeScopeList(readChats);
333
+ if (entries === undefined)
284
334
  return true;
285
- const entries = (Array.isArray(readChats) ? readChats : [readChats])
286
- .map(normalizeChatKey)
287
- .filter(Boolean);
288
335
  // An empty list is a configured empty list — deny, rather than silently
289
336
  // reading everything because someone left brackets behind.
290
337
  if (entries.length === 0)