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 +73 -2
- package/dist/channel.js +248 -22
- package/dist/fetch-media.js +96 -0
- package/dist/gramjs-client.js +39 -0
- package/dist/helpers.js +49 -14
- package/dist/media.js +76 -6
- package/dist/silent-reaction.js +33 -4
- package/openclaw.plugin.json +13 -5
- package/package.json +1 -1
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"`
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/gramjs-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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
|
-
|
|
152
|
-
const
|
|
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") {
|
package/dist/silent-reaction.js
CHANGED
|
@@ -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
|
|
191
|
-
|
|
217
|
+
const { answer, fellBack } = await completeEmoji({
|
|
218
|
+
deps: params.deps,
|
|
219
|
+
model: params.model,
|
|
192
220
|
systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
|
|
193
|
-
|
|
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;
|
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.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