clawgram 2.17.1 → 2.19.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
@@ -32,6 +32,7 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
32
32
  - **Read receipts** — mark messages as read
33
33
  - **Emoji reactions** — acknowledge a message with a reaction instead of a reply (`react` action)
34
34
  - **Chat metadata** — title, type, member count, description, forum flag and pinned message (`chatInfo` action)
35
+ - **Attachments on demand** — fetch the photo or voice note on any message in read scope, as a reading, a file, or both (`fetch-media` action)
35
36
  - **Chat management** — create supergroups, add/remove members, promote/demote admins, transfer ownership, export invite links (`createGroup`, `addMembers`, `removeMember`, `promoteAdmin`, `demoteAdmin`, `transferOwnership`, `inviteLink`) — off until `manageChats` allows it
36
37
  - **User allowlist** — control which user has access to send messages for direct
37
38
  - **Chat allowlist** — control which chats the assistant can access
@@ -236,19 +237,42 @@ reconnect), not the whole Gateway.
236
237
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
237
238
  | `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
238
239
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
240
+ | `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 |
239
241
 
240
242
  Group config fields:
241
243
 
242
244
  | Field | Type | Default | Description |
243
245
  |---|---|---|---|
244
246
  | `enabled` | boolean | `true` | Enables or disables replies in the group |
245
- | `groupPolicy` | `"open"` \| `"mention"` | `"mention"` | `open` replies to any group message, `mention` only on @mention or reply-to-self |
247
+ | `groupPolicy` | `"open"` \| `"mention"` \| `"tag"` | `"mention"` | What wakes the agent here see [What wakes the agent in a group](#what-wakes-the-agent-in-a-group) |
246
248
  | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group |
247
249
  | `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
250
  | `toolsBySender` | object | unset | Per-sender tool policy inside this group, keys `id:<id>`, `username:<handle>`, `name:<display>` or `*` |
249
251
  | `skills` | string[] | unset | Skill allowlist for this group; `[]` = no skills here, unset = the agent's skills |
250
252
  | `systemPrompt` | string | unset | Trusted prompt block appended for messages from this group |
251
253
 
254
+ ### What wakes the agent in a group
255
+
256
+ `groupPolicy` decides which messages start a turn at all. It is a ladder, widest
257
+ first, and the rung is chosen per group:
258
+
259
+ | Rung | Wakes on | Use it when |
260
+ |---|---|---|
261
+ | `open` | every message in the chat | the agent works as a member of the team and an address may carry no name at all |
262
+ | `mention` | the name it answers to, an `@username`, or a reply to it | the default: the agent is a participant, not a fixture |
263
+ | `tag` | an `@username` or a reply to it — **never the name** | the name occurs in conversation constantly, as it does in a large community |
264
+
265
+ Two things are worth knowing before reaching for `open`:
266
+
267
+ - it spends a **full turn on every message**, chatter included. Whether words
268
+ are owed is then the agent's decision, and most of the time the answer is no;
269
+ - the typing indicator is shown only for messages that actually addressed the
270
+ agent. Under `open` the room would otherwise watch it "type" through
271
+ conversations it is merely reading, with nothing following.
272
+
273
+ Emoji reactions are unaffected by the rung: the channel leaves one only where
274
+ the agent was genuinely addressed, so background reading stays unmarked.
275
+
252
276
  ### Per-group tools, skills and system prompt
253
277
 
254
278
  Since 2.17.0 a group entry can narrow what the assistant does *in that chat*
@@ -716,6 +740,53 @@ first. These errors are surfaced as-is rather than retried.
716
740
  }
717
741
  ```
718
742
 
743
+ ## Fetching attachments
744
+
745
+ Inbound attachments are read as they arrive: a photo or a voice note sent while the agent is being
746
+ addressed becomes text in the message body, and the bytes are dropped. History reads (`read`) carry
747
+ attachment *metadata* — kind, file name, size, duration — and fetch nothing. That leaves two things
748
+ out: an image posted in a chat before the agent was addressed, and any reuse of an image at all,
749
+ because the file the inbound path read is deleted the moment the reading ends.
750
+
751
+ `fetch-media` covers both. It takes one message and returns what is attached to it.
752
+
753
+ | Parameter | Aliases | Notes |
754
+ |---|---|---|
755
+ | `chatId` | `target`, `to`, `chat` | Same targets as everywhere else: `@username`, numeric id, `me` |
756
+ | `messageId` | `id`, `message`, `msgId` | The id `read` reported for the message |
757
+ | `mode` | — | `both` (default), `read`, `file` |
758
+
759
+ Modes differ in what happens to the bytes:
760
+
761
+ - **`read`** — the attachment is turned into text (an image described, a voice note transcribed) and
762
+ the file is deleted, exactly the inbound contract. No path is returned.
763
+ - **`file`** — the file is kept and its path returned, and no understanding model is called. This is
764
+ what forwarding through `upload-file` or attaching to a ticket needs.
765
+ - **`both`** — the default: the reading *and* the path, from a single download.
766
+
767
+ The action is confined by `readChats`, the same scope that gates history and membership: a chat the
768
+ account may not read history from cannot be a source of bytes either. The action name also answers
769
+ to `fetchMedia`, `download-media`, `downloadMedia` and `getMedia`.
770
+
771
+ What comes back is `ok: true` with `media` (the same metadata `read` reports), `understanding`
772
+ (`description` or `transcript`), and `text` and/or `filePath` per the mode. A fetch that yields
773
+ nothing is not an error — it says which nothing it was:
774
+
775
+ | `error` | Meaning |
776
+ |---|---|
777
+ | `message-not-found` | No such message, or it was deleted |
778
+ | `no-media` | The message is text only |
779
+ | `unsupported-media` | An attachment this channel does not read — video, a spreadsheet, a sticker |
780
+ | `media-too-large` | Over the 25 MB inbound cap. Telegram reports no size for a compressed photo, so this is a document limit in practice |
781
+
782
+ A reading that fails while the download succeeded still returns `ok: true`, with `readError` beside
783
+ the path: the bytes are already there and can still be forwarded.
784
+
785
+ **Fetched files live in the system temp directory** (`clawgram-fetched/`), named after the chat and
786
+ message they came from, and are pruned after 24 hours by the next fetch. Nothing else removes them,
787
+ and nothing sends them anywhere — putting a fetched file in a chat is an ordinary `upload-file`,
788
+ with whatever confirmation the deployment requires for that.
789
+
719
790
  ## Security and privacy
720
791
 
721
792
  This plugin holds credentials for a real Telegram account and handles private correspondence. What
@@ -726,7 +797,7 @@ that means in practice, and what the code does about it:
726
797
  | `apiHash`, `sessionString` | `openclaw.json`, or a secret store | Written there by `--auth`. Never logged. Since 2.1.0 the session string is not printed after login either — only shown, behind an explicit warning, if you decline the automatic config write. Since 2.2.0 both accept a **SecretRef** instead of a literal, so the credential need not sit in the config file at all |
727
798
  | Proxy password | `accounts.*.proxy.password`, or a secret store | Also accepts a SecretRef since 2.2.0. Marked `sensitive` in `uiHints`; diagnostics say `socks4`/`socks5` and nothing more. An invalid proxy fails the account rather than falling back to a direct connection, which would leak the host IP to Telegram |
728
799
  | Message bodies | channel logs | **Not logged.** Outbound sends record recipient, ids and `textLength`. Until 2.1.0 the full outbound text was written to the channel log — if you ran 2.0.x, treat those journal entries as containing private correspondence |
729
- | Read scope | `accounts.*.readChats` | History and membership reads are confined to the listed chats. Absent means no restriction; an empty array denies everything |
800
+ | Read scope | `accounts.*.readChats` | History, membership and attachment fetches are confined to the listed chats. Absent means no restriction; an empty array denies everything |
730
801
  | Manage scope | `accounts.*.manageChats` | Creating groups, changing membership, admin rights, ownership and invite links are confined to the listed chats — and **off entirely** when the key is absent or empty (opposite default to `readChats`, because these actions change chats rather than read them) |
731
802
  | 2FA password | `accounts.*.twoFaPassword`, or a secret store | Read only by `transferOwnership`, exchanged for an SRP proof in-process. Accepts a SecretRef since 2.12.0; `sensitive` in `uiHints`; on the forbidden-log-keys list the static tests enforce |
732
803
  | Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions |
package/dist/channel.js CHANGED
@@ -12,6 +12,15 @@ const node_fs_1 = require("node:fs");
12
12
  * a different conversation from a spoken line or a screenshot, and the
13
13
  * transfer is not free. */
14
14
  const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
15
+ /**
16
+ * How long a file fetched by `fetch-media` stays on disk.
17
+ *
18
+ * Long enough for the turn that asked for it and the next one — forwarding a
19
+ * screenshot happens minutes after reading it, not days — and short enough
20
+ * that a chat full of images does not silently become a copy of itself in the
21
+ * temp directory.
22
+ */
23
+ const FETCHED_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
15
24
  /**
16
25
  * What this channel promises the Gateway.
17
26
  *
@@ -43,6 +52,7 @@ const CHANNEL_CAPABILITIES = {
43
52
  },
44
53
  };
45
54
  const media_1 = require("./media");
55
+ const fetch_media_1 = require("./fetch-media");
46
56
  const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
47
57
  const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
48
58
  const tool_send_1 = require("openclaw/plugin-sdk/tool-send");
@@ -80,6 +90,22 @@ function readAccountReactionLevel(cfg, accountId) {
80
90
  }
81
91
  return cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
82
92
  }
93
+ /**
94
+ * Model ref for the emoji pick, when the account names one.
95
+ *
96
+ * Picking one emoji out of a fixed list of 68 is the cheapest judgement this
97
+ * channel makes and the only model call it makes on its own; running it on the
98
+ * agent's own head spends the expensive quota on a decision a small model
99
+ * makes just as well.
100
+ */
101
+ function readAccountReactionModel(cfg, accountId) {
102
+ const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
103
+ if (!resolvedAccountId) {
104
+ return undefined;
105
+ }
106
+ const raw = cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionModel;
107
+ return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
108
+ }
83
109
  /**
84
110
  * Wires `reactToSilentMention` to this account's runtime, config and log.
85
111
  *
@@ -96,6 +122,7 @@ async function reactToSilentMentionForAccount(params) {
96
122
  }
97
123
  await (0, silent_reaction_1.reactToSilentMention)({
98
124
  appetite: (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(params.cfg, params.accountId)),
125
+ model: readAccountReactionModel(params.cfg, params.accountId),
99
126
  wasMentioned: params.wasMentioned,
100
127
  chatId: params.chatId,
101
128
  messageId: params.messageId,
@@ -222,6 +249,33 @@ function resolveAgentDirForMedia(cfg) {
222
249
  const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
223
250
  return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
224
251
  }
252
+ /**
253
+ * Turns a downloaded attachment into text.
254
+ *
255
+ * Shared by the inbound path and by `fetch-media`: the backend choice lives in
256
+ * `runtime.mediaUnderstanding`, and both callers have to make exactly the same
257
+ * call — an image read on arrival and the same image read on request must not
258
+ * become two different readings because two call sites drifted.
259
+ */
260
+ async function understandAttachmentFile(params) {
261
+ const media = params.runtime?.mediaUnderstanding;
262
+ if (!media)
263
+ return undefined;
264
+ const result = params.understanding === "transcript"
265
+ ? await media.transcribeAudioFile({
266
+ filePath: params.filePath,
267
+ cfg: params.cfg,
268
+ mime: params.mimeType,
269
+ })
270
+ : await media.describeImageFile({
271
+ filePath: params.filePath,
272
+ cfg: params.cfg,
273
+ mime: params.mimeType,
274
+ agentDir: resolveAgentDirForMedia(params.cfg),
275
+ });
276
+ const text = typeof result?.text === "string" ? result.text.trim() : "";
277
+ return text || undefined;
278
+ }
225
279
  async function readInboundAttachment(params) {
226
280
  const media = params.runtime?.mediaUnderstanding;
227
281
  const message = params.event?.message;
@@ -250,19 +304,13 @@ async function readInboundAttachment(params) {
250
304
  return undefined;
251
305
  }
252
306
  try {
253
- const result = downloaded.understanding === "transcript"
254
- ? await media.transcribeAudioFile({
255
- filePath: downloaded.path,
256
- cfg: params.cfg,
257
- mime: downloaded.mimeType,
258
- })
259
- : await media.describeImageFile({
260
- filePath: downloaded.path,
261
- cfg: params.cfg,
262
- mime: downloaded.mimeType,
263
- agentDir: resolveAgentDirForMedia(params.cfg),
264
- });
265
- const read = typeof result?.text === "string" ? result.text.trim() : "";
307
+ const read = await understandAttachmentFile({
308
+ runtime: params.runtime,
309
+ cfg: params.cfg,
310
+ filePath: downloaded.path,
311
+ mimeType: downloaded.mimeType,
312
+ understanding: downloaded.understanding,
313
+ });
266
314
  if (!read) {
267
315
  params.log?.info?.("clawgram attachment read empty", {
268
316
  accountId: params.accountId,
@@ -360,6 +408,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
360
408
  "Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
361
409
  "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
410
  "Pass that `threadId` to `read` as well: without it a forum read returns every topic interleaved rather than the one that was asked about.",
411
+ "Use the `fetch-media` action (chatId + messageId) to fetch the attachment on a message `read` reported: `mode: \"read\"` returns a description of an image or a transcript of a voice note, `\"file\"` returns a path to reuse, `\"both\"` (default) returns both. `read` only says an attachment exists; this is what brings it.",
363
412
  "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`.",
364
413
  "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.",
365
414
  ],
@@ -370,6 +419,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
370
419
  "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.",
371
420
  "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
421
  "clawgram can list the topics of a forum supergroup via `topics`: id, title, last message, and whether a topic is closed, hidden or pinned.",
422
+ "clawgram can fetch the attachment on any message inside its read scope via `fetch-media`: images come back described, voice notes transcribed, and either can be returned as a file path for reuse.",
373
423
  "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\".",
374
424
  "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.",
375
425
  ],
@@ -696,13 +746,19 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
696
746
  // channelRuntime comes from the untyped ctx, so the generic route type falls
697
747
  // back to the minimal RouteLike. The runtime value is a ResolvedAgentRoute.
698
748
  const route = inboundRoute;
699
- const wasMentioned = (0, helpers_1.hasTelegramMention)({
700
- cfg,
701
- agentId: route.agentId,
702
- selfUsername,
703
- text,
704
- message: rawMessage,
705
- });
749
+ // Under `tag` the name is not an address: in a chat of a thousand
750
+ // people it occurs in conversation constantly and is aimed at her
751
+ // almost never. Only the `@` counts, and it is the same fact the
752
+ // stricter rung of the ladder is named after.
753
+ const wasMentioned = groupConfig.groupPolicy === "tag"
754
+ ? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text, message: rawMessage })
755
+ : (0, helpers_1.hasTelegramMention)({
756
+ cfg,
757
+ agentId: route.agentId,
758
+ selfUsername,
759
+ text,
760
+ message: rawMessage,
761
+ });
706
762
  const wasReplyToSelf = await (0, helpers_1.isReplyToSelfMessage)(rawMessage, selfId);
707
763
  const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
708
764
  facts: {
@@ -712,7 +768,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
712
768
  },
713
769
  policy: {
714
770
  isGroup: true,
715
- requireMention: groupConfig.groupPolicy === "mention",
771
+ requireMention: groupConfig.groupPolicy !== "open",
716
772
  allowTextCommands: false,
717
773
  hasControlCommand: false,
718
774
  commandAuthorized: true,
@@ -723,6 +779,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
723
779
  chatId: normalized.chatId,
724
780
  messageId: normalized.messageId,
725
781
  selfUsername,
782
+ groupPolicy: groupConfig.groupPolicy,
726
783
  mentionedFlag: rawMessage?.mentioned === true,
727
784
  hasEntities: Array.isArray(rawMessage?.entities) ? rawMessage.entities.length : 0,
728
785
  wasMentioned,
@@ -730,7 +787,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
730
787
  shouldSkip: mentionDecision.shouldSkip,
731
788
  text,
732
789
  });
733
- if (groupConfig.groupPolicy === "mention" && mentionDecision.shouldSkip && !wasReplyToSelf) {
790
+ if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
734
791
  log?.info?.("clawgram skipping group message without mention", {
735
792
  accountId,
736
793
  chatId: normalized.chatId,
@@ -993,6 +1050,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
993
1050
  }, {
994
1051
  readMessageId: Number(normalized.messageId),
995
1052
  messageThreadId,
1053
+ // The indicator is a promise of an answer, and it is owed only
1054
+ // to someone who addressed her. Under `open` the turn runs on
1055
+ // every message in the chat, so without this the whole room
1056
+ // watches her "type" through conversations she is only reading.
1057
+ typing: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
996
1058
  });
997
1059
  log?.info?.("clawgram group inbound handled", {
998
1060
  accountId,
@@ -1327,6 +1389,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1327
1389
  // file stays on disk. That is exactly what happened on 2026-08-07.
1328
1390
  actions: [
1329
1391
  "send", "read", "participants", "joins", "react", "chatInfo", "topics", "dialogs", "upload-file",
1392
+ // Reading an attachment that is already in a chat. `read` reports
1393
+ // that a photo exists; this is what turns it into something the
1394
+ // agent can look at or pass on.
1395
+ "fetch-media",
1330
1396
  // Chat management (2.12.0) — gated by the account's manageChats
1331
1397
  // scope; without it every one of these is refused.
1332
1398
  "createGroup", "addMembers", "removeMember",
@@ -1387,6 +1453,166 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1387
1453
  messages: history.messages,
1388
1454
  });
1389
1455
  }
1456
+ // The attachment on a message that is already in a chat.
1457
+ //
1458
+ // `read` says a photo exists; it does not fetch it, and the inbound
1459
+ // path only ever reads what arrives while the agent is being addressed.
1460
+ // Everything else — a screenshot posted an hour ago, a diagram in a
1461
+ // chat the agent reads but was not tagged in — was visible to the
1462
+ // channel and unreachable to the agent. Same `readChats` scope as
1463
+ // history: this must not become a way to pull bytes out of a chat the
1464
+ // account was never allowed to read.
1465
+ if (action === "fetch-media" || action === "fetchMedia" ||
1466
+ action === "download-media" || action === "downloadMedia" ||
1467
+ action === "getMedia") {
1468
+ const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
1469
+ const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
1470
+ if (!fetchAccountId) {
1471
+ throw new Error("clawgram: no configured account found");
1472
+ }
1473
+ if (!(0, history_1.isChatReadable)(fetchParams.target, resolveAccountReadChats(cfg, fetchAccountId))) {
1474
+ actionLog.warn("clawgram fetch-media refused: chat outside read scope", {
1475
+ accountId: fetchAccountId,
1476
+ target: fetchParams.target,
1477
+ });
1478
+ throw new Error(`clawgram: not-allowed-chat ${fetchParams.target}`);
1479
+ }
1480
+ const fetchGram = runtimes.get(fetchAccountId);
1481
+ if (!fetchGram) {
1482
+ throw new Error(`clawgram: runtime not found for account ${fetchAccountId}`);
1483
+ }
1484
+ // Fetching is a read: a dry run answers for real, the same way `read`
1485
+ // does. Nothing leaves the machine — the file lands in a temp
1486
+ // directory this channel prunes — so a rehearsal that reported
1487
+ // "would fetch" would only teach the agent to ask twice.
1488
+ const found = await fetchGram.getMessageById(fetchParams.target, fetchParams.messageId);
1489
+ const fetchChatId = found.chatId ?? fetchParams.target;
1490
+ if (!found.message) {
1491
+ actionLog.info("clawgram fetch-media found no message", {
1492
+ accountId: fetchAccountId,
1493
+ chatId: fetchChatId,
1494
+ messageId: fetchParams.messageId,
1495
+ });
1496
+ return (0, core_1.jsonResult)({
1497
+ ok: false,
1498
+ accountId: fetchAccountId,
1499
+ chatId: fetchChatId,
1500
+ messageId: String(fetchParams.messageId),
1501
+ error: "message-not-found",
1502
+ });
1503
+ }
1504
+ // `read` throws the file away, so it gets a directory of its own —
1505
+ // the shared directory is keyed by chat and message, and deleting
1506
+ // that path would pull the file out from under an earlier `both`
1507
+ // fetch of the same message that handed the caller a path.
1508
+ const sharedFetchDir = node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-fetched");
1509
+ let fetchDir = sharedFetchDir;
1510
+ if (fetchParams.mode === "read") {
1511
+ const { mkdtemp } = await import("node:fs/promises");
1512
+ fetchDir = await mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-media-"));
1513
+ }
1514
+ else {
1515
+ await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
1516
+ }
1517
+ const downloaded = await (0, media_1.downloadMessageMediaToFile)({
1518
+ client: fetchGram.getClient(),
1519
+ message: found.message,
1520
+ maxBytes: INBOUND_MEDIA_MAX_BYTES,
1521
+ dir: fetchDir,
1522
+ fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
1523
+ chatId: fetchChatId,
1524
+ messageId: fetchParams.messageId,
1525
+ extension,
1526
+ fileName: media.fileName,
1527
+ }),
1528
+ });
1529
+ if (!downloaded) {
1530
+ // Three different nothings, and the agent has to be able to tell
1531
+ // them apart: a message with no attachment, an attachment this
1532
+ // channel does not read (a video, a spreadsheet), and one too
1533
+ // large to be worth the transfer. Saying "could not fetch" to all
1534
+ // three is how "she ignored the picture" starts.
1535
+ const described = (0, media_1.describeMedia)(found.message?.media);
1536
+ const tooLarge = typeof described?.size === "number" && described.size > INBOUND_MEDIA_MAX_BYTES;
1537
+ const error = !described
1538
+ ? "no-media"
1539
+ : tooLarge
1540
+ ? "media-too-large"
1541
+ : "unsupported-media";
1542
+ actionLog.info("clawgram fetch-media returned nothing", {
1543
+ accountId: fetchAccountId,
1544
+ chatId: fetchChatId,
1545
+ messageId: fetchParams.messageId,
1546
+ kind: described?.kind ?? null,
1547
+ error,
1548
+ });
1549
+ return (0, core_1.jsonResult)({
1550
+ ok: false,
1551
+ accountId: fetchAccountId,
1552
+ chatId: fetchChatId,
1553
+ messageId: String(fetchParams.messageId),
1554
+ media: described ?? null,
1555
+ error,
1556
+ });
1557
+ }
1558
+ let read;
1559
+ let readError;
1560
+ if (fetchParams.mode !== "file") {
1561
+ try {
1562
+ read = await understandAttachmentFile({
1563
+ runtime: pluginRuntime,
1564
+ cfg,
1565
+ filePath: downloaded.path,
1566
+ mimeType: downloaded.mimeType,
1567
+ understanding: downloaded.understanding,
1568
+ });
1569
+ if (!read) {
1570
+ readError = "read-empty";
1571
+ }
1572
+ }
1573
+ catch (err) {
1574
+ // The bytes are already here. A failed reading is worth
1575
+ // reporting, but it does not undo a successful fetch: the file
1576
+ // still exists and can still be forwarded.
1577
+ readError = String(err);
1578
+ }
1579
+ }
1580
+ // `read` mode is the inbound contract — the words, not the file — so
1581
+ // the bytes go away with the answer. Any other mode keeps them:
1582
+ // that is the whole point of asking for a path.
1583
+ if (fetchParams.mode === "read") {
1584
+ try {
1585
+ const { rm } = await import("node:fs/promises");
1586
+ await rm(fetchDir, { recursive: true, force: true });
1587
+ }
1588
+ catch {
1589
+ // A file left behind is pruned within a day; failing the call
1590
+ // over it would throw away a reading that already succeeded.
1591
+ }
1592
+ }
1593
+ actionLog.info("clawgram fetch-media completed", {
1594
+ accountId: fetchAccountId,
1595
+ chatId: fetchChatId,
1596
+ messageId: fetchParams.messageId,
1597
+ mode: fetchParams.mode,
1598
+ kind: downloaded.media.kind,
1599
+ understanding: downloaded.understanding,
1600
+ characters: read?.length ?? 0,
1601
+ readError: readError ?? null,
1602
+ });
1603
+ return (0, core_1.jsonResult)({
1604
+ ok: true,
1605
+ accountId: fetchAccountId,
1606
+ chatId: fetchChatId,
1607
+ messageId: String(fetchParams.messageId),
1608
+ mode: fetchParams.mode,
1609
+ media: downloaded.media,
1610
+ understanding: downloaded.understanding,
1611
+ filePath: fetchParams.mode === "read" ? undefined : downloaded.path,
1612
+ text: read,
1613
+ readError,
1614
+ });
1615
+ }
1390
1616
  // Membership is a read, so the same `readChats` scope that gates history
1391
1617
  // gates it too: this cannot become a way to enumerate chats the account
1392
1618
  // was never allowed to read.
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * Fetching an attachment that is already sitting in a chat.
4
+ *
5
+ * Inbound attachments are read as they arrive: a photo sent to the agent
6
+ * becomes `[изображение] …` in the message body and the bytes are dropped.
7
+ * That covers being shown something, and nothing else. It does not cover
8
+ * "посмотри картинку, которую Женя кидал вчера" — history reads carry
9
+ * metadata only (`media.ts`), so a screenshot posted before the agent was
10
+ * addressed exists to it as the word "photo" and no more. It also does not
11
+ * cover reuse: the file the agent read is deleted the moment the read ends,
12
+ * so an image cannot be forwarded, attached to a ticket, or looked at twice.
13
+ *
14
+ * This module is the pure half of the `fetch-media` action: parameter
15
+ * parsing and file naming, testable without a Telegram client. The transport
16
+ * lives in `GramJsClientManager.getMessageById`, the dispatch in `channel.ts`.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.parseFetchMediaMode = parseFetchMediaMode;
20
+ exports.parseFetchMediaParams = parseFetchMediaParams;
21
+ exports.sanitizeFileName = sanitizeFileName;
22
+ exports.fetchedMediaFileName = fetchedMediaFileName;
23
+ const history_1 = require("./history");
24
+ /**
25
+ * A caller that guessed a neighbouring word is not refused: these are all
26
+ * unambiguous, and an error over vocabulary costs a turn to say nothing.
27
+ */
28
+ const MODE_ALIASES = {
29
+ read: "read",
30
+ describe: "read",
31
+ description: "read",
32
+ transcript: "read",
33
+ transcribe: "read",
34
+ text: "read",
35
+ file: "file",
36
+ download: "file",
37
+ path: "file",
38
+ bytes: "file",
39
+ both: "both",
40
+ all: "both",
41
+ };
42
+ function parseFetchMediaMode(value) {
43
+ if (value === undefined || value === null || value === "")
44
+ return "both";
45
+ if (typeof value !== "string") {
46
+ throw new Error("clawgram: mode must be one of read, file, both");
47
+ }
48
+ const mode = MODE_ALIASES[value.trim().toLowerCase()];
49
+ if (!mode) {
50
+ throw new Error(`clawgram: unknown mode ${value} — expected read, file or both`);
51
+ }
52
+ return mode;
53
+ }
54
+ function parseFetchMediaParams(params) {
55
+ const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat;
56
+ const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
57
+ if (!target) {
58
+ throw new Error("clawgram: fetch-media requires a chatId");
59
+ }
60
+ const rawMessageId = params.messageId ?? params.id ?? params.message ?? params.msgId;
61
+ const messageId = (0, history_1.parseMessageId)(rawMessageId, "messageId");
62
+ if (messageId === undefined) {
63
+ throw new Error("clawgram: fetch-media requires a messageId");
64
+ }
65
+ return { target, messageId, mode: parseFetchMediaMode(params.mode) };
66
+ }
67
+ /**
68
+ * A file name that survives a round trip through a shell, a log line and a
69
+ * second tool: Telegram file names carry spaces, Cyrillic, quotes and the
70
+ * occasional path separator, and the last of those is the one that matters —
71
+ * `../../x.jpg` as a name must not decide where the file lands.
72
+ */
73
+ function sanitizeFileName(name) {
74
+ if (typeof name !== "string")
75
+ return undefined;
76
+ const flattened = name.replace(/[/\\]/g, "_").replace(/\s+/g, "_").trim();
77
+ const cleaned = flattened
78
+ .replace(/[^\p{L}\p{N}._-]/gu, "")
79
+ // A run of dots survives the separator strip as `..`, which is harmless in
80
+ // a basename but reads like a traversal in every log it lands in.
81
+ .replace(/\.{2,}/g, ".")
82
+ .replace(/^[._-]+/, "");
83
+ return cleaned.slice(0, 80) || undefined;
84
+ }
85
+ /**
86
+ * Deterministic on purpose: fetching the same message twice writes the same
87
+ * path instead of scattering copies of one screenshot across the temp
88
+ * directory. The chat and message ids are in the name so two fetches in
89
+ * flight at once cannot land on each other.
90
+ */
91
+ function fetchedMediaFileName(params) {
92
+ const own = sanitizeFileName(params.fileName);
93
+ const chat = params.chatId.replace(/[^0-9a-zA-Z_-]/g, "");
94
+ const stem = `${chat || "chat"}-${params.messageId}`;
95
+ return own ? `${stem}-${own}` : `${stem}.${params.extension}`;
96
+ }
@@ -495,6 +495,23 @@ class GramJsClientManager {
495
495
  truncated: raw.length >= args.limit,
496
496
  };
497
497
  }
498
+ /**
499
+ * One message by id, for the sake of the attachment on it.
500
+ *
501
+ * `listMessages` reads a window and reports metadata; this reads a single
502
+ * message and hands the raw GramJS object back, because `downloadMedia`
503
+ * needs the message itself, not a summary of it. Telegram answers a missing
504
+ * or deleted id with a hole in the array rather than an error, so the caller
505
+ * gets `undefined` and says "no such message" instead of throwing something
506
+ * that reads like a transport failure.
507
+ */
508
+ async getMessageById(target, messageId) {
509
+ const resolved = await this.resolvePeer(target);
510
+ const fetched = await this.client.getMessages(resolved.peer, { ids: [messageId] });
511
+ const raw = Array.isArray(fetched) ? fetched : [];
512
+ const message = raw.find((entry) => entry && entry.className !== "MessageEmpty");
513
+ return { chatId: resolved.chatId, message };
514
+ }
498
515
  /**
499
516
  * Chat membership, ids only. The caller needs to answer "do we share a group
500
517
  * with this person" — an id answers that and a full profile does not, so
@@ -565,7 +582,29 @@ class GramJsClientManager {
565
582
  }
566
583
  await this.client.markAsRead(resolved.peer, messageId).catch(() => undefined);
567
584
  }
585
+ /**
586
+ * Runs `fn` while the chat shows "typing", and marks the message read.
587
+ *
588
+ * `typing: false` keeps the read receipt and drops the indicator. The two
589
+ * are separable because they promise different things: reading is what the
590
+ * agent did, typing is a promise that words are coming. Under
591
+ * `groupPolicy: "open"` every message starts a turn, and most of those turns
592
+ * end in silence — on 2026-08-17 the management chat watched «Тина
593
+ * печатает…» for 20–26 seconds on each of four messages that were never
594
+ * addressed to her, and nothing followed. An indicator that is not owed to
595
+ * anyone is worse than no indicator.
596
+ */
568
597
  async withTyping(target, fn, options) {
598
+ if (options?.typing === false) {
599
+ // Still a read receipt: she did read it, and the chat may show that.
600
+ const resolvedPeer = await this.resolvePeer(target).then((r) => r.peer).catch(() => undefined);
601
+ if (resolvedPeer) {
602
+ await this.markRead(resolvedPeer, options?.readMessageId, {
603
+ messageThreadId: options?.messageThreadId,
604
+ }).catch(() => undefined);
605
+ }
606
+ return await fn();
607
+ }
569
608
  let peer;
570
609
  let readMarked = false;
571
610
  let stopped = false;
package/dist/helpers.js CHANGED
@@ -27,6 +27,7 @@ exports.resolveActiveUsername = resolveActiveUsername;
27
27
  exports.normalizeAllowEntry = normalizeAllowEntry;
28
28
  exports.isSenderAllowed = isSenderAllowed;
29
29
  exports.hasTelegramMention = hasTelegramMention;
30
+ exports.hasExplicitTelegramMention = hasExplicitTelegramMention;
30
31
  exports.toDisplayName = toDisplayName;
31
32
  exports.withTimeout = withTimeout;
32
33
  exports.stripSilentReplyToken = stripSilentReplyToken;
@@ -271,8 +272,17 @@ function resolveAllowFrom(value) {
271
272
  const entries = value.map((entry) => String(entry).trim()).filter(Boolean);
272
273
  return entries.length > 0 ? entries : ["*"];
273
274
  }
275
+ /**
276
+ * Three rungs, widest first: `open` wakes on every message, `mention` on the
277
+ * name or an `@`, `tag` on the `@` alone. Anything unrecognised lands on
278
+ * `mention`, the rung this channel has always defaulted to.
279
+ */
274
280
  function resolveGroupPolicy(value) {
275
- return value === "open" ? "open" : "mention";
281
+ if (value === "open")
282
+ return "open";
283
+ if (value === "tag")
284
+ return "tag";
285
+ return "mention";
276
286
  }
277
287
  /**
278
288
  * Per-group `skills` → core `replyOptions.skillFilter`, `systemPrompt` →
@@ -342,19 +352,27 @@ function isSenderAllowed(input) {
342
352
  ].filter((value) => Boolean(value)).map(normalizeAllowEntry);
343
353
  return input.allowFrom.map(normalizeAllowEntry).some((entry) => senderIds.includes(entry));
344
354
  }
345
- function hasTelegramMention(input) {
355
+ /**
356
+ * Whether the message tags this account with `@username` — nothing else.
357
+ *
358
+ * The name the agent answers to (`Тина`, and whatever else core's mention
359
+ * regexes carry) is deliberately NOT consulted. In a thousand-person chat the
360
+ * name occurs in conversation constantly and almost never as an address; the
361
+ * `@` is the one form that is unambiguously aimed at her. This is the whole of
362
+ * `groupPolicy: "tag"`.
363
+ *
364
+ * `message.mentioned` is Telegram's own flag and stays in: the client sets it
365
+ * for an @-mention and for a reply to her, and both are addresses.
366
+ */
367
+ function hasExplicitTelegramMention(input) {
346
368
  const normalizedText = input.text.trim();
347
369
  const message = input.message;
348
- const mentionRegexes = (0, channel_inbound_1.buildMentionRegexes)(input.cfg, input.agentId);
349
370
  const selfUsername = input.selfUsername?.replace(/^@/, "").trim();
371
+ if (!selfUsername) {
372
+ return false;
373
+ }
350
374
  const entities = Array.isArray(message?.entities) ? message.entities : [];
351
- const hasAnyMention = Boolean(message?.mentioned) ||
352
- entities.some((entity) => {
353
- const kind = typeof entity?.className === "string" ? entity.className : entity?.type;
354
- return kind === "MessageEntityMention" || kind === "mention" || kind === "MessageEntityMentionName" || kind === "InputMessageEntityMentionName";
355
- }) ||
356
- /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(normalizedText);
357
- const entityExplicitMention = Boolean(selfUsername) && entities.some((entity) => {
375
+ const entityExplicitMention = entities.some((entity) => {
358
376
  const kind = typeof entity?.className === "string" ? entity.className : entity?.type;
359
377
  if (kind !== "MessageEntityMention" && kind !== "mention") {
360
378
  return false;
@@ -366,10 +384,27 @@ function hasTelegramMention(input) {
366
384
  }
367
385
  return normalizedText.slice(offset, offset + length).replace(/^@/, "").trim().toLowerCase() === selfUsername.toLowerCase();
368
386
  });
369
- const explicitlyMentioned = Boolean(selfUsername) &&
370
- (message?.mentioned === true ||
371
- entityExplicitMention ||
372
- new RegExp(`(^|\\s)@${selfUsername?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(normalizedText));
387
+ return message?.mentioned === true ||
388
+ entityExplicitMention ||
389
+ new RegExp(`(^|\\s)@${selfUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(normalizedText);
390
+ }
391
+ function hasTelegramMention(input) {
392
+ const normalizedText = input.text.trim();
393
+ const message = input.message;
394
+ const mentionRegexes = (0, channel_inbound_1.buildMentionRegexes)(input.cfg, input.agentId);
395
+ const selfUsername = input.selfUsername?.replace(/^@/, "").trim();
396
+ const entities = Array.isArray(message?.entities) ? message.entities : [];
397
+ const hasAnyMention = Boolean(message?.mentioned) ||
398
+ entities.some((entity) => {
399
+ const kind = typeof entity?.className === "string" ? entity.className : entity?.type;
400
+ return kind === "MessageEntityMention" || kind === "mention" || kind === "MessageEntityMentionName" || kind === "InputMessageEntityMentionName";
401
+ }) ||
402
+ /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(normalizedText);
403
+ const explicitlyMentioned = hasExplicitTelegramMention({
404
+ selfUsername: input.selfUsername,
405
+ text: input.text,
406
+ message,
407
+ });
373
408
  return (0, channel_inbound_1.matchesMentionWithExplicit)({
374
409
  text: normalizedText,
375
410
  mentionRegexes,
package/dist/media.js CHANGED
@@ -15,6 +15,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.describeMedia = describeMedia;
16
16
  exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
17
17
  exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
18
+ exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
19
+ exports.pruneFetchedMedia = pruneFetchedMedia;
18
20
  /**
19
21
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
20
22
  * the same shape that once made `senderId` silently undefined. Anything that
@@ -130,6 +132,37 @@ function inboundMediaUnderstanding(media) {
130
132
  * and is responsible for removing it.
131
133
  */
132
134
  async function downloadInboundMediaToTempFile(params) {
135
+ const { mkdtemp } = await import("node:fs/promises");
136
+ const { join } = await import("node:path");
137
+ const described = describeMedia(params.message?.media);
138
+ const understanding = inboundMediaUnderstanding(described);
139
+ if (!described || !understanding) {
140
+ return undefined;
141
+ }
142
+ // Both gates run before `mkdtemp`: a directory created for an attachment
143
+ // that is never fetched is litter nobody comes back to remove, and the
144
+ // caller only deletes what it was handed.
145
+ if (typeof described.size === "number" && described.size > params.maxBytes) {
146
+ return undefined;
147
+ }
148
+ const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
149
+ return downloadMessageMediaToFile({
150
+ client: params.client,
151
+ message: params.message,
152
+ maxBytes: params.maxBytes,
153
+ dir,
154
+ fileNameFor: ({ extension }) => `attachment.${extension}`,
155
+ });
156
+ }
157
+ /**
158
+ * Downloads an attachment into a directory the caller names and owns.
159
+ *
160
+ * Split out of the inbound path for `fetch-media`, where the file is the
161
+ * point: it has to outlive the read so the agent can forward it or attach it
162
+ * somewhere. The inbound path keeps deleting its temp directory — nothing
163
+ * about that changed.
164
+ */
165
+ async function downloadMessageMediaToFile(params) {
133
166
  const described = describeMedia(params.message?.media);
134
167
  const understanding = inboundMediaUnderstanding(described);
135
168
  if (!described || !understanding) {
@@ -137,7 +170,8 @@ async function downloadInboundMediaToTempFile(params) {
137
170
  }
138
171
  // A cap belongs here rather than in the caller: an oversized attachment
139
172
  // should be reported as such, not fetched and then discarded after the
140
- // transfer cost.
173
+ // transfer cost. Telegram reports no size for a compressed photo, so this
174
+ // guards documents in practice — which is where the large files are.
141
175
  if (typeof described.size === "number" && described.size > params.maxBytes) {
142
176
  return undefined;
143
177
  }
@@ -145,13 +179,49 @@ async function downloadInboundMediaToTempFile(params) {
145
179
  if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
146
180
  return undefined;
147
181
  }
148
- const extension = extensionFor(described, understanding);
149
- const { mkdtemp, writeFile } = await import("node:fs/promises");
182
+ const { mkdir, writeFile } = await import("node:fs/promises");
150
183
  const { join } = await import("node:path");
151
- const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
152
- const path = join(dir, `attachment.${extension}`);
184
+ await mkdir(params.dir, { recursive: true });
185
+ const extension = extensionFor(described, understanding);
186
+ const path = join(params.dir, params.fileNameFor({ media: described, extension }));
153
187
  await writeFile(path, buffer);
154
- return { path, mimeType: described.mimeType, understanding };
188
+ return { path, mimeType: described.mimeType, understanding, media: described };
189
+ }
190
+ /**
191
+ * Removes fetched files older than `maxAgeMs` from `dir`.
192
+ *
193
+ * `fetch-media` writes files that deliberately outlive the call, and nothing
194
+ * else would ever delete them: a chat full of screenshots would accumulate in
195
+ * the temp directory until the box was rebooted. Pruning on the way in keeps
196
+ * the sweep in the one place that knows the directory exists, and failure is
197
+ * ignored — a stale file is not a reason to fail a fetch the agent is waiting
198
+ * for.
199
+ */
200
+ async function pruneFetchedMedia(dir, maxAgeMs, now) {
201
+ const { readdir, stat, rm } = await import("node:fs/promises");
202
+ const { join } = await import("node:path");
203
+ let entries;
204
+ try {
205
+ entries = await readdir(dir);
206
+ }
207
+ catch {
208
+ return 0;
209
+ }
210
+ let removed = 0;
211
+ for (const entry of entries) {
212
+ const path = join(dir, entry);
213
+ try {
214
+ const info = await stat(path);
215
+ if (now - info.mtimeMs > maxAgeMs) {
216
+ await rm(path, { recursive: true, force: true });
217
+ removed += 1;
218
+ }
219
+ }
220
+ catch {
221
+ // A file that vanished between readdir and stat is already pruned.
222
+ }
223
+ }
224
+ return removed;
155
225
  }
156
226
  function extensionFor(media, understanding) {
157
227
  if (understanding === "description") {
@@ -151,6 +151,33 @@ function shouldReactToSilentTurn(params) {
151
151
  }
152
152
  /** The message text is truncated before it reaches the model; a mood needs no more. */
153
153
  const MAX_JUDGED_CHARS = 2000;
154
+ /**
155
+ * Asks for the emoji on the configured model, falling back to the default one.
156
+ *
157
+ * The fallback is not defensive habit. Core refuses a plugin's model override
158
+ * unless `plugins.entries.clawgram.llm.allowModelOverride` is set, and the
159
+ * refusal is a throw — which this feature swallows by design. Without the
160
+ * retry, pointing `reactionModel` at a cheap model in a config that never
161
+ * granted the permission would not make reactions cheaper, it would make them
162
+ * disappear, and the log would say nothing about why.
163
+ */
164
+ async function completeEmoji(params) {
165
+ const request = {
166
+ messages: [{ role: "user", content: params.content }],
167
+ systemPrompt: params.systemPrompt,
168
+ maxTokens: 8,
169
+ purpose: "clawgram: emoji reaction for a silent mention",
170
+ };
171
+ if (!params.model) {
172
+ return { answer: await params.deps.complete(request), fellBack: false };
173
+ }
174
+ try {
175
+ return { answer: await params.deps.complete({ ...request, model: params.model }), fellBack: false };
176
+ }
177
+ catch {
178
+ return { answer: await params.deps.complete(request), fellBack: true };
179
+ }
180
+ }
154
181
  /**
155
182
  * Leaves an emoji on a message the agent was named in but chose not to answer.
156
183
  *
@@ -187,11 +214,11 @@ async function reactToSilentMention(params) {
187
214
  params.deps.onDecision?.({ messageId, appetite, chose: "none", allowedCount: 0 });
188
215
  return undefined;
189
216
  }
190
- const answer = await params.deps.complete({
191
- messages: [{ role: "user", content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS) }],
217
+ const { answer, fellBack } = await completeEmoji({
218
+ deps: params.deps,
219
+ model: params.model,
192
220
  systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
193
- maxTokens: 8,
194
- purpose: "clawgram: emoji reaction for a silent mention",
221
+ content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS),
195
222
  });
196
223
  const emoji = parseEmojiChoice(answer?.text, allowed);
197
224
  params.deps.onDecision?.({
@@ -200,6 +227,8 @@ async function reactToSilentMention(params) {
200
227
  chose: emoji ? "emoji" : "none",
201
228
  emoji,
202
229
  allowedCount: allowed?.length,
230
+ ...params.model ? { model: params.model } : {},
231
+ ...fellBack ? { modelFellBack: true } : {},
203
232
  });
204
233
  if (!emoji) {
205
234
  return undefined;
@@ -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.17.1",
5
+ "version": "2.19.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
@@ -53,9 +53,11 @@
53
53
  "type": "string",
54
54
  "enum": [
55
55
  "open",
56
- "mention"
56
+ "mention",
57
+ "tag"
57
58
  ],
58
- "default": "mention"
59
+ "default": "mention",
60
+ "description": "What wakes the agent in this chat (2.18.0). `mention` \u2014 the name, an @mention or a reply to it; `open` \u2014 every message, the agent decides for itself whether words are owed; `tag` \u2014 only an explicit @username or a reply, for chats where the name occurs in conversation constantly."
59
61
  },
60
62
  "allowFrom": {
61
63
  "type": "array",
@@ -358,9 +360,11 @@
358
360
  "type": "string",
359
361
  "enum": [
360
362
  "open",
361
- "mention"
363
+ "mention",
364
+ "tag"
362
365
  ],
363
- "default": "mention"
366
+ "default": "mention",
367
+ "description": "What wakes the agent in this chat (2.18.0). `mention` \u2014 the name, an @mention or a reply to it; `open` \u2014 every message; `tag` \u2014 only an explicit @username or a reply."
364
368
  },
365
369
  "allowFrom": {
366
370
  "type": "array",
@@ -507,6 +511,10 @@
507
511
  ],
508
512
  "description": "How freely the agent may leave emoji reactions (2.8.0). Core injects its `## Reactions` prompt section only for `minimal` and `extensive`; `off` and `ack` leave the prompt silent about reactions, and the agent then effectively never reacts on its own. Absent means `minimal`. Mirrors the bundled Telegram channel's reactionLevel."
509
513
  },
514
+ "reactionModel": {
515
+ "type": "string",
516
+ "description": "Model ref or alias for the emoji pick on a silent mention (2.18.0). Absent = the agent's own model, which is what every version before this used. Requires `plugins.entries.clawgram.llm.allowModelOverride: true` in the gateway config; without it core refuses the override and the pick falls back to the default model rather than losing the reaction."
517
+ },
510
518
  "replyParseMode": {
511
519
  "type": "string",
512
520
  "enum": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.17.1",
3
+ "version": "2.19.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": {