clawgram 2.17.0 → 2.18.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
@@ -236,19 +236,42 @@ reconnect), not the whole Gateway.
236
236
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
237
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) |
238
238
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
239
+ | `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
240
 
240
241
  Group config fields:
241
242
 
242
243
  | Field | Type | Default | Description |
243
244
  |---|---|---|---|
244
245
  | `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 |
246
+ | `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
247
  | `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group |
247
248
  | `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
249
  | `toolsBySender` | object | unset | Per-sender tool policy inside this group, keys `id:<id>`, `username:<handle>`, `name:<display>` or `*` |
249
250
  | `skills` | string[] | unset | Skill allowlist for this group; `[]` = no skills here, unset = the agent's skills |
250
251
  | `systemPrompt` | string | unset | Trusted prompt block appended for messages from this group |
251
252
 
253
+ ### What wakes the agent in a group
254
+
255
+ `groupPolicy` decides which messages start a turn at all. It is a ladder, widest
256
+ first, and the rung is chosen per group:
257
+
258
+ | Rung | Wakes on | Use it when |
259
+ |---|---|---|
260
+ | `open` | every message in the chat | the agent works as a member of the team and an address may carry no name at all |
261
+ | `mention` | the name it answers to, an `@username`, or a reply to it | the default: the agent is a participant, not a fixture |
262
+ | `tag` | an `@username` or a reply to it — **never the name** | the name occurs in conversation constantly, as it does in a large community |
263
+
264
+ Two things are worth knowing before reaching for `open`:
265
+
266
+ - it spends a **full turn on every message**, chatter included. Whether words
267
+ are owed is then the agent's decision, and most of the time the answer is no;
268
+ - the typing indicator is shown only for messages that actually addressed the
269
+ agent. Under `open` the room would otherwise watch it "type" through
270
+ conversations it is merely reading, with nothing following.
271
+
272
+ Emoji reactions are unaffected by the rung: the channel leaves one only where
273
+ the agent was genuinely addressed, so background reading stays unmarked.
274
+
252
275
  ### Per-group tools, skills and system prompt
253
276
 
254
277
  Since 2.17.0 a group entry can narrow what the assistant does *in that chat*
package/dist/channel.js CHANGED
@@ -80,6 +80,22 @@ function readAccountReactionLevel(cfg, accountId) {
80
80
  }
81
81
  return cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
82
82
  }
83
+ /**
84
+ * Model ref for the emoji pick, when the account names one.
85
+ *
86
+ * Picking one emoji out of a fixed list of 68 is the cheapest judgement this
87
+ * channel makes and the only model call it makes on its own; running it on the
88
+ * agent's own head spends the expensive quota on a decision a small model
89
+ * makes just as well.
90
+ */
91
+ function readAccountReactionModel(cfg, accountId) {
92
+ const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
93
+ if (!resolvedAccountId) {
94
+ return undefined;
95
+ }
96
+ const raw = cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionModel;
97
+ return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
98
+ }
83
99
  /**
84
100
  * Wires `reactToSilentMention` to this account's runtime, config and log.
85
101
  *
@@ -96,6 +112,7 @@ async function reactToSilentMentionForAccount(params) {
96
112
  }
97
113
  await (0, silent_reaction_1.reactToSilentMention)({
98
114
  appetite: (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(params.cfg, params.accountId)),
115
+ model: readAccountReactionModel(params.cfg, params.accountId),
99
116
  wasMentioned: params.wasMentioned,
100
117
  chatId: params.chatId,
101
118
  messageId: params.messageId,
@@ -696,13 +713,19 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
696
713
  // channelRuntime comes from the untyped ctx, so the generic route type falls
697
714
  // back to the minimal RouteLike. The runtime value is a ResolvedAgentRoute.
698
715
  const route = inboundRoute;
699
- const wasMentioned = (0, helpers_1.hasTelegramMention)({
700
- cfg,
701
- agentId: route.agentId,
702
- selfUsername,
703
- text,
704
- message: rawMessage,
705
- });
716
+ // Under `tag` the name is not an address: in a chat of a thousand
717
+ // people it occurs in conversation constantly and is aimed at her
718
+ // almost never. Only the `@` counts, and it is the same fact the
719
+ // stricter rung of the ladder is named after.
720
+ const wasMentioned = groupConfig.groupPolicy === "tag"
721
+ ? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text, message: rawMessage })
722
+ : (0, helpers_1.hasTelegramMention)({
723
+ cfg,
724
+ agentId: route.agentId,
725
+ selfUsername,
726
+ text,
727
+ message: rawMessage,
728
+ });
706
729
  const wasReplyToSelf = await (0, helpers_1.isReplyToSelfMessage)(rawMessage, selfId);
707
730
  const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
708
731
  facts: {
@@ -712,7 +735,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
712
735
  },
713
736
  policy: {
714
737
  isGroup: true,
715
- requireMention: groupConfig.groupPolicy === "mention",
738
+ requireMention: groupConfig.groupPolicy !== "open",
716
739
  allowTextCommands: false,
717
740
  hasControlCommand: false,
718
741
  commandAuthorized: true,
@@ -723,6 +746,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
723
746
  chatId: normalized.chatId,
724
747
  messageId: normalized.messageId,
725
748
  selfUsername,
749
+ groupPolicy: groupConfig.groupPolicy,
726
750
  mentionedFlag: rawMessage?.mentioned === true,
727
751
  hasEntities: Array.isArray(rawMessage?.entities) ? rawMessage.entities.length : 0,
728
752
  wasMentioned,
@@ -730,7 +754,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
730
754
  shouldSkip: mentionDecision.shouldSkip,
731
755
  text,
732
756
  });
733
- if (groupConfig.groupPolicy === "mention" && mentionDecision.shouldSkip && !wasReplyToSelf) {
757
+ if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
734
758
  log?.info?.("clawgram skipping group message without mention", {
735
759
  accountId,
736
760
  chatId: normalized.chatId,
@@ -993,6 +1017,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
993
1017
  }, {
994
1018
  readMessageId: Number(normalized.messageId),
995
1019
  messageThreadId,
1020
+ // The indicator is a promise of an answer, and it is owed only
1021
+ // to someone who addressed her. Under `open` the turn runs on
1022
+ // every message in the chat, so without this the whole room
1023
+ // watches her "type" through conversations she is only reading.
1024
+ typing: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
996
1025
  });
997
1026
  log?.info?.("clawgram group inbound handled", {
998
1027
  accountId,
@@ -238,10 +238,25 @@ class GramJsClientManager {
238
238
  }
239
239
  this.started = true;
240
240
  }
241
+ /**
242
+ * Tears the client down for good.
243
+ *
244
+ * `destroy()`, not `disconnect()`. GramJS runs its update loop as
245
+ * `while (!client._destroyed)` (telegram/client/updates.js), and only
246
+ * `destroy()` sets that flag — `disconnect()` drops the connection and
247
+ * leaves the loop spinning, retrying and logging `Error: TIMEOUT` forever.
248
+ * The manager is thrown away on stop, so losing the event handlers that
249
+ * `destroy()` clears is exactly what we want.
250
+ *
251
+ * This leak was invisible while a config write restarted the whole Gateway.
252
+ * 2.17.0 made channel restarts routine, and the rate then grew by one loop
253
+ * per restart — measured on the owner's server 2026-08-15: ~3 timeouts/min
254
+ * before a restart, ~4.5/min after one.
255
+ */
241
256
  async stop() {
242
257
  if (!this.started)
243
258
  return;
244
- await this.client.disconnect();
259
+ await this.client.destroy();
245
260
  this.started = false;
246
261
  }
247
262
  getClient() {
@@ -550,7 +565,29 @@ class GramJsClientManager {
550
565
  }
551
566
  await this.client.markAsRead(resolved.peer, messageId).catch(() => undefined);
552
567
  }
568
+ /**
569
+ * Runs `fn` while the chat shows "typing", and marks the message read.
570
+ *
571
+ * `typing: false` keeps the read receipt and drops the indicator. The two
572
+ * are separable because they promise different things: reading is what the
573
+ * agent did, typing is a promise that words are coming. Under
574
+ * `groupPolicy: "open"` every message starts a turn, and most of those turns
575
+ * end in silence — on 2026-08-17 the management chat watched «Тина
576
+ * печатает…» for 20–26 seconds on each of four messages that were never
577
+ * addressed to her, and nothing followed. An indicator that is not owed to
578
+ * anyone is worse than no indicator.
579
+ */
553
580
  async withTyping(target, fn, options) {
581
+ if (options?.typing === false) {
582
+ // Still a read receipt: she did read it, and the chat may show that.
583
+ const resolvedPeer = await this.resolvePeer(target).then((r) => r.peer).catch(() => undefined);
584
+ if (resolvedPeer) {
585
+ await this.markRead(resolvedPeer, options?.readMessageId, {
586
+ messageThreadId: options?.messageThreadId,
587
+ }).catch(() => undefined);
588
+ }
589
+ return await fn();
590
+ }
554
591
  let peer;
555
592
  let readMarked = false;
556
593
  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,
@@ -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.0",
5
+ "version": "2.18.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.0",
3
+ "version": "2.18.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": {