clawgram 2.18.0 → 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 +49 -1
- package/dist/channel.js +210 -13
- package/dist/fetch-media.js +96 -0
- package/dist/gramjs-client.js +17 -0
- package/dist/media.js +76 -6
- package/openclaw.plugin.json +1 -1
- 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
|
|
@@ -739,6 +740,53 @@ first. These errors are surfaced as-is rather than retried.
|
|
|
739
740
|
}
|
|
740
741
|
```
|
|
741
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
|
+
|
|
742
790
|
## Security and privacy
|
|
743
791
|
|
|
744
792
|
This plugin holds credentials for a real Telegram account and handles private correspondence. What
|
|
@@ -749,7 +797,7 @@ that means in practice, and what the code does about it:
|
|
|
749
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 |
|
|
750
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 |
|
|
751
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 |
|
|
752
|
-
| 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 |
|
|
753
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) |
|
|
754
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 |
|
|
755
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");
|
|
@@ -239,6 +249,33 @@ function resolveAgentDirForMedia(cfg) {
|
|
|
239
249
|
const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
|
|
240
250
|
return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
|
|
241
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
|
+
}
|
|
242
279
|
async function readInboundAttachment(params) {
|
|
243
280
|
const media = params.runtime?.mediaUnderstanding;
|
|
244
281
|
const message = params.event?.message;
|
|
@@ -267,19 +304,13 @@ async function readInboundAttachment(params) {
|
|
|
267
304
|
return undefined;
|
|
268
305
|
}
|
|
269
306
|
try {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
filePath: downloaded.path,
|
|
278
|
-
cfg: params.cfg,
|
|
279
|
-
mime: downloaded.mimeType,
|
|
280
|
-
agentDir: resolveAgentDirForMedia(params.cfg),
|
|
281
|
-
});
|
|
282
|
-
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
|
+
});
|
|
283
314
|
if (!read) {
|
|
284
315
|
params.log?.info?.("clawgram attachment read empty", {
|
|
285
316
|
accountId: params.accountId,
|
|
@@ -377,6 +408,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
377
408
|
"Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
|
|
378
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.",
|
|
379
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.",
|
|
380
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`.",
|
|
381
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.",
|
|
382
414
|
],
|
|
@@ -387,6 +419,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
387
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.",
|
|
388
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.",
|
|
389
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.",
|
|
390
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\".",
|
|
391
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.",
|
|
392
425
|
],
|
|
@@ -1356,6 +1389,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1356
1389
|
// file stays on disk. That is exactly what happened on 2026-08-07.
|
|
1357
1390
|
actions: [
|
|
1358
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",
|
|
1359
1396
|
// Chat management (2.12.0) — gated by the account's manageChats
|
|
1360
1397
|
// scope; without it every one of these is refused.
|
|
1361
1398
|
"createGroup", "addMembers", "removeMember",
|
|
@@ -1416,6 +1453,166 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1416
1453
|
messages: history.messages,
|
|
1417
1454
|
});
|
|
1418
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
|
+
}
|
|
1419
1616
|
// Membership is a read, so the same `readChats` scope that gates history
|
|
1420
1617
|
// gates it too: this cannot become a way to enumerate chats the account
|
|
1421
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
|
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/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,
|
package/package.json
CHANGED