clawgram 2.10.0 โ†’ 2.11.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/dist/channel.js CHANGED
@@ -101,6 +101,9 @@ async function reactToSilentMentionForAccount(params) {
101
101
  // method that needs its receiver.
102
102
  complete: (args) => llm.complete(args),
103
103
  sendReaction: (args) => gram.sendReaction(args),
104
+ allowedReactions: gram.getAllowedReactions
105
+ ? () => gram.getAllowedReactions(params.chatId)
106
+ : undefined,
104
107
  onDecision: (info) => actionLog.info("clawgram silent-mention reaction", {
105
108
  accountId: params.accountId,
106
109
  ...info,
@@ -360,6 +360,46 @@ class GramJsClientManager {
360
360
  })().catch(() => undefined);
361
361
  return { entity, full };
362
362
  }
363
+ /**
364
+ * Which reactions a chat permits, or `undefined` when it permits all.
365
+ *
366
+ * Telegram models this three ways on the full chat: absent or
367
+ * `ChatReactionsAll` means everything, `ChatReactionsSome` carries the
368
+ * allowed list, and `ChatReactionsNone` means reactions are switched off โ€”
369
+ * reported here as an empty list, which callers must read as "react with
370
+ * nothing", not as "no restriction".
371
+ *
372
+ * Custom emoji entries are dropped: they need a Premium account to send.
373
+ */
374
+ async getAllowedReactions(target) {
375
+ const resolved = await this.resolvePeer(target);
376
+ const entity = await this.client.getEntity(resolved.peer);
377
+ const full = await (async () => {
378
+ switch (entity?.className) {
379
+ case "Channel":
380
+ return (await this.client.invoke(new telegram_1.Api.channels.GetFullChannel({
381
+ channel: entity,
382
+ }))).fullChat;
383
+ case "Chat":
384
+ return (await this.client.invoke(new telegram_1.Api.messages.GetFullChat({
385
+ chatId: entity.id,
386
+ }))).fullChat;
387
+ default:
388
+ return undefined;
389
+ }
390
+ })();
391
+ const available = full?.availableReactions;
392
+ switch (available?.className) {
393
+ case "ChatReactionsNone":
394
+ return [];
395
+ case "ChatReactionsSome":
396
+ return (available.reactions ?? [])
397
+ .map((reaction) => reaction?.emoticon)
398
+ .filter((emoticon) => typeof emoticon === "string");
399
+ default:
400
+ return undefined;
401
+ }
402
+ }
363
403
  /**
364
404
  * Adds or clears this account's reaction on a message.
365
405
  *
@@ -20,17 +20,32 @@
20
20
  * tested without Telegram or a model.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.TELEGRAM_REACTIONS = void 0;
23
24
  exports.buildEmojiSystemPrompt = buildEmojiSystemPrompt;
25
+ exports.canonicalizeReactionEmoji = canonicalizeReactionEmoji;
24
26
  exports.parseEmojiChoice = parseEmojiChoice;
25
27
  exports.shouldReactToSilentTurn = shouldReactToSilentTurn;
26
28
  exports.reactToSilentMention = reactToSilentMention;
27
- function buildEmojiSystemPrompt(appetite) {
29
+ function buildEmojiSystemPrompt(appetite, allowed) {
30
+ const choices = allowed === undefined || allowed.length === 0 ? exports.TELEGRAM_REACTIONS : allowed;
28
31
  const shared = [
29
32
  "You pick a single emoji reaction for a chat message.",
30
33
  "The assistant was mentioned in this message but decided it needs no written reply.",
31
34
  "Answer with exactly one emoji and nothing else, or the word NONE if no reaction fits.",
32
- "Match the mood of the message: a joke gets something amused, praise something warm,",
33
- "bad news something sympathetic, an achievement something celebratory.",
35
+ // The set is not decoration: Telegram refuses anything outside it, and an
36
+ // answer outside it is discarded, so offering the choices up front is the
37
+ // difference between a reaction and silence.
38
+ `Choose ONLY from this set, copied exactly: ${choices.join(" ")}`,
39
+ // Fixed answers the owner asked for by name. They come before the mood
40
+ // rule because the model's own instinct here was wrong in a specific way:
41
+ // it answered ๐Ÿ‘ to being praised, which reads as approving of the praise
42
+ // rather than being touched by it.
43
+ "Three situations have a fixed answer. Use it, and do not substitute a similar emoji:",
44
+ `- the assistant is praised or thanked, or someone speaks well of it โ†’ โค (never \u{1F44D} here)`,
45
+ `- the assistant is asked or told to do something โ†’ \u{1FAE1}, or \u{1F44C} for a small routine request`,
46
+ `- the message is about producing something written โ€” a text, a reply, a document, a draft โ†’ โœ`,
47
+ "Otherwise match the mood: a joke gets something amused, bad news something",
48
+ "sympathetic, an achievement something celebratory.",
34
49
  "Answer NONE when the message is conflictual, heavy, or discusses a person's",
35
50
  "performance โ€” a reaction there reads as a verdict on someone.",
36
51
  ];
@@ -45,14 +60,50 @@ function buildEmojiSystemPrompt(appetite) {
45
60
  ].join("\n");
46
61
  }
47
62
  /**
48
- * Turns a model answer into an emoji, or nothing.
63
+ * The emoji Telegram accepts as reactions, in the exact form it expects.
49
64
  *
50
- * Deliberately strict. A wrong emoji is a visible act on someone else's
51
- * message, and Telegram rejects emoji outside the chat's allowed set anyway โ€”
52
- * so anything that does not look like a bare emoji is treated as "no
53
- * reaction" rather than sent hopefully.
65
+ * Reactions are not "any emoji". Telegram keeps a fixed set, and several of
66
+ * its members carry **no** variation selector โ€” `โค` is U+2764 alone, and so
67
+ * are `โšก`, `โœ`, `๐Ÿ•Š`, `โ˜ƒ`. Sending the U+FE0F-decorated form of any of them
68
+ * fails, which is exactly what happened on the first live attempt:
69
+ *
70
+ * RPCError: 400: REACTION_INVALID (caused by messages.SendReaction)
71
+ *
72
+ * The list is written with explicit escapes for those five, because the
73
+ * difference is invisible in an editor and a stray U+FE0F would break them
74
+ * again silently.
75
+ */
76
+ exports.TELEGRAM_REACTIONS = [
77
+ "๐Ÿ‘", "๐Ÿ‘Ž", "โค", "๐Ÿ”ฅ", "๐Ÿฅฐ", "๐Ÿ‘", "๐Ÿ˜", "๐Ÿค”", "๐Ÿคฏ", "๐Ÿ˜ฑ",
78
+ "๐Ÿคฌ", "๐Ÿ˜ข", "๐ŸŽ‰", "๐Ÿคฉ", "๐Ÿคฎ", "๐Ÿ’ฉ", "๐Ÿ™", "๐Ÿ‘Œ", "\u{1F54A}", "๐Ÿคก",
79
+ "๐Ÿฅฑ", "๐Ÿฅด", "๐Ÿ˜", "๐Ÿณ", "๐ŸŒš", "๐ŸŒญ", "๐Ÿ’ฏ", "๐Ÿคฃ", "โšก", "๐ŸŒ",
80
+ "๐Ÿ†", "๐Ÿ’”", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ“", "๐Ÿพ", "๐Ÿ’‹", "๐Ÿ˜ˆ", "๐Ÿ˜ด", "๐Ÿ˜ญ",
81
+ "๐Ÿค“", "๐Ÿ‘ป", "๐Ÿ‘€", "๐ŸŽƒ", "๐Ÿ™ˆ", "๐Ÿ˜‡", "๐Ÿ˜จ", "๐Ÿค", "โœ", "๐Ÿค—",
82
+ "๐Ÿซก", "๐ŸŽ…", "๐ŸŽ„", "โ˜ƒ", "๐Ÿ’…", "๐Ÿคช", "๐Ÿ—ฟ", "๐Ÿ†’", "๐Ÿ’˜", "๐Ÿ™‰",
83
+ "๐Ÿฆ„", "๐Ÿ˜˜", "๐Ÿ’Š", "๐Ÿ™Š", "๐Ÿ˜Ž", "๐Ÿ‘พ", "๐Ÿคท", "๐Ÿ˜ก",
84
+ ];
85
+ /**
86
+ * Strips the decorations a model adds that Telegram will not accept.
87
+ *
88
+ * U+FE0F is the big one โ€” models emit `โค๏ธ` and `โšก๏ธ` by habit, and the reaction
89
+ * set wants them bare. Skin-tone modifiers are dropped for the same reason:
90
+ * `๐Ÿ‘๐Ÿฝ` is not a member of the set, `๐Ÿ‘` is.
91
+ */
92
+ function canonicalizeReactionEmoji(value) {
93
+ return value.replace(/๏ธ/g, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
94
+ }
95
+ /**
96
+ * Turns a model answer into an emoji Telegram will actually take, or nothing.
97
+ *
98
+ * Deliberately strict, and strict in the one way that matters: the result is
99
+ * matched against the reaction set rather than merely "looks like an emoji".
100
+ * The first live attempt proved the difference โ€” the model picked a perfectly
101
+ * sensible emoji, the parser passed it, and Telegram refused it.
102
+ *
103
+ * `allowed` narrows the set further for chats that restrict which reactions
104
+ * they permit; omit it when the chat allows all of them.
54
105
  */
55
- function parseEmojiChoice(raw) {
106
+ function parseEmojiChoice(raw, allowed) {
56
107
  if (typeof raw !== "string") {
57
108
  return undefined;
58
109
  }
@@ -65,12 +116,14 @@ function parseEmojiChoice(raw) {
65
116
  if (/\s/.test(cleaned) || cleaned.length > 8) {
66
117
  return undefined;
67
118
  }
68
- // Latin letters and digits mean words like "NONE", "ok" or "1" slipped
69
- // through; an emoji has none of them.
70
- if (/[A-Za-z0-9]/.test(cleaned)) {
71
- return undefined;
72
- }
73
- return cleaned;
119
+ const candidate = canonicalizeReactionEmoji(cleaned);
120
+ // `undefined` is "the chat does not restrict reactions"; an empty list is
121
+ // `ChatReactionsNone` โ€” reactions switched off โ€” and must permit nothing.
122
+ // Collapsing the two would react in a chat that forbids reacting.
123
+ const permitted = allowed === undefined
124
+ ? exports.TELEGRAM_REACTIONS
125
+ : allowed.map(canonicalizeReactionEmoji);
126
+ return permitted.includes(candidate) ? candidate : undefined;
74
127
  }
75
128
  /**
76
129
  * Whether a silent turn deserves a reaction attempt at all.
@@ -111,14 +164,32 @@ async function reactToSilentMention(params) {
111
164
  if (!Number.isInteger(messageId) || messageId <= 0) {
112
165
  return undefined;
113
166
  }
167
+ // A chat that restricts reactions would reject anything outside its own set,
168
+ // so the restriction has to reach the model rather than be discovered by a
169
+ // rejected send. Not knowing is not the same as being forbidden: a failure
170
+ // here falls back to the full Telegram set.
171
+ const allowed = await (params.deps.allowedReactions?.() ?? Promise.resolve(undefined))
172
+ .catch(() => undefined);
173
+ // Reactions switched off for the whole chat: nothing to pick from, and no
174
+ // reason to spend a model call finding that out.
175
+ if (allowed !== undefined && allowed.length === 0) {
176
+ params.deps.onDecision?.({ messageId, appetite, chose: "none", allowedCount: 0 });
177
+ return undefined;
178
+ }
114
179
  const answer = await params.deps.complete({
115
180
  messages: [{ role: "user", content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS) }],
116
- systemPrompt: buildEmojiSystemPrompt(appetite),
181
+ systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
117
182
  maxTokens: 8,
118
183
  purpose: "clawgram: emoji reaction for a silent mention",
119
184
  });
120
- const emoji = parseEmojiChoice(answer?.text);
121
- params.deps.onDecision?.({ messageId, appetite, chose: emoji ? "emoji" : "none" });
185
+ const emoji = parseEmojiChoice(answer?.text, allowed);
186
+ params.deps.onDecision?.({
187
+ messageId,
188
+ appetite,
189
+ chose: emoji ? "emoji" : "none",
190
+ emoji,
191
+ allowedCount: allowed?.length,
192
+ });
122
193
  if (!emoji) {
123
194
  return undefined;
124
195
  }
@@ -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.10.0",
5
+ "version": "2.11.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.10.0",
3
+ "version": "2.11.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": {