clawgram 2.9.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 +96 -56
- package/dist/gramjs-client.js +40 -0
- package/dist/reactions.js +6 -45
- package/dist/silent-reaction.js +195 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/channel.js
CHANGED
|
@@ -58,6 +58,7 @@ const normalize_1 = require("./normalize");
|
|
|
58
58
|
const history_1 = require("./history");
|
|
59
59
|
const joins_1 = require("./joins");
|
|
60
60
|
const reactions_1 = require("./reactions");
|
|
61
|
+
const silent_reaction_1 = require("./silent-reaction");
|
|
61
62
|
const chat_info_1 = require("./chat-info");
|
|
62
63
|
const secret_refs_1 = require("./secret-refs");
|
|
63
64
|
const secret_ref_runtime_1 = require("openclaw/plugin-sdk/secret-ref-runtime");
|
|
@@ -75,6 +76,41 @@ function readAccountReactionLevel(cfg, accountId) {
|
|
|
75
76
|
}
|
|
76
77
|
return cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
|
|
77
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Wires `reactToSilentMention` to this account's runtime, config and log.
|
|
81
|
+
*
|
|
82
|
+
* The decision itself lives in `silent-reaction.ts`, testable without a
|
|
83
|
+
* Telegram connection; everything here is lookup. Missing pieces — no
|
|
84
|
+
* connected client, no model access — resolve to no reaction rather than to
|
|
85
|
+
* an error, because by this point the agent has already declined to reply.
|
|
86
|
+
*/
|
|
87
|
+
async function reactToSilentMentionForAccount(params) {
|
|
88
|
+
const gram = params.gram;
|
|
89
|
+
const llm = params.pluginRuntime?.llm;
|
|
90
|
+
if (!gram || typeof llm?.complete !== "function") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await (0, silent_reaction_1.reactToSilentMention)({
|
|
94
|
+
appetite: (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(params.cfg, params.accountId)),
|
|
95
|
+
wasMentioned: params.wasMentioned,
|
|
96
|
+
chatId: params.chatId,
|
|
97
|
+
messageId: params.messageId,
|
|
98
|
+
messageText: params.messageText,
|
|
99
|
+
deps: {
|
|
100
|
+
// Bound rather than destructured: the SDK may implement this as a
|
|
101
|
+
// method that needs its receiver.
|
|
102
|
+
complete: (args) => llm.complete(args),
|
|
103
|
+
sendReaction: (args) => gram.sendReaction(args),
|
|
104
|
+
allowedReactions: gram.getAllowedReactions
|
|
105
|
+
? () => gram.getAllowedReactions(params.chatId)
|
|
106
|
+
: undefined,
|
|
107
|
+
onDecision: (info) => actionLog.info("clawgram silent-mention reaction", {
|
|
108
|
+
accountId: params.accountId,
|
|
109
|
+
...info,
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
78
114
|
/**
|
|
79
115
|
* Read scope as configured for the account. Left `undefined` when the key is
|
|
80
116
|
* absent so `isChatReadable` can tell "not configured" from "configured empty" —
|
|
@@ -241,50 +277,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
241
277
|
},
|
|
242
278
|
capabilities: CHANNEL_CAPABILITIES,
|
|
243
279
|
agentPrompt: {
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
configuredLevel: typeof account?.reactionLevel === "string" ? account.reactionLevel : null,
|
|
263
|
-
level: level ?? null,
|
|
264
|
-
});
|
|
265
|
-
// "clawgram" is our plugin id; the section reads "Reactions are
|
|
266
|
-
// enabled for <label>", and the label is the platform people see.
|
|
267
|
-
return level ? { level, channelLabel: "Telegram" } : undefined;
|
|
268
|
-
},
|
|
269
|
-
messageToolHints: ({ cfg, accountId } = {}) => {
|
|
270
|
-
const level = (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(cfg, accountId));
|
|
271
|
-
// Logged for the same reason reactionGuidance is: this path is the
|
|
272
|
-
// workaround for core skipping that hook, so "did our text reach the
|
|
273
|
-
// prompt" has to be answerable from the log rather than by inference.
|
|
274
|
-
actionLog.info("clawgram messageToolHints", {
|
|
275
|
-
accountId: accountId ?? null,
|
|
276
|
-
reactionLevel: level ?? null,
|
|
277
|
-
});
|
|
278
|
-
return [
|
|
279
|
-
"Use clawgram to send Telegram replies from the connected personal account.",
|
|
280
|
-
"When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
|
|
281
|
-
"Explicit targets may be @username, numeric Telegram user id, phone/contact resolvable by Telegram, group chat ids, or clawgram:<target>.",
|
|
282
|
-
"For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
|
|
283
|
-
"Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
|
|
284
|
-
"Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
|
|
285
|
-
...(0, reactions_1.buildReactionHintLines)(level),
|
|
286
|
-
];
|
|
287
|
-
},
|
|
280
|
+
// Nothing here steers reactions, and that is deliberate. 2.8.0 added a
|
|
281
|
+
// `reactionGuidance` hook and 2.9.0 moved the same text onto these
|
|
282
|
+
// hints; instrumentation then showed both hooks logging zero
|
|
283
|
+
// invocations across live turns while the assembled prompt stayed
|
|
284
|
+
// byte-identical at 44 266 chars. Core resolves the channel for prompt
|
|
285
|
+
// assembly from `params.messageChannel ?? params.messageProvider`,
|
|
286
|
+
// which is empty on this path, so nothing this channel contributes to
|
|
287
|
+
// the prompt reaches the agent at all. Reactions are decided in code
|
|
288
|
+
// instead — see `reactToSilentMention`. Do not re-add prompt text here
|
|
289
|
+
// expecting it to arrive.
|
|
290
|
+
messageToolHints: () => [
|
|
291
|
+
"Use clawgram to send Telegram replies from the connected personal account.",
|
|
292
|
+
"When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
|
|
293
|
+
"Explicit targets may be @username, numeric Telegram user id, phone/contact resolvable by Telegram, group chat ids, or clawgram:<target>.",
|
|
294
|
+
"For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
|
|
295
|
+
"Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
|
|
296
|
+
"Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
|
|
297
|
+
],
|
|
288
298
|
messageToolCapabilities: () => [
|
|
289
299
|
"clawgram can reply in the current Telegram conversation when no explicit target is provided.",
|
|
290
300
|
"clawgram can send text messages to direct chats and groups from the connected personal account.",
|
|
@@ -835,15 +845,53 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
835
845
|
const visibleFallbackText = fallbackText
|
|
836
846
|
? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
|
|
837
847
|
: "";
|
|
838
|
-
if (
|
|
839
|
-
|
|
848
|
+
if (!visibleFallbackText) {
|
|
849
|
+
if (fallbackText) {
|
|
850
|
+
log?.info?.("clawgram skipping silent transcript fallback", {
|
|
851
|
+
accountId,
|
|
852
|
+
chatId: normalized.chatId,
|
|
853
|
+
messageId: normalized.messageId,
|
|
854
|
+
routeSessionKey: route.sessionKey,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
log?.warn?.("clawgram transcript fallback unavailable", {
|
|
859
|
+
accountId,
|
|
860
|
+
chatId: normalized.chatId,
|
|
861
|
+
messageId: normalized.messageId,
|
|
862
|
+
routeSessionKey: route.sessionKey,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
// Named, and nothing came back: leave a reaction so the
|
|
866
|
+
// decision is visible instead of reading as her ignoring
|
|
867
|
+
// people. The condition is her silence, not the shape of
|
|
868
|
+
// the transcript — a turn that wrote no entry at all is
|
|
869
|
+
// just as silent as one that wrote the NO_REPLY token.
|
|
870
|
+
//
|
|
871
|
+
// Never allowed to disturb the turn: the reply is already
|
|
872
|
+
// settled by this point, so a failure here stays silent.
|
|
873
|
+
await reactToSilentMentionForAccount({
|
|
874
|
+
cfg,
|
|
840
875
|
accountId,
|
|
876
|
+
gram: runtimes.get(accountId),
|
|
877
|
+
pluginRuntime,
|
|
841
878
|
chatId: normalized.chatId,
|
|
842
879
|
messageId: normalized.messageId,
|
|
843
|
-
|
|
880
|
+
messageText: normalized.text,
|
|
881
|
+
// Same sense of "addressed" the agent was given for this
|
|
882
|
+
// turn on line 817: a reply to her own message counts as
|
|
883
|
+
// being spoken to, mention or not.
|
|
884
|
+
wasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
|
|
885
|
+
}).catch((err) => {
|
|
886
|
+
log?.info?.("clawgram silent-mention reaction failed", {
|
|
887
|
+
accountId,
|
|
888
|
+
chatId: normalized.chatId,
|
|
889
|
+
messageId: normalized.messageId,
|
|
890
|
+
error: String(err),
|
|
891
|
+
});
|
|
844
892
|
});
|
|
845
893
|
}
|
|
846
|
-
else
|
|
894
|
+
else {
|
|
847
895
|
log?.warn?.("clawgram using transcript fallback reply", {
|
|
848
896
|
accountId,
|
|
849
897
|
chatId: normalized.chatId,
|
|
@@ -857,14 +905,6 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
857
905
|
messageThreadId,
|
|
858
906
|
});
|
|
859
907
|
}
|
|
860
|
-
else {
|
|
861
|
-
log?.warn?.("clawgram transcript fallback unavailable", {
|
|
862
|
-
accountId,
|
|
863
|
-
chatId: normalized.chatId,
|
|
864
|
-
messageId: normalized.messageId,
|
|
865
|
-
routeSessionKey: route.sessionKey,
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
908
|
}
|
|
869
909
|
}, {
|
|
870
910
|
readMessageId: Number(normalized.messageId),
|
package/dist/gramjs-client.js
CHANGED
|
@@ -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
|
*
|
package/dist/reactions.js
CHANGED
|
@@ -11,20 +11,16 @@
|
|
|
11
11
|
* tested without a Telegram connection.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.buildReactionHintLines = buildReactionHintLines;
|
|
15
14
|
exports.resolveAgentReactionGuidance = resolveAgentReactionGuidance;
|
|
16
15
|
exports.parseReactionParams = parseReactionParams;
|
|
17
16
|
/**
|
|
18
|
-
* How
|
|
17
|
+
* How freely the agent may react, from the account's `reactionLevel`.
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* and
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Levels and fallbacks mirror the bundled Telegram channel so the two behave
|
|
27
|
-
* the same for the same config:
|
|
19
|
+
* This started as an answer for core's `agentPrompt.reactionGuidance` hook,
|
|
20
|
+
* which core turned out never to call for this channel. The config key stays
|
|
21
|
+
* and keeps its meaning; it now steers `reactToSilentMention` instead of a
|
|
22
|
+
* paragraph of prompt text. Levels mirror the bundled Telegram channel so the
|
|
23
|
+
* same config reads the same way in both:
|
|
28
24
|
*
|
|
29
25
|
* - `off`, `ack` — no agent reactions (`ack` is the "seen it" emoji core sends
|
|
30
26
|
* by itself, which is a different feature);
|
|
@@ -34,41 +30,6 @@ exports.parseReactionParams = parseReactionParams;
|
|
|
34
30
|
* An invalid value yields no guidance rather than a guess: a typo turning a
|
|
35
31
|
* work chat chatty is worse than a typo turning it quiet.
|
|
36
32
|
*/
|
|
37
|
-
/**
|
|
38
|
-
* The reaction guidance as prompt lines, carried by `messageToolHints`.
|
|
39
|
-
*
|
|
40
|
-
* Core has its own `## Reactions` section, but it only builds it when
|
|
41
|
-
* `params.config` is truthy in the prompt assembler — a condition our channel
|
|
42
|
-
* never satisfied: the hook logged zero invocations across live turns while
|
|
43
|
-
* the neighbouring `messageToolHints`, guarded only by the channel being
|
|
44
|
-
* resolved, is called every time.
|
|
45
|
-
*
|
|
46
|
-
* That condition lives in the minified `openclaw` dependency, so it is not
|
|
47
|
-
* ours to fix; patching `node_modules` would evaporate on the next update.
|
|
48
|
-
* The hints path is ours, reaches the same prompt, and does not depend on
|
|
49
|
-
* that diagnosis being right — if the hints arrive, so does the text.
|
|
50
|
-
*
|
|
51
|
-
* Wording follows core's own so behaviour stays the same if it ever starts
|
|
52
|
-
* calling the hook and both appear.
|
|
53
|
-
*/
|
|
54
|
-
function buildReactionHintLines(level) {
|
|
55
|
-
if (!level) {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
const shared = [
|
|
59
|
-
"Use the `react` action for this: it leaves an emoji on a message without sending one.",
|
|
60
|
-
"A reaction is not a reply — you can react and still return NO_REPLY, and that is the point when someone names you but needs no answer.",
|
|
61
|
-
];
|
|
62
|
-
return level === "minimal"
|
|
63
|
-
? [
|
|
64
|
-
"Reactions are enabled for Telegram in MINIMAL mode. React ONLY when truly relevant: acknowledge an important request or confirmation, or show genuine sentiment sparingly. Do not react to routine messages or to your own replies. Guideline: at most 1 reaction per 5-10 exchanges.",
|
|
65
|
-
...shared,
|
|
66
|
-
]
|
|
67
|
-
: [
|
|
68
|
-
"Reactions are enabled for Telegram in EXTENSIVE mode. React liberally: acknowledge messages with a fitting emoji, show sentiment and personality, react to humour, notable events or good news, and use a reaction to confirm agreement. Guideline: react whenever it feels natural.",
|
|
69
|
-
...shared,
|
|
70
|
-
];
|
|
71
|
-
}
|
|
72
33
|
function resolveAgentReactionGuidance(value) {
|
|
73
34
|
if (value === undefined || value === null) {
|
|
74
35
|
return "minimal";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reacting when the agent decides to stay silent.
|
|
4
|
+
*
|
|
5
|
+
* The agent is told, in her own workspace rules, to leave a reaction when she
|
|
6
|
+
* is named but has nothing to say. She never did — not once across every turn
|
|
7
|
+
* in the logs — and the reason turned out not to be the wording: core builds
|
|
8
|
+
* her system prompt without knowing which channel it is for, so nothing this
|
|
9
|
+
* channel contributes to the prompt reaches her at all. `messageToolHints`
|
|
10
|
+
* and `reactionGuidance` both logged zero invocations while the assembled
|
|
11
|
+
* prompt stayed byte-identical.
|
|
12
|
+
*
|
|
13
|
+
* So the decision moves out of the prompt and into code, at the one moment
|
|
14
|
+
* that is unambiguous: she returned NO_REPLY on a message that named her.
|
|
15
|
+
* The emoji is still chosen by a model — the point was a reaction that fits
|
|
16
|
+
* the message, not a fixed acknowledgement stamp — but choosing it is now a
|
|
17
|
+
* separate, cheap call that cannot be forgotten mid-prompt.
|
|
18
|
+
*
|
|
19
|
+
* Parsing lives here, away from the network, so the awkward cases can be
|
|
20
|
+
* tested without Telegram or a model.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.TELEGRAM_REACTIONS = void 0;
|
|
24
|
+
exports.buildEmojiSystemPrompt = buildEmojiSystemPrompt;
|
|
25
|
+
exports.canonicalizeReactionEmoji = canonicalizeReactionEmoji;
|
|
26
|
+
exports.parseEmojiChoice = parseEmojiChoice;
|
|
27
|
+
exports.shouldReactToSilentTurn = shouldReactToSilentTurn;
|
|
28
|
+
exports.reactToSilentMention = reactToSilentMention;
|
|
29
|
+
function buildEmojiSystemPrompt(appetite, allowed) {
|
|
30
|
+
const choices = allowed === undefined || allowed.length === 0 ? exports.TELEGRAM_REACTIONS : allowed;
|
|
31
|
+
const shared = [
|
|
32
|
+
"You pick a single emoji reaction for a chat message.",
|
|
33
|
+
"The assistant was mentioned in this message but decided it needs no written reply.",
|
|
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(" ")}`,
|
|
39
|
+
"Match the mood of the message: a joke gets something amused, praise something warm,",
|
|
40
|
+
"bad news something sympathetic, an achievement something celebratory.",
|
|
41
|
+
"Answer NONE when the message is conflictual, heavy, or discusses a person's",
|
|
42
|
+
"performance — a reaction there reads as a verdict on someone.",
|
|
43
|
+
];
|
|
44
|
+
return appetite === "minimal"
|
|
45
|
+
? [
|
|
46
|
+
...shared,
|
|
47
|
+
"Be sparing: answer NONE unless the message clearly invites a reaction.",
|
|
48
|
+
].join("\n")
|
|
49
|
+
: [
|
|
50
|
+
...shared,
|
|
51
|
+
"Be generous: react whenever a reaction would feel natural to a colleague.",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The emoji Telegram accepts as reactions, in the exact form it expects.
|
|
56
|
+
*
|
|
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.
|
|
97
|
+
*/
|
|
98
|
+
function parseEmojiChoice(raw, allowed) {
|
|
99
|
+
if (typeof raw !== "string") {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
// Models like to wrap answers in quotes, backticks or a trailing period.
|
|
103
|
+
const cleaned = raw.trim().replace(/^["'`]+|["'`.]+$/g, "").trim();
|
|
104
|
+
if (!cleaned || /^none$/i.test(cleaned)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
// A sentence is a refusal or an explanation, not a reaction.
|
|
108
|
+
if (/\s/.test(cleaned) || cleaned.length > 8) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
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;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a silent turn deserves a reaction attempt at all.
|
|
122
|
+
*
|
|
123
|
+
* Only mentions: the rule the owner asked for is about being named and having
|
|
124
|
+
* nothing to add. A silent turn on a message that never mentioned her is
|
|
125
|
+
* ordinary background reading, and reacting to it would be noise.
|
|
126
|
+
*/
|
|
127
|
+
function shouldReactToSilentTurn(params) {
|
|
128
|
+
if (!params.appetite || !params.wasMentioned) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return Boolean(params.messageText?.trim());
|
|
132
|
+
}
|
|
133
|
+
/** The message text is truncated before it reaches the model; a mood needs no more. */
|
|
134
|
+
const MAX_JUDGED_CHARS = 2000;
|
|
135
|
+
/**
|
|
136
|
+
* Leaves an emoji on a message the agent was named in but chose not to answer.
|
|
137
|
+
*
|
|
138
|
+
* Best-effort by construction: the reply is already settled when this runs, so
|
|
139
|
+
* a missing model, a refusal, or an emoji Telegram will not take all end as
|
|
140
|
+
* silence — the same outcome as before the feature existed. Callers still wrap
|
|
141
|
+
* it, because a throw here would surface as a failed turn on a message the
|
|
142
|
+
* agent had already decided needed nothing.
|
|
143
|
+
*
|
|
144
|
+
* Returns the emoji it sent, for tests and for nothing else.
|
|
145
|
+
*/
|
|
146
|
+
async function reactToSilentMention(params) {
|
|
147
|
+
const { appetite } = params;
|
|
148
|
+
if (!appetite || !shouldReactToSilentTurn({
|
|
149
|
+
wasMentioned: params.wasMentioned,
|
|
150
|
+
appetite,
|
|
151
|
+
messageText: params.messageText,
|
|
152
|
+
})) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const messageId = Number(params.messageId);
|
|
156
|
+
if (!Number.isInteger(messageId) || messageId <= 0) {
|
|
157
|
+
return undefined;
|
|
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
|
+
}
|
|
171
|
+
const answer = await params.deps.complete({
|
|
172
|
+
messages: [{ role: "user", content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS) }],
|
|
173
|
+
systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
|
|
174
|
+
maxTokens: 8,
|
|
175
|
+
purpose: "clawgram: emoji reaction for a silent mention",
|
|
176
|
+
});
|
|
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
|
+
});
|
|
185
|
+
if (!emoji) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
await params.deps.sendReaction({
|
|
189
|
+
target: params.chatId,
|
|
190
|
+
messageId,
|
|
191
|
+
emoji,
|
|
192
|
+
remove: false,
|
|
193
|
+
});
|
|
194
|
+
return emoji;
|
|
195
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "2.10.1",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED