clawgram 2.10.0 → 2.10.1

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,15 +20,22 @@
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.",
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(" ")}`,
32
39
  "Match the mood of the message: a joke gets something amused, praise something warm,",
33
40
  "bad news something sympathetic, an achievement something celebratory.",
34
41
  "Answer NONE when the message is conflictual, heavy, or discusses a person's",
@@ -45,14 +52,50 @@ function buildEmojiSystemPrompt(appetite) {
45
52
  ].join("\n");
46
53
  }
47
54
  /**
48
- * Turns a model answer into an emoji, or nothing.
55
+ * The emoji Telegram accepts as reactions, in the exact form it expects.
49
56
  *
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.
57
+ * Reactions are not "any emoji". Telegram keeps a fixed set, and several of
58
+ * its members carry **no** variation selector `❤` is U+2764 alone, and so
59
+ * are `⚡`, `✍`, `🕊`, `☃`. Sending the U+FE0F-decorated form of any of them
60
+ * fails, which is exactly what happened on the first live attempt:
61
+ *
62
+ * RPCError: 400: REACTION_INVALID (caused by messages.SendReaction)
63
+ *
64
+ * The list is written with explicit escapes for those five, because the
65
+ * difference is invisible in an editor and a stray U+FE0F would break them
66
+ * again silently.
67
+ */
68
+ exports.TELEGRAM_REACTIONS = [
69
+ "👍", "👎", "❤", "🔥", "🥰", "👏", "😁", "🤔", "🤯", "😱",
70
+ "🤬", "😢", "🎉", "🤩", "🤮", "💩", "🙏", "👌", "\u{1F54A}", "🤡",
71
+ "🥱", "🥴", "😍", "🐳", "🌚", "🌭", "💯", "🤣", "⚡", "🍌",
72
+ "🏆", "💔", "🤨", "😐", "🍓", "🍾", "💋", "😈", "😴", "😭",
73
+ "🤓", "👻", "👀", "🎃", "🙈", "😇", "😨", "🤝", "✍", "🤗",
74
+ "🫡", "🎅", "🎄", "☃", "💅", "🤪", "🗿", "🆒", "💘", "🙉",
75
+ "🦄", "😘", "💊", "🙊", "😎", "👾", "🤷", "😡",
76
+ ];
77
+ /**
78
+ * Strips the decorations a model adds that Telegram will not accept.
79
+ *
80
+ * U+FE0F is the big one — models emit `❤️` and `⚡️` by habit, and the reaction
81
+ * set wants them bare. Skin-tone modifiers are dropped for the same reason:
82
+ * `👍🏽` is not a member of the set, `👍` is.
83
+ */
84
+ function canonicalizeReactionEmoji(value) {
85
+ return value.replace(/️/g, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
86
+ }
87
+ /**
88
+ * Turns a model answer into an emoji Telegram will actually take, or nothing.
89
+ *
90
+ * Deliberately strict, and strict in the one way that matters: the result is
91
+ * matched against the reaction set rather than merely "looks like an emoji".
92
+ * The first live attempt proved the difference — the model picked a perfectly
93
+ * sensible emoji, the parser passed it, and Telegram refused it.
94
+ *
95
+ * `allowed` narrows the set further for chats that restrict which reactions
96
+ * they permit; omit it when the chat allows all of them.
54
97
  */
55
- function parseEmojiChoice(raw) {
98
+ function parseEmojiChoice(raw, allowed) {
56
99
  if (typeof raw !== "string") {
57
100
  return undefined;
58
101
  }
@@ -65,12 +108,14 @@ function parseEmojiChoice(raw) {
65
108
  if (/\s/.test(cleaned) || cleaned.length > 8) {
66
109
  return undefined;
67
110
  }
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;
111
+ const candidate = canonicalizeReactionEmoji(cleaned);
112
+ // `undefined` is "the chat does not restrict reactions"; an empty list is
113
+ // `ChatReactionsNone` — reactions switched off — and must permit nothing.
114
+ // Collapsing the two would react in a chat that forbids reacting.
115
+ const permitted = allowed === undefined
116
+ ? exports.TELEGRAM_REACTIONS
117
+ : allowed.map(canonicalizeReactionEmoji);
118
+ return permitted.includes(candidate) ? candidate : undefined;
74
119
  }
75
120
  /**
76
121
  * Whether a silent turn deserves a reaction attempt at all.
@@ -111,14 +156,32 @@ async function reactToSilentMention(params) {
111
156
  if (!Number.isInteger(messageId) || messageId <= 0) {
112
157
  return undefined;
113
158
  }
159
+ // A chat that restricts reactions would reject anything outside its own set,
160
+ // so the restriction has to reach the model rather than be discovered by a
161
+ // rejected send. Not knowing is not the same as being forbidden: a failure
162
+ // here falls back to the full Telegram set.
163
+ const allowed = await (params.deps.allowedReactions?.() ?? Promise.resolve(undefined))
164
+ .catch(() => undefined);
165
+ // Reactions switched off for the whole chat: nothing to pick from, and no
166
+ // reason to spend a model call finding that out.
167
+ if (allowed !== undefined && allowed.length === 0) {
168
+ params.deps.onDecision?.({ messageId, appetite, chose: "none", allowedCount: 0 });
169
+ return undefined;
170
+ }
114
171
  const answer = await params.deps.complete({
115
172
  messages: [{ role: "user", content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS) }],
116
- systemPrompt: buildEmojiSystemPrompt(appetite),
173
+ systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
117
174
  maxTokens: 8,
118
175
  purpose: "clawgram: emoji reaction for a silent mention",
119
176
  });
120
- const emoji = parseEmojiChoice(answer?.text);
121
- params.deps.onDecision?.({ messageId, appetite, chose: emoji ? "emoji" : "none" });
177
+ const emoji = parseEmojiChoice(answer?.text, allowed);
178
+ params.deps.onDecision?.({
179
+ messageId,
180
+ appetite,
181
+ chose: emoji ? "emoji" : "none",
182
+ emoji,
183
+ allowedCount: allowed?.length,
184
+ });
122
185
  if (!emoji) {
123
186
  return undefined;
124
187
  }
@@ -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.10.1",
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.10.1",
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": {