clawgram 2.14.0 → 2.17.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
@@ -183,6 +183,12 @@ openclaw gateway restart
183
183
  openclaw gateway restart
184
184
  ```
185
185
 
186
+ One restart after installation is enough. From 2.17.0 the plugin declares
187
+ `channels.clawgram` as a hot-reloadable prefix, so later edits under it —
188
+ `allowFrom`, `groups`, `readChats`, proxy — are picked up by the Gateway's
189
+ config watcher and restart only this channel (a few seconds of MTProto
190
+ reconnect), not the whole Gateway.
191
+
186
192
 
187
193
  ## Configuration Reference
188
194
 
@@ -228,6 +234,7 @@ openclaw gateway restart
228
234
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
229
235
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
230
236
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
237
+ | `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
231
238
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
232
239
 
233
240
  Group config fields:
@@ -237,7 +244,82 @@ Group config fields:
237
244
  | `enabled` | boolean | `true` | Enables or disables replies in the group |
238
245
  | `groupPolicy` | `"open"` \| `"mention"` | `"mention"` | `open` replies to any group message, `mention` only on @mention or reply-to-self |
239
246
  | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group |
247
+ | `tools` | object | unset | `{ allow?, alsoAllow?, deny? }` — tool policy for this group; see [Per-group tools, skills and system prompt](#per-group-tools-skills-and-system-prompt) |
248
+ | `toolsBySender` | object | unset | Per-sender tool policy inside this group, keys `id:<id>`, `username:<handle>`, `name:<display>` or `*` |
249
+ | `skills` | string[] | unset | Skill allowlist for this group; `[]` = no skills here, unset = the agent's skills |
250
+ | `systemPrompt` | string | unset | Trusted prompt block appended for messages from this group |
251
+
252
+ ### Per-group tools, skills and system prompt
253
+
254
+ Since 2.17.0 a group entry can narrow what the assistant does *in that chat*
255
+ without touching the agent as a whole. The keys mirror the bundled Telegram
256
+ channel's group config, and `*` works as the default group for them too.
257
+
258
+ ```json
259
+ "groups": {
260
+ "-1001234567890": {
261
+ "groupPolicy": "mention",
262
+ "systemPrompt": "This chat is project BRO. Other projects are out of scope here.",
263
+ "skills": ["pm-standup", "pm-jira"],
264
+ "tools": { "deny": ["browser", "cron"] },
265
+ "toolsBySender": { "id:123456789": { "alsoAllow": ["cron"] } }
266
+ }
267
+ }
268
+ ```
269
+
270
+ | Key | Effect |
271
+ |---|---|
272
+ | `systemPrompt` | Appended to the system prompt as a trusted block for turns from this group — the place to say what the chat is about and what stays out of it. |
273
+ | `skills` | Skill allowlist for turns from this group. Omit to inherit the agent's skills; `[]` means no skills in this chat. |
274
+ | `tools` / `toolsBySender` | Tool policy resolved by OpenClaw core for this group (`toolsBySender` wins for a matching sender). Keys use core's typed grammar: `id:`, `username:`, `name:`, `channel:clawgram:<id>` or `*`. |
275
+
276
+ **Limits — read before relying on `tools`.** The policy governs OpenClaw's
277
+ gateway tools (`message`, `sessions_*`, `cron`, `memory_*`, …). Under CLI
278
+ backends such as `claude-cli` those reach the model through the loopback MCP
279
+ tool list and are filtered per group; the backend's own native tools —
280
+ `exec`, `read`, `write`, `edit`, `apply_patch`, `process` — are governed by
281
+ the agent's exec policy, not by the group. If a chat must not have a shell at
282
+ all, bind it to a separate agent without one; the routing is core's, and the
283
+ peer id carries the account prefix this channel uses:
284
+
285
+ ```json
286
+ "agents": {
287
+ "list": [
288
+ { "id": "main", "default": true, "workspace": "~/.openclaw/workspace" },
289
+ {
290
+ "id": "without-hands",
291
+ "workspace": "~/.openclaw/workspace-without-hands",
292
+ "model": "anthropic/claude-sonnet-5",
293
+ "tools": {
294
+ "deny": ["exec", "read", "write", "edit", "apply_patch", "process", "browser", "cron"],
295
+ "message": { "actions": { "allow": ["send"] }, "crossContext": { "allowWithinProvider": false } }
296
+ }
297
+ }
298
+ ]
299
+ },
300
+ "bindings": [
301
+ { "agentId": "without-hands", "match": { "channel": "clawgram", "peer": { "kind": "group", "id": "default:-1001234567890" } } }
302
+ ]
303
+ ```
304
+
305
+ Nothing above changes for a group that does not set these keys.
306
+
307
+ ### Message formatting
308
+
309
+ `replyParseMode` sets the outbound format for every path that does not name
310
+ one explicitly: replies, core-delivered text, media captions, and `send`
311
+ actions without a `parseMode` parameter. A per-call `parseMode` still wins.
312
+
313
+ | Mode | Behavior |
314
+ |---|---|
315
+ | `"html"` | **Recommended for agents.** The text is rendered before sending (2.15.0): markdown (`**bold**`, `*italic*`, `` `code` ``, ``` fences, `[text](url)`, `# headings`, `> quotes`, `~~strike~~`, `\|\|spoiler\|\|`) becomes Telegram entities, hand-written Telegram HTML (`<b>`, `<a href>`, `<code>`, …) passes through, structural HTML (`<ul>`, `<p>`, …) is dropped, and stray `<`, `>`, `&` arrive as literal text instead of vanishing into a failed tag. Markdown inside code is never converted. |
316
+ | `"markdown"` | GramJS's own markdown parser: `**`, `__`, `~~`, `` ` ``, ``` ``` ``` only — no links, no single-asterisk emphasis. |
317
+ | `"none"` | No parsing at all: the text is delivered exactly as typed. |
318
+ | unset | GramJS's historical default, which is its markdown parser — **not** plain text. Set `"none"` if you want plain. |
240
319
 
320
+ Agent-authored messages mix markdown and HTML freely, so `"html"` is the mode
321
+ that renders both. There is no reliable way to prompt a model out of writing
322
+ markdown; rendering it is the deterministic fix.
241
323
 
242
324
  ### Configuration variant for example
243
325
 
package/dist/channel.js CHANGED
@@ -61,6 +61,9 @@ const reactions_1 = require("./reactions");
61
61
  const manage_1 = require("./manage");
62
62
  const silent_reaction_1 = require("./silent-reaction");
63
63
  const chat_info_1 = require("./chat-info");
64
+ const topics_1 = require("./topics");
65
+ const dialogs_1 = require("./dialogs");
66
+ const group_tool_policy_1 = require("./group-tool-policy");
64
67
  const secret_refs_1 = require("./secret-refs");
65
68
  const secret_ref_runtime_1 = require("openclaw/plugin-sdk/secret-ref-runtime");
66
69
  const group_reply_address_1 = require("./group-reply-address");
@@ -132,6 +135,10 @@ function resolveAccountReadChats(cfg, accountId) {
132
135
  * `readChats`, an absent value already means "deny", so there is nothing to
133
136
  * tell apart here.
134
137
  */
138
+ /** Chat discovery as configured; absent means "deny", like management scope. */
139
+ function resolveAccountDiscoverChats(cfg, accountId) {
140
+ return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.discoverChats;
141
+ }
135
142
  function resolveAccountManageChats(cfg, accountId) {
136
143
  return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.manageChats;
137
144
  }
@@ -319,6 +326,20 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
319
326
  aliases: ["tguserbot"],
320
327
  },
321
328
  capabilities: CHANNEL_CAPABILITIES,
329
+ // Core plans config hot reloads from these prefixes. Without the
330
+ // declaration a changed `channels.clawgram.*` path matches no rule and
331
+ // core restarts the whole Gateway (SIGUSR1, all runs aborted) — measured
332
+ // 2026-08-13. With it, the same edit restarts only this channel. No
333
+ // `noopPrefixes`: `groups`/`allowFrom`/`readChats` are read from the cfg
334
+ // captured in `startAccount`, so a channel restart is exactly what an
335
+ // edit needs to take effect.
336
+ reload: { configPrefixes: ["channels.clawgram"] },
337
+ // Per-group `tools` / `toolsBySender` from the config. Core asks the
338
+ // channel first because only the channel knows that its group ids carry
339
+ // an account prefix; see src/group-tool-policy.ts.
340
+ groups: {
341
+ resolveToolPolicy: group_tool_policy_1.resolveClawgramGroupToolPolicy,
342
+ },
322
343
  agentPrompt: {
323
344
  // Nothing here steers reactions, and that is deliberate. 2.8.0 added a
324
345
  // `reactionGuidance` hook and 2.9.0 moved the same text onto these
@@ -337,6 +358,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
337
358
  "For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
338
359
  "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.",
339
360
  "Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
361
+ "Use the `topics` action to list a forum's topics by name (optional `query` narrows by title); that is where a `threadId` comes from when someone names a topic instead of quoting a message in it.",
362
+ "Pass that `threadId` to `read` as well: without it a forum read returns every topic interleaved rather than the one that was asked about.",
363
+ "Use the `dialogs` action to find out which group chats this account is actually in — including ones nobody has configured yet. It reports id, title and type only, never direct chats, and only when the account enables `discoverChats`.",
340
364
  "Use `createGroup` (title, optional about, optional users) to create a new Telegram supergroup; `addMembers`/`removeMember` change who is in a managed chat, `promoteAdmin`/`demoteAdmin` grant or revoke admin rights, `transferOwnership` hands the chat over, `inviteLink` issues an invite link for people Telegram refused to add directly.",
341
365
  ],
342
366
  messageToolCapabilities: () => [
@@ -345,6 +369,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
345
369
  "clawgram supports Telegram forum topics via the `threadId` parameter on group sends.",
346
370
  "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.",
347
371
  "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.",
372
+ "clawgram can list the topics of a forum supergroup via `topics`: id, title, last message, and whether a topic is closed, hidden or pinned.",
373
+ "clawgram can list the group chats the account belongs to via `dialogs`, when the account sets discoverChats. Metadata only, no direct chats — it answers \"where am I\", not \"what was said\".",
348
374
  "clawgram can manage chats where the account's manageChats config allows it: create supergroups, add and remove members, promote and demote admins, transfer ownership, and export invite links.",
349
375
  ],
350
376
  },
@@ -596,7 +622,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
596
622
  const sendTextToConversation = async (args) => {
597
623
  const targets = [conversationTarget, ...conversationFallbackTargets];
598
624
  // Replies have no per-call parseMode slot — the format is an
599
- // account setting (2.3.1); absent keeps plain text.
625
+ // account setting (2.3.1); absent keeps the GramJS default
626
+ // (its markdown parser — not plain text, see 2.15.0 notes).
600
627
  const replyParseMode = gram.replyParseMode;
601
628
  let lastError;
602
629
  for (const target of targets) {
@@ -751,6 +778,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
751
778
  ReplyToIsQuote: normalized.replyIsQuote,
752
779
  MessageThreadId: normalized.messageThreadId,
753
780
  NativeChannelId: normalized.chatId,
781
+ // Trusted per-group prompt block from `groups.<id>.systemPrompt`.
782
+ // Core normalizes it (`normalizeTrustedTextField`) and appends
783
+ // it to the system prompt for this turn. Undefined = no block.
784
+ GroupSystemPrompt: groupConfig.systemPrompt,
754
785
  OriginatingChannel: "clawgram",
755
786
  OriginatingTo: conversationRouteTarget,
756
787
  });
@@ -866,6 +897,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
866
897
  },
867
898
  replyOptions: {
868
899
  onModelSelected,
900
+ // `groups.<id>.skills` → core's per-turn skill allowlist.
901
+ // Undefined = inherit the agent's skills; [] = none here.
902
+ skillFilter: groupConfig.skillFilter,
869
903
  },
870
904
  });
871
905
  log?.info?.("clawgram group dispatch completed", {
@@ -1292,7 +1326,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1292
1326
  // never sees a way to send the file, announces it in words, and the
1293
1327
  // file stays on disk. That is exactly what happened on 2026-08-07.
1294
1328
  actions: [
1295
- "send", "read", "participants", "joins", "react", "chatInfo", "upload-file",
1329
+ "send", "read", "participants", "joins", "react", "chatInfo", "topics", "dialogs", "upload-file",
1296
1330
  // Chat management (2.12.0) — gated by the account's manageChats
1297
1331
  // scope; without it every one of these is refused.
1298
1332
  "createGroup", "addMembers", "removeMember",
@@ -1392,6 +1426,80 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1392
1426
  participants: membership.participants,
1393
1427
  });
1394
1428
  }
1429
+ // Topic names. A forum chat is addressed by topic id, and until now an
1430
+ // id could only be lifted off an inbound message — so a topic nobody had
1431
+ // written in yet was unreachable, and one named in words was unfindable.
1432
+ // Titles say what a chat is working on, so the read scope gates them.
1433
+ if (action === "topics" || action === "forumTopics") {
1434
+ const topicsParams = (0, topics_1.parseTopicsParams)(params);
1435
+ const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
1436
+ if (!topicsAccountId) {
1437
+ throw new Error("clawgram: no configured account found");
1438
+ }
1439
+ if (!(0, history_1.isChatReadable)(topicsParams.target, resolveAccountReadChats(cfg, topicsAccountId))) {
1440
+ actionLog.warn("clawgram topics refused: chat outside read scope", {
1441
+ accountId: topicsAccountId,
1442
+ target: topicsParams.target,
1443
+ });
1444
+ throw new Error(`clawgram: not-allowed-chat ${topicsParams.target}`);
1445
+ }
1446
+ const topicsGram = runtimes.get(topicsAccountId);
1447
+ if (!topicsGram) {
1448
+ throw new Error(`clawgram: runtime not found for account ${topicsAccountId}`);
1449
+ }
1450
+ const forum = await topicsGram.listTopics(topicsParams);
1451
+ actionLog.info("clawgram handleAction topics completed", {
1452
+ accountId: topicsAccountId,
1453
+ target: topicsParams.target,
1454
+ limit: topicsParams.limit,
1455
+ returned: forum.topics.length,
1456
+ truncated: forum.truncated,
1457
+ });
1458
+ return (0, core_1.jsonResult)({
1459
+ ok: true,
1460
+ accountId: topicsAccountId,
1461
+ chatId: forum.chatId ?? topicsParams.target,
1462
+ count: forum.topics.length,
1463
+ truncated: forum.truncated,
1464
+ topics: forum.topics,
1465
+ });
1466
+ }
1467
+ // Which chats this account is in. Not gated by `readChats` — the whole
1468
+ // point is to find chats that are not in it yet — so it has a gate of
1469
+ // its own, is metadata only, and never reports direct chats.
1470
+ if (action === "dialogs" || action === "chats") {
1471
+ const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
1472
+ const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
1473
+ if (!dialogsAccountId) {
1474
+ throw new Error("clawgram: no configured account found");
1475
+ }
1476
+ if (!(0, dialogs_1.isChatDiscoveryEnabled)(resolveAccountDiscoverChats(cfg, dialogsAccountId))) {
1477
+ actionLog.warn("clawgram dialogs refused: chat-discovery is not enabled", {
1478
+ accountId: dialogsAccountId,
1479
+ });
1480
+ throw new Error("clawgram: chat-discovery is not enabled");
1481
+ }
1482
+ const dialogsGram = runtimes.get(dialogsAccountId);
1483
+ if (!dialogsGram) {
1484
+ throw new Error(`clawgram: runtime not found for account ${dialogsAccountId}`);
1485
+ }
1486
+ const found = await dialogsGram.listDialogs(dialogsParams);
1487
+ // Counts only: which chats a person's account sits in is exactly the
1488
+ // kind of thing that should not be sitting in a log.
1489
+ actionLog.info("clawgram handleAction dialogs completed", {
1490
+ accountId: dialogsAccountId,
1491
+ limit: dialogsParams.limit,
1492
+ returned: found.dialogs.length,
1493
+ truncated: found.truncated,
1494
+ });
1495
+ return (0, core_1.jsonResult)({
1496
+ ok: true,
1497
+ accountId: dialogsAccountId,
1498
+ count: found.dialogs.length,
1499
+ truncated: found.truncated,
1500
+ dialogs: found.dialogs,
1501
+ });
1502
+ }
1395
1503
  // Where this account was recently added, and by whom. Reading the journal
1396
1504
  // has no scope check of its own: it only ever contains chats this account
1397
1505
  // was put into, which is exactly what the caller is allowed to learn.
@@ -1762,6 +1870,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1762
1870
  target: uploadTo,
1763
1871
  file,
1764
1872
  caption: caption || undefined,
1873
+ // Same resolution as the text `send`: per-call value wins, an
1874
+ // omitted one inherits the account format (2.15.0). A caption is
1875
+ // the same prose as a message and renders identically.
1876
+ parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
1765
1877
  replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
1766
1878
  messageThreadId: parseOptionalThreadId(uploadThreadId),
1767
1879
  asVoice,
@@ -2065,6 +2177,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2065
2177
  target,
2066
2178
  file,
2067
2179
  caption: ctx.caption ?? ctx.text,
2180
+ // Captions follow the account reply format like every other reply:
2181
+ // they are the same agent prose, just attached to a file (2.15.0).
2182
+ parseMode: gram.replyParseMode,
2068
2183
  replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
2069
2184
  messageThreadId,
2070
2185
  asVoice: ctx.audioAsVoice === true,
package/dist/chat-info.js CHANGED
@@ -17,6 +17,7 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.parseChatInfoParams = parseChatInfoParams;
19
19
  exports.describeChat = describeChat;
20
+ const helpers_1 = require("./helpers");
20
21
  function parseChatInfoParams(params, toolContext) {
21
22
  const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
22
23
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
@@ -78,7 +79,9 @@ function describeChat(entity, full) {
78
79
  chatId: readId(raw?.id),
79
80
  type,
80
81
  title: type === "direct" ? resolveUserTitle(raw) : readString(raw?.title),
81
- username: readString(raw?.username),
82
+ // Not `raw.username`: an account or chat holding more than one handle keeps
83
+ // them in `usernames[]` and leaves the legacy field empty.
84
+ username: (0, helpers_1.resolveActiveUsername)(raw),
82
85
  about: readString(fullChat?.about),
83
86
  pinnedMessageId: readId(fullChat?.pinnedMsgId),
84
87
  };
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DIALOGS_MAX_LIMIT = exports.DIALOGS_DEFAULT_LIMIT = void 0;
4
+ exports.parseDialogsParams = parseDialogsParams;
5
+ exports.isChatDiscoveryEnabled = isChatDiscoveryEnabled;
6
+ exports.normalizeDialogs = normalizeDialogs;
7
+ /**
8
+ * Which chats this account is actually in.
9
+ *
10
+ * Everything else here answers questions about a chat the caller can already
11
+ * name. Nothing answered "where am I now" — the assistant learned that from a
12
+ * service message Telegram sends when somebody adds it, and large supergroups
13
+ * do not send one. A chat could therefore hold the account for weeks while
14
+ * every message from it was dropped as "group not present in groups config",
15
+ * with no trace anywhere that the account was even a member.
16
+ *
17
+ * Discovery is metadata only — id, title, type — and never direct chats: a
18
+ * personal account also sits in family and one-to-one conversations, and
19
+ * enumerating those is the surveillance the read scope exists to prevent.
20
+ * It stays off until the account sets `discoverChats`.
21
+ */
22
+ const normalize_js_1 = require("./normalize.js");
23
+ exports.DIALOGS_DEFAULT_LIMIT = 100;
24
+ exports.DIALOGS_MAX_LIMIT = 500;
25
+ function parseDialogsParams(params) {
26
+ const rawQuery = params.query ?? params.search ?? params.title;
27
+ const trimmedQuery = typeof rawQuery === "string" ? rawQuery.trim() : "";
28
+ const query = trimmedQuery.length > 0 ? trimmedQuery : undefined;
29
+ const rawLimit = params.limit;
30
+ if (rawLimit === undefined || rawLimit === null || rawLimit === "") {
31
+ return { limit: exports.DIALOGS_DEFAULT_LIMIT, query };
32
+ }
33
+ const parsed = Number(rawLimit);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) {
35
+ throw new Error("clawgram: dialogs limit must be a positive number");
36
+ }
37
+ return { limit: Math.min(Math.floor(parsed), exports.DIALOGS_MAX_LIMIT), query };
38
+ }
39
+ /** Chat discovery is off unless the account asks for it. */
40
+ function isChatDiscoveryEnabled(discoverChats) {
41
+ return discoverChats === true;
42
+ }
43
+ function resolveDialogType(dialog) {
44
+ if (dialog.isUser === true)
45
+ return undefined;
46
+ const entity = (dialog.entity ?? {});
47
+ if (dialog.isChannel === true) {
48
+ // Telegram models supergroups and broadcast channels with one constructor;
49
+ // `megagroup` is what separates "a group with history" from "a feed".
50
+ return entity.megagroup === true ? "supergroup" : "channel";
51
+ }
52
+ return dialog.isGroup === true ? "group" : undefined;
53
+ }
54
+ function matchesQuery(title, query) {
55
+ if (!query)
56
+ return true;
57
+ if (!title)
58
+ return false;
59
+ return title.toLocaleLowerCase().includes(query.toLocaleLowerCase());
60
+ }
61
+ function normalizeDialogs(raw, options = {}) {
62
+ if (!Array.isArray(raw))
63
+ return [];
64
+ const dialogs = [];
65
+ for (const entry of raw) {
66
+ if (!entry || typeof entry !== "object")
67
+ continue;
68
+ const dialog = entry;
69
+ const type = resolveDialogType(dialog);
70
+ if (!type)
71
+ continue;
72
+ const chatId = (0, normalize_js_1.toStringId)(dialog.id);
73
+ if (!chatId)
74
+ continue;
75
+ const entity = (dialog.entity ?? {});
76
+ const rawTitle = dialog.title ?? entity.title;
77
+ const title = typeof rawTitle === "string" && rawTitle.length > 0 ? rawTitle : undefined;
78
+ if (!matchesQuery(title, options.query))
79
+ continue;
80
+ const summary = { chatId, type };
81
+ if (title !== undefined)
82
+ summary.title = title;
83
+ if (entity.forum === true)
84
+ summary.isForum = true;
85
+ dialogs.push(summary);
86
+ }
87
+ return dialogs;
88
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GramJsClientManager = void 0;
4
+ exports.buildParticipantsQuery = buildParticipantsQuery;
4
5
  exports.buildVoiceNoteParams = buildVoiceNoteParams;
5
6
  const telegram_1 = require("telegram");
6
7
  const sessions_1 = require("telegram/sessions");
@@ -8,9 +9,12 @@ const sessions_1 = require("telegram/sessions");
8
9
  // the package has no `exports` field to forbid it.
9
10
  const Password_1 = require("telegram/Password");
10
11
  const helpers_1 = require("./helpers");
12
+ const html_render_1 = require("./html-render");
11
13
  const proxy_config_1 = require("./proxy-config");
12
14
  const secret_refs_1 = require("./secret-refs");
13
15
  const history_1 = require("./history");
16
+ const topics_1 = require("./topics");
17
+ const dialogs_1 = require("./dialogs");
14
18
  const manage_1 = require("./manage");
15
19
  function toStringId(value) {
16
20
  if (value === null || value === undefined)
@@ -84,6 +88,22 @@ function parseTargetWithThread(rawTarget) {
84
88
  chatId: raw,
85
89
  };
86
90
  }
91
+ /**
92
+ * Membership query for `getParticipants`.
93
+ *
94
+ * The filter has to arrive as a TL constructor. `getParticipants` accepts an
95
+ * unknown `filter` value without complaining and falls back to everyone, so a
96
+ * plain `"admins"` string would return the whole chat under a name promising
97
+ * otherwise — and an allowFrom rebuilt from that list would open a 1000-person
98
+ * chat to all of it.
99
+ */
100
+ function buildParticipantsQuery(args) {
101
+ const query = { limit: args.limit };
102
+ if (args.filter === "admins") {
103
+ query.filter = new telegram_1.Api.ChannelParticipantsAdmins();
104
+ }
105
+ return query;
106
+ }
87
107
  /**
88
108
  * Voice-message option for `sendFile`.
89
109
  *
@@ -323,10 +343,20 @@ class GramJsClientManager {
323
343
  const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
324
344
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
325
345
  return this.client.sendMessage(resolved.peer, {
326
- message: args.text,
327
- // GramJS accepts "md" | "html"; absent keeps plain text so every
328
- // pre-2.3.0 caller behaves exactly as before.
329
- ...(args.parseMode ? { parseMode: args.parseMode === "markdown" ? "md" : "html" } : {}),
346
+ // In html mode the text is rendered first: the agent writes markdown,
347
+ // Telegram HTML, or both, and GramJS's HTML parser alone would ship
348
+ // the markdown as literal asterisks (2026-08-12 00:13 UTC, a whole
349
+ // monthly report of them).
350
+ message: args.parseMode === "html" ? (0, html_render_1.renderTelegramHtml)(args.text) : args.text,
351
+ // GramJS accepts "md" | "html". `false` switches parsing off — needed
352
+ // because an *absent* mode is not plain text: GramJS then applies its
353
+ // own default markdown parser, and always has. "none" is the honest
354
+ // spelling of "exactly as typed".
355
+ ...(args.parseMode === "none"
356
+ ? { parseMode: false }
357
+ : args.parseMode
358
+ ? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
359
+ : {}),
330
360
  ...replyParams,
331
361
  });
332
362
  }
@@ -431,7 +461,13 @@ class GramJsClientManager {
431
461
  */
432
462
  async listMessages(args) {
433
463
  const resolved = await this.resolvePeer(args.target);
434
- const query = (0, history_1.buildHistoryQuery)(args);
464
+ // A topic can be named two ways — `chatId:topic:N` in the target or a
465
+ // `threadId` parameter beside it. The explicit parameter wins; both used to
466
+ // be parsed and then dropped before the query was built.
467
+ const query = (0, history_1.buildHistoryQuery)({
468
+ ...args,
469
+ messageThreadId: args.messageThreadId ?? resolved.messageThreadId,
470
+ });
435
471
  const fetched = await this.client.getMessages(resolved.peer, query);
436
472
  const raw = Array.isArray(fetched) ? fetched : [];
437
473
  return {
@@ -451,37 +487,50 @@ class GramJsClientManager {
451
487
  */
452
488
  async listParticipants(args) {
453
489
  const resolved = await this.resolvePeer(args.target);
454
- const fetched = await this.client.getParticipants(resolved.peer, {
455
- limit: args.limit,
456
- });
490
+ const fetched = await this.client.getParticipants(resolved.peer, buildParticipantsQuery(args));
457
491
  const raw = Array.isArray(fetched) ? fetched : [];
458
- const participants = [];
459
- for (const entry of raw) {
460
- const rawId = entry?.id;
461
- if (rawId === undefined || rawId === null) {
462
- continue;
463
- }
464
- const username = typeof entry?.username === "string" && entry.username.length > 0
465
- ? entry.username
466
- : undefined;
467
- const member = {
468
- userId: String(rawId),
469
- username,
470
- isBot: entry?.bot === true,
471
- };
472
- // Display names are personal data, so they are opt-in: only the identity
473
- // linking flow asks for them, and it discards them once a link is made.
474
- if (args.includeNames) {
475
- if (typeof entry?.firstName === "string" && entry.firstName.length > 0)
476
- member.firstName = entry.firstName;
477
- if (typeof entry?.lastName === "string" && entry.lastName.length > 0)
478
- member.lastName = entry.lastName;
479
- }
480
- participants.push(member);
481
- }
482
492
  return {
483
493
  chatId: resolved.chatId,
484
- participants,
494
+ participants: (0, history_1.normalizeParticipants)(raw, { includeNames: args.includeNames }),
495
+ truncated: raw.length >= args.limit,
496
+ };
497
+ }
498
+ /**
499
+ * The group chats this account belongs to.
500
+ *
501
+ * Deliberately thin: `getDialogs` also returns every private conversation,
502
+ * and `normalizeDialogs` drops them before anything else sees the list.
503
+ */
504
+ async listDialogs(args) {
505
+ const fetched = await this.client.getDialogs({ limit: args.limit });
506
+ const raw = Array.isArray(fetched) ? fetched : [];
507
+ return {
508
+ dialogs: (0, dialogs_1.normalizeDialogs)(raw, { query: args.query }),
509
+ truncated: raw.length >= args.limit,
510
+ };
511
+ }
512
+ /**
513
+ * Topics of a forum supergroup, by name.
514
+ *
515
+ * `q` is passed to Telegram when the caller narrows the list, and the same
516
+ * text is applied again to the result: the server-side search is not
517
+ * guaranteed to be there on every layer, and a filter that silently does
518
+ * nothing is worse than one that runs twice.
519
+ */
520
+ async listTopics(args) {
521
+ const resolved = await this.resolvePeer(args.target, { kind: "channel" });
522
+ const result = await this.client.invoke(new telegram_1.Api.channels.GetForumTopics({
523
+ channel: resolved.peer,
524
+ ...(args.query ? { q: args.query } : {}),
525
+ offsetDate: 0,
526
+ offsetId: 0,
527
+ offsetTopic: 0,
528
+ limit: args.limit,
529
+ }));
530
+ const raw = Array.isArray(result?.topics) ? result.topics : [];
531
+ return {
532
+ chatId: resolved.chatId,
533
+ topics: (0, topics_1.normalizeForumTopics)(raw, { query: args.query }),
485
534
  truncated: raw.length >= args.limit,
486
535
  };
487
536
  }
@@ -561,7 +610,18 @@ class GramJsClientManager {
561
610
  const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
562
611
  return this.client.sendFile(resolved.peer, {
563
612
  file: args.file,
564
- caption: args.caption,
613
+ // Captions are agent prose too — the outbound path sends `caption ??
614
+ // text` — so they render exactly like sendText does. Before 2.15.0
615
+ // captions carried no mode at all, which meant GramJS's default
616
+ // markdown pass, a third rendering behavior nobody chose.
617
+ caption: args.parseMode === "html" && args.caption
618
+ ? (0, html_render_1.renderTelegramHtml)(args.caption)
619
+ : args.caption,
620
+ ...(args.parseMode === "none"
621
+ ? { parseMode: false }
622
+ : args.parseMode
623
+ ? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
624
+ : {}),
565
625
  ...replyParams,
566
626
  ...buildVoiceNoteParams(args.asVoice),
567
627
  });
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveClawgramGroupToolPolicy = resolveClawgramGroupToolPolicy;
4
+ const channel_policy_1 = require("openclaw/plugin-sdk/channel-policy");
5
+ const constants_1 = require("./constants");
6
+ const helpers_1 = require("./helpers");
7
+ /**
8
+ * `groups.resolveToolPolicy` — core calls this before its own lookup when a
9
+ * message from a group session runs. The only thing this channel adds is the
10
+ * id translation: core passes the scoped peer id (`<accountId>:<chatId>`),
11
+ * the config is keyed by the bare chat id. Everything else — per-account vs
12
+ * top-level `groups`, the `*` default, `toolsBySender` by id/username/name —
13
+ * is the SDK's `resolveChannelGroupToolsPolicy`, the same function the
14
+ * bundled Telegram channel delegates to.
15
+ *
16
+ * What the returned policy governs: gateway tools (message, sessions_*,
17
+ * cron, memory_*, …) — under CLI backends via the loopback MCP tool list.
18
+ * Not the CLI backend's own exec/read/write; those are per agent, not per
19
+ * group, and only a separate agent bound to the chat restricts them.
20
+ */
21
+ function resolveClawgramGroupToolPolicy(ctx) {
22
+ const groupId = (0, helpers_1.stripAccountScopedGroupId)(ctx.groupId, ctx.accountId);
23
+ if (!groupId) {
24
+ return undefined;
25
+ }
26
+ return (0, channel_policy_1.resolveChannelGroupToolsPolicy)({
27
+ cfg: ctx.cfg,
28
+ channel: constants_1.CHANNEL_ID,
29
+ groupId,
30
+ accountId: ctx.accountId ?? "default",
31
+ senderId: ctx.senderId,
32
+ senderName: ctx.senderName,
33
+ senderUsername: ctx.senderUsername,
34
+ senderE164: ctx.senderE164,
35
+ });
36
+ }