clawgram 2.28.0 → 2.29.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 +2 -1
- package/dist/actions-read.js +9 -5
- package/dist/actions-send.js +61 -0
- package/dist/actions.js +14 -0
- package/dist/attachments.js +12 -0
- package/dist/channel.js +5 -2
- package/dist/docx-text.js +153 -0
- package/dist/edits.js +46 -0
- package/dist/gramjs-client.js +30 -0
- package/dist/media.js +39 -2
- package/openclaw.plugin.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
|
|
|
31
31
|
- **@Mention detection** — respond only when mentioned in groups (text, caption, and ID-based mentions)
|
|
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
|
+
- **Editing what was sent** — rewrite the account's own message in place instead of posting a correction beneath it (`edit` action)
|
|
34
35
|
- **Chat metadata** — title, type, member count, description, forum flag and pinned message (`chatInfo` action)
|
|
35
36
|
- **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)
|
|
36
37
|
- **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
|
|
@@ -260,7 +261,7 @@ loud where it does occur.
|
|
|
260
261
|
| `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
|
|
261
262
|
| `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
|
|
262
263
|
| `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
|
|
263
|
-
| `sendChats` | string[] | unset | Chats the assistant may **send to** — `send`, `upload-file`, `react` and core's own delivery path (`--deliver`, sub-agent announcements). Absent = every chat; `[]` = none. Phone-number targets are refused regardless (2.18.0; core delivery covered since 2.25.0) |
|
|
264
|
+
| `sendChats` | string[] | unset | Chats the assistant may **send to** — `send`, `upload-file`, `react`, `edit` and core's own delivery path (`--deliver`, sub-agent announcements). Absent = every chat; `[]` = none. Phone-number targets are refused regardless (2.18.0; core delivery covered since 2.25.0) |
|
|
264
265
|
| `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
|
|
265
266
|
| `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
|
|
266
267
|
| `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 |
|
package/dist/actions-read.js
CHANGED
|
@@ -141,11 +141,13 @@ async function handleReadAction(ctx) {
|
|
|
141
141
|
else {
|
|
142
142
|
await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
|
|
143
143
|
}
|
|
144
|
+
const described = (0, media_1.describeMedia)(found.message?.media);
|
|
144
145
|
const downloaded = await (0, media_1.downloadMessageMediaToFile)({
|
|
145
146
|
client: fetchGram.getClient(),
|
|
146
147
|
message: found.message,
|
|
147
148
|
maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
|
|
148
149
|
dir: fetchDir,
|
|
150
|
+
understanding: (0, media_1.fetchMediaUnderstanding)(described),
|
|
149
151
|
fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
|
|
150
152
|
chatId: fetchChatId,
|
|
151
153
|
messageId: fetchParams.messageId,
|
|
@@ -159,7 +161,6 @@ async function handleReadAction(ctx) {
|
|
|
159
161
|
// channel does not read (a video, a spreadsheet), and one too
|
|
160
162
|
// large to be worth the transfer. Saying "could not fetch" to all
|
|
161
163
|
// three is how "she ignored the picture" starts.
|
|
162
|
-
const described = (0, media_1.describeMedia)(found.message?.media);
|
|
163
164
|
const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
|
|
164
165
|
const error = !described
|
|
165
166
|
? "no-media"
|
|
@@ -192,6 +193,7 @@ async function handleReadAction(ctx) {
|
|
|
192
193
|
filePath: downloaded.path,
|
|
193
194
|
mimeType: downloaded.mimeType,
|
|
194
195
|
understanding: downloaded.understanding,
|
|
196
|
+
fileName: downloaded.media.fileName,
|
|
195
197
|
});
|
|
196
198
|
if (!read) {
|
|
197
199
|
readError = "read-empty";
|
|
@@ -207,7 +209,8 @@ async function handleReadAction(ctx) {
|
|
|
207
209
|
// `read` mode is the inbound contract — the words, not the file — so
|
|
208
210
|
// the bytes go away with the answer. Any other mode keeps them:
|
|
209
211
|
// that is the whole point of asking for a path.
|
|
210
|
-
|
|
212
|
+
const pdfNeedsFile = downloaded.understanding === "pdf";
|
|
213
|
+
if (fetchParams.mode === "read" && !pdfNeedsFile) {
|
|
211
214
|
try {
|
|
212
215
|
const { rm } = await import("node:fs/promises");
|
|
213
216
|
await rm(fetchDir, { recursive: true, force: true });
|
|
@@ -217,6 +220,7 @@ async function handleReadAction(ctx) {
|
|
|
217
220
|
// over it would throw away a reading that already succeeded.
|
|
218
221
|
}
|
|
219
222
|
}
|
|
223
|
+
const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
|
|
220
224
|
actionLog.info("clawgram fetch-media completed", {
|
|
221
225
|
accountId: fetchAccountId,
|
|
222
226
|
chatId: fetchChatId,
|
|
@@ -225,7 +229,7 @@ async function handleReadAction(ctx) {
|
|
|
225
229
|
kind: downloaded.media.kind,
|
|
226
230
|
understanding: downloaded.understanding,
|
|
227
231
|
characters: read?.length ?? 0,
|
|
228
|
-
readError:
|
|
232
|
+
readError: finalReadError ?? null,
|
|
229
233
|
});
|
|
230
234
|
return (0, core_1.jsonResult)({
|
|
231
235
|
ok: true,
|
|
@@ -235,9 +239,9 @@ async function handleReadAction(ctx) {
|
|
|
235
239
|
mode: fetchParams.mode,
|
|
236
240
|
media: downloaded.media,
|
|
237
241
|
understanding: downloaded.understanding,
|
|
238
|
-
filePath: fetchParams.mode === "read" ? undefined : downloaded.path,
|
|
242
|
+
filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
|
|
239
243
|
text: read,
|
|
240
|
-
readError,
|
|
244
|
+
readError: finalReadError,
|
|
241
245
|
});
|
|
242
246
|
}
|
|
243
247
|
/**
|
package/dist/actions-send.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.handleSendAction = handleSendAction;
|
|
|
4
4
|
const core_1 = require("openclaw/plugin-sdk/core");
|
|
5
5
|
const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
|
|
6
6
|
const account_scopes_1 = require("./account-scopes");
|
|
7
|
+
const edits_1 = require("./edits");
|
|
7
8
|
const group_reply_address_1 = require("./group-reply-address");
|
|
8
9
|
const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
|
|
9
10
|
const helpers_1 = require("./helpers");
|
|
@@ -62,6 +63,66 @@ async function handleSendAction(ctx) {
|
|
|
62
63
|
removed: reactionParams.remove,
|
|
63
64
|
});
|
|
64
65
|
}
|
|
66
|
+
// Rewriting a message already sent. Gated exactly like `react`: it is a
|
|
67
|
+
// visible act in someone else's chat under this account's name, so it takes
|
|
68
|
+
// the outbound scope check and honours `dryRun`, which reading does not need.
|
|
69
|
+
if (canonical === "edit") {
|
|
70
|
+
const editParams = (0, edits_1.parseEditParams)(params, toolContext);
|
|
71
|
+
const editAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
72
|
+
if (!editAccountId) {
|
|
73
|
+
throw new Error("clawgram: no configured account found");
|
|
74
|
+
}
|
|
75
|
+
if (!(0, send_scope_1.isChatSendable)(editParams.target, (0, account_scopes_1.resolveAccountSendChats)(cfg, editAccountId))) {
|
|
76
|
+
(0, account_scopes_1.refuseOutboundOutsideScope)("edit", editAccountId, editParams.target);
|
|
77
|
+
}
|
|
78
|
+
// The same sentinel guard as `send`, for the same reason: `NO_REPLY` is
|
|
79
|
+
// OpenClaw's "say nothing", and an explicit tool call is not a path that
|
|
80
|
+
// strips it. Editing a real answer down to the token would replace a good
|
|
81
|
+
// message with what looks like a malfunction — worse than the send case,
|
|
82
|
+
// because the original text is gone.
|
|
83
|
+
if ((0, helpers_1.isSilentReplyText)(editParams.text)) {
|
|
84
|
+
throw new Error("clawgram: edit refuses a silent-reply sentinel as the new text");
|
|
85
|
+
}
|
|
86
|
+
// No reply-address prefix, no visible-reply memory, no turn-send memory:
|
|
87
|
+
// those three exist to shape and de-duplicate NEW messages. An edit
|
|
88
|
+
// addresses nobody afresh and adds nothing to the chat, so recording it as
|
|
89
|
+
// "this turn has spoken" would suppress a later genuine answer.
|
|
90
|
+
const editParseMode = (0, helpers_1.resolveOutboundParseMode)(params, cfg, editAccountId);
|
|
91
|
+
actionLog.info("clawgram handleAction edit", {
|
|
92
|
+
accountId: editAccountId,
|
|
93
|
+
dryRun: dryRun === true,
|
|
94
|
+
target: editParams.target,
|
|
95
|
+
messageId: editParams.messageId,
|
|
96
|
+
});
|
|
97
|
+
if (dryRun === true) {
|
|
98
|
+
return (0, core_1.jsonResult)({
|
|
99
|
+
ok: true,
|
|
100
|
+
dryRun: true,
|
|
101
|
+
accountId: editAccountId,
|
|
102
|
+
chatId: editParams.target,
|
|
103
|
+
messageId: editParams.messageId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const editGram = requireRuntimeFor(editAccountId);
|
|
107
|
+
await editGram.editText({
|
|
108
|
+
target: editParams.target,
|
|
109
|
+
messageId: editParams.messageId,
|
|
110
|
+
text: editParams.text,
|
|
111
|
+
parseMode: editParseMode,
|
|
112
|
+
});
|
|
113
|
+
actionLog.info("clawgram handleAction edit completed", {
|
|
114
|
+
accountId: editAccountId,
|
|
115
|
+
target: editParams.target,
|
|
116
|
+
messageId: editParams.messageId,
|
|
117
|
+
});
|
|
118
|
+
return (0, core_1.jsonResult)({
|
|
119
|
+
ok: true,
|
|
120
|
+
edited: true,
|
|
121
|
+
accountId: editAccountId,
|
|
122
|
+
chatId: editParams.target,
|
|
123
|
+
messageId: editParams.messageId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
65
126
|
// Core normalizes whichever of these it filled in to a local path (see
|
|
66
127
|
// `mediaSourceParams` above); `mediaUrl` stays a URL, which GramJS
|
|
67
128
|
// accepts as well.
|
package/dist/actions.js
CHANGED
|
@@ -30,6 +30,14 @@ const ACTION_ALIASES = {
|
|
|
30
30
|
list: "read",
|
|
31
31
|
react: "react",
|
|
32
32
|
joins: "joins",
|
|
33
|
+
// Rewriting a message already sent (2.29.0). Core has always known the name
|
|
34
|
+
// — it is in `CHANNEL_MESSAGE_ACTION_NAMES` — so the call reached this
|
|
35
|
+
// channel and was refused as unsupported, and a wrong answer could only be
|
|
36
|
+
// followed by a second message correcting it.
|
|
37
|
+
edit: "edit",
|
|
38
|
+
editMessage: "edit",
|
|
39
|
+
"edit-message": "edit",
|
|
40
|
+
update: "edit",
|
|
33
41
|
"upload-file": "upload-file",
|
|
34
42
|
sendAttachment: "upload-file",
|
|
35
43
|
"fetch-media": "fetch-media",
|
|
@@ -108,6 +116,11 @@ function canonicalAction(action) {
|
|
|
108
116
|
* call fell through to the current chat and answered about the wrong one.
|
|
109
117
|
* `readChatTargetParam` is the single list of accepted spellings now.
|
|
110
118
|
*
|
|
119
|
+
* `edit` (2.29.0) is the third shape: core resolves it as a *message* target,
|
|
120
|
+
* so both the chat and `messageId` arrive named. It needs no synonym — the
|
|
121
|
+
* name core uses is the name this channel uses — and it is listed here only so
|
|
122
|
+
* the suite asserts core still knows it.
|
|
123
|
+
*
|
|
111
124
|
* These spellings are derived from `ACTION_ALIASES` rather than kept beside
|
|
112
125
|
* it; that core actually knows each of them is asserted against the installed
|
|
113
126
|
* core in `core-action-synonyms.test.ts`.
|
|
@@ -115,6 +128,7 @@ function canonicalAction(action) {
|
|
|
115
128
|
const CORE_VOCABULARY_SPELLINGS = [
|
|
116
129
|
"thread-list", "channel-list", "channel-info", "member-info", "download-file",
|
|
117
130
|
"channel-create", "addParticipant", "kick", "role-add", "role-remove",
|
|
131
|
+
"edit",
|
|
118
132
|
];
|
|
119
133
|
exports.CORE_ACTION_SYNONYMS = Object.fromEntries(CORE_VOCABULARY_SPELLINGS.map((name) => [name, ACTION_ALIASES[name]]));
|
|
120
134
|
/** Canonical actions that go through the chat-management gate. */
|
package/dist/attachments.js
CHANGED
|
@@ -15,6 +15,7 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
15
15
|
const node_path_1 = __importDefault(require("node:path"));
|
|
16
16
|
const node_fs_1 = require("node:fs");
|
|
17
17
|
const media_1 = require("./media");
|
|
18
|
+
const docx_text_1 = require("./docx-text");
|
|
18
19
|
const state_dir_1 = require("./state-dir");
|
|
19
20
|
/** Attachments above this are left unread: a long recording or a huge image is
|
|
20
21
|
* a different conversation from a spoken line or a screenshot, and the
|
|
@@ -46,6 +47,16 @@ function resolveAgentDirForMedia(cfg) {
|
|
|
46
47
|
* become two different readings because two call sites drifted.
|
|
47
48
|
*/
|
|
48
49
|
async function understandAttachmentFile(params) {
|
|
50
|
+
if (params.understanding === "document") {
|
|
51
|
+
const { readFile } = await import("node:fs/promises");
|
|
52
|
+
const input = await readFile(params.filePath);
|
|
53
|
+
return (0, docx_text_1.isDocxDocument)(params.mimeType, params.fileName)
|
|
54
|
+
? (0, docx_text_1.extractDocxText)(input)
|
|
55
|
+
: (0, docx_text_1.extractPlainText)(input);
|
|
56
|
+
}
|
|
57
|
+
if (params.understanding === "pdf") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
49
60
|
const media = params.runtime?.mediaUnderstanding;
|
|
50
61
|
if (!media)
|
|
51
62
|
return undefined;
|
|
@@ -98,6 +109,7 @@ async function readInboundAttachment(params) {
|
|
|
98
109
|
filePath: downloaded.path,
|
|
99
110
|
mimeType: downloaded.mimeType,
|
|
100
111
|
understanding: downloaded.understanding,
|
|
112
|
+
fileName: undefined,
|
|
101
113
|
});
|
|
102
114
|
if (!read) {
|
|
103
115
|
params.log?.info?.("clawgram attachment read empty", {
|
package/dist/channel.js
CHANGED
|
@@ -148,7 +148,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
148
148
|
"Use the `thread-list` 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. Name the chat with `chatId` and do not pass `target` — core refuses it for this action. `topics` is the same call under a name core does not know, and is only reachable through the gateway RPC.",
|
|
149
149
|
"Name the chat for `read` with `target`, never `chatId`: `read` is in core's own vocabulary, so core resolves the destination itself and reads only `to`/`target` — `chatId` is silently ignored and the call is refused as targetless. The chat-shaped reads next to it (`thread-list`, `channel-info`, `member-info`) are the opposite, because core does not know them; that asymmetry is core's, not a typo, and it cost 745 refused reads in the week before 2026-09-04.",
|
|
150
150
|
"Pass that `threadId` to `read` as well: without it a forum read returns every topic interleaved rather than the one that was asked about.",
|
|
151
|
-
"Use the `download-file` action to fetch the attachment on a message `read` reported. Name the chat with `chatId` and the message with `messageId`; do not pass `target` — core refuses it for this action:
|
|
151
|
+
"Use the `download-file` action to fetch the attachment on a message `read` reported. Name the chat with `chatId` and the message with `messageId`; do not pass `target` — core refuses it for this action: images and audio return a description or transcript; DOCX and UTF-8 text documents (.txt, .md, .csv, .json, YAML, XML, HTML, RTF) return text; PDFs return a retained `filePath` for the PDF tool. `mode: \"file\"` returns a path to reuse and `\"both\"` (default) returns both. `read` only says an attachment exists; this is what brings it.",
|
|
152
152
|
"Use the `channel-list` 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`.",
|
|
153
153
|
"Use `member-info` with a `chatId` to list who is in a chat, and `kick` with a `chatId` and `userId` to remove someone from a managed chat. The rest of the chat-management family and `joins` have no name core knows, so they are reachable only through the gateway RPC, not from this tool.",
|
|
154
154
|
"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.",
|
|
@@ -160,7 +160,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
160
160
|
"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.",
|
|
161
161
|
"clawgram can describe a chat via `channel-info`: title, type (direct/group/supergroup/channel), member count, description, whether it is a forum, and the pinned message id.",
|
|
162
162
|
"clawgram can list the topics of a forum supergroup via `thread-list`: id, title, last message, and whether a topic is closed, hidden or pinned.",
|
|
163
|
-
"clawgram can fetch
|
|
163
|
+
"clawgram can fetch an explicitly named attachment inside its read scope via `download-file`: images come back described, voice notes transcribed, DOCX and UTF-8 text documents as text, and PDFs as a private file path for the PDF tool.",
|
|
164
164
|
"clawgram can list the group chats the account belongs to via `channel-list`, when the account sets discoverChats. Metadata only, no direct chats — it answers \"where am I\", not \"what was said\".",
|
|
165
165
|
"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.",
|
|
166
166
|
],
|
|
@@ -454,6 +454,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
454
454
|
// one. `joins` likewise.
|
|
455
455
|
actions: [
|
|
456
456
|
"send", "read", "react", "upload-file",
|
|
457
|
+
// Rewriting a message this account already sent (2.29.0). Core
|
|
458
|
+
// knows the name, so it needs no synonym — unlike the reads below.
|
|
459
|
+
"edit",
|
|
457
460
|
// Reading an attachment that is already in a chat. `read` reports
|
|
458
461
|
// that a photo exists; this is what turns it into something the
|
|
459
462
|
// agent can look at or pass on.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractDocxText = extractDocxText;
|
|
4
|
+
exports.isDocxDocument = isDocxDocument;
|
|
5
|
+
exports.isTextDocument = isTextDocument;
|
|
6
|
+
exports.extractPlainText = extractPlainText;
|
|
7
|
+
const node_zlib_1 = require("node:zlib");
|
|
8
|
+
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
9
|
+
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
10
|
+
const LOCAL_FILE_SIGNATURE = 0x04034b50;
|
|
11
|
+
const WORD_DOCUMENT_PATH = "word/document.xml";
|
|
12
|
+
const MAX_DOCUMENT_XML_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const TEXT_DOCUMENT_EXTENSIONS = new Set([
|
|
14
|
+
"txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "yaml", "yml",
|
|
15
|
+
"toml", "ini", "cfg", "conf", "xml", "html", "htm", "log", "rtf",
|
|
16
|
+
]);
|
|
17
|
+
function findEndOfCentralDirectory(input) {
|
|
18
|
+
// The optional ZIP comment is at most 65,535 bytes, so scanning this tail
|
|
19
|
+
// avoids treating a matching four-byte sequence in compressed data as a
|
|
20
|
+
// directory record.
|
|
21
|
+
const start = Math.max(0, input.length - 65_557);
|
|
22
|
+
for (let offset = input.length - 22; offset >= start; offset -= 1) {
|
|
23
|
+
if (input.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
24
|
+
return offset;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
28
|
+
}
|
|
29
|
+
function wordDocumentEntry(input) {
|
|
30
|
+
if (input.length < 22) {
|
|
31
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
32
|
+
}
|
|
33
|
+
const end = findEndOfCentralDirectory(input);
|
|
34
|
+
const entryCount = input.readUInt16LE(end + 10);
|
|
35
|
+
let offset = input.readUInt32LE(end + 16);
|
|
36
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
37
|
+
if (offset + 46 > input.length || input.readUInt32LE(offset) !== CENTRAL_DIRECTORY_SIGNATURE) {
|
|
38
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
39
|
+
}
|
|
40
|
+
const flags = input.readUInt16LE(offset + 8);
|
|
41
|
+
const compressionMethod = input.readUInt16LE(offset + 10);
|
|
42
|
+
const compressedSize = input.readUInt32LE(offset + 20);
|
|
43
|
+
const uncompressedSize = input.readUInt32LE(offset + 24);
|
|
44
|
+
const nameLength = input.readUInt16LE(offset + 28);
|
|
45
|
+
const extraLength = input.readUInt16LE(offset + 30);
|
|
46
|
+
const commentLength = input.readUInt16LE(offset + 32);
|
|
47
|
+
const localHeaderOffset = input.readUInt32LE(offset + 42);
|
|
48
|
+
const nameEnd = offset + 46 + nameLength;
|
|
49
|
+
if (nameEnd > input.length) {
|
|
50
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
51
|
+
}
|
|
52
|
+
const name = input.subarray(offset + 46, nameEnd).toString("utf8");
|
|
53
|
+
if (name === WORD_DOCUMENT_PATH) {
|
|
54
|
+
if ((flags & 0x1) !== 0) {
|
|
55
|
+
throw new Error("clawgram: encrypted DOCX attachments are not supported");
|
|
56
|
+
}
|
|
57
|
+
return { compressionMethod, compressedSize, uncompressedSize, localHeaderOffset };
|
|
58
|
+
}
|
|
59
|
+
offset = nameEnd + extraLength + commentLength;
|
|
60
|
+
}
|
|
61
|
+
throw new Error("clawgram: DOCX has no word/document.xml");
|
|
62
|
+
}
|
|
63
|
+
function decodeXmlText(value) {
|
|
64
|
+
return value
|
|
65
|
+
.replaceAll("&", "&")
|
|
66
|
+
.replaceAll("<", "<")
|
|
67
|
+
.replaceAll(">", ">")
|
|
68
|
+
.replaceAll(""", '"')
|
|
69
|
+
.replaceAll("'", "'")
|
|
70
|
+
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_whole, source) => {
|
|
71
|
+
const codePoint = String(source).toLowerCase().startsWith("x")
|
|
72
|
+
? Number.parseInt(String(source).slice(1), 16)
|
|
73
|
+
: Number.parseInt(String(source), 10);
|
|
74
|
+
const valid = Number.isInteger(codePoint)
|
|
75
|
+
&& codePoint >= 0
|
|
76
|
+
&& codePoint <= 0x10ffff
|
|
77
|
+
&& (codePoint < 0xd800 || codePoint > 0xdfff);
|
|
78
|
+
return valid ? String.fromCodePoint(codePoint) : "";
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function textFromWordXml(xml) {
|
|
82
|
+
const withBreaks = xml
|
|
83
|
+
.replace(/<w:tab\b[^>]*\/>/gi, "\t")
|
|
84
|
+
.replace(/<w:br\b[^>]*\/>/gi, "\n")
|
|
85
|
+
.replace(/<w:cr\b[^>]*\/>/gi, "\n")
|
|
86
|
+
.replace(/<\/w:p>/gi, "\n")
|
|
87
|
+
.replace(/<[^>]*>/g, "");
|
|
88
|
+
return decodeXmlText(withBreaks)
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join("\n")
|
|
93
|
+
.trim();
|
|
94
|
+
}
|
|
95
|
+
/** Extracts plain text from the DOCX part the user explicitly asked to read. */
|
|
96
|
+
function extractDocxText(input) {
|
|
97
|
+
const entry = wordDocumentEntry(input);
|
|
98
|
+
if (entry.uncompressedSize > MAX_DOCUMENT_XML_BYTES) {
|
|
99
|
+
throw new Error("clawgram: DOCX text is too large to read");
|
|
100
|
+
}
|
|
101
|
+
const local = entry.localHeaderOffset;
|
|
102
|
+
if (local + 30 > input.length || input.readUInt32LE(local) !== LOCAL_FILE_SIGNATURE) {
|
|
103
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
104
|
+
}
|
|
105
|
+
const nameLength = input.readUInt16LE(local + 26);
|
|
106
|
+
const extraLength = input.readUInt16LE(local + 28);
|
|
107
|
+
const start = local + 30 + nameLength + extraLength;
|
|
108
|
+
const end = start + entry.compressedSize;
|
|
109
|
+
if (start > input.length || end > input.length) {
|
|
110
|
+
throw new Error("clawgram: attachment is not a valid DOCX zip");
|
|
111
|
+
}
|
|
112
|
+
const compressed = input.subarray(start, end);
|
|
113
|
+
const xml = entry.compressionMethod === 0
|
|
114
|
+
? compressed
|
|
115
|
+
: entry.compressionMethod === 8
|
|
116
|
+
? (0, node_zlib_1.inflateRawSync)(compressed, { maxOutputLength: MAX_DOCUMENT_XML_BYTES })
|
|
117
|
+
: undefined;
|
|
118
|
+
if (!xml) {
|
|
119
|
+
throw new Error(`clawgram: DOCX compression method ${entry.compressionMethod} is not supported`);
|
|
120
|
+
}
|
|
121
|
+
return textFromWordXml(xml.toString("utf8"));
|
|
122
|
+
}
|
|
123
|
+
function isDocxDocument(mimeType, fileName) {
|
|
124
|
+
return mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
125
|
+
|| fileName?.toLowerCase().endsWith(".docx") === true;
|
|
126
|
+
}
|
|
127
|
+
function isTextDocument(mimeType, fileName) {
|
|
128
|
+
if (mimeType?.startsWith("text/"))
|
|
129
|
+
return true;
|
|
130
|
+
const extension = fileName?.trim().split(".").pop()?.toLowerCase();
|
|
131
|
+
return extension ? TEXT_DOCUMENT_EXTENSIONS.has(extension) : false;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Plain-text attachments are kept as UTF-8. They are never silently decoded
|
|
135
|
+
* as binary data, because replacement glyphs hide a wrong file type from the
|
|
136
|
+
* agent and make its answer look trustworthy when it is not.
|
|
137
|
+
*/
|
|
138
|
+
function extractPlainText(input) {
|
|
139
|
+
if (input.length > MAX_DOCUMENT_XML_BYTES) {
|
|
140
|
+
throw new Error("clawgram: text document is too large to read");
|
|
141
|
+
}
|
|
142
|
+
if (input.includes(0)) {
|
|
143
|
+
throw new Error("clawgram: attachment is binary, not a text document");
|
|
144
|
+
}
|
|
145
|
+
let text;
|
|
146
|
+
try {
|
|
147
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(input);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error("clawgram: text attachment is not valid UTF-8");
|
|
151
|
+
}
|
|
152
|
+
return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim();
|
|
153
|
+
}
|
package/dist/edits.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Editing a message already sent — the `edit` action of OpenClaw's message
|
|
4
|
+
* tool.
|
|
5
|
+
*
|
|
6
|
+
* Telegram lets an account rewrite its own message in place, and until 2.29.0
|
|
7
|
+
* this channel had no way to ask for it: a wrong answer could only be followed
|
|
8
|
+
* by a second message correcting the first, which leaves both standing in the
|
|
9
|
+
* chat. `edit` is in core's `CHANNEL_MESSAGE_ACTION_NAMES`, so the name was
|
|
10
|
+
* reachable from the tool the whole time and simply arrived at a channel that
|
|
11
|
+
* refused it.
|
|
12
|
+
*
|
|
13
|
+
* Parsing lives here, apart from the network, so the argument handling can be
|
|
14
|
+
* tested without a Telegram connection — the same split as `reactions.ts`.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.parseEditParams = parseEditParams;
|
|
18
|
+
const helpers_1 = require("./helpers");
|
|
19
|
+
const history_1 = require("./history");
|
|
20
|
+
function parseEditParams(params, toolContext) {
|
|
21
|
+
const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
|
|
22
|
+
const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
|
|
23
|
+
if (!target) {
|
|
24
|
+
throw new Error("clawgram: edit requires a chatId");
|
|
25
|
+
}
|
|
26
|
+
// No fall-back to `currentMessageId`, and that is the difference from
|
|
27
|
+
// `react`. The current message is the one being answered — someone else's.
|
|
28
|
+
// Reacting to it is the normal case; editing it is never a thing this
|
|
29
|
+
// account may do, so a missing id is a caller mistake and not an invitation
|
|
30
|
+
// to guess. Telegram would refuse it, but the refusal would arrive as
|
|
31
|
+
// MESSAGE_AUTHOR_REQUIRED on a call nobody meant to make.
|
|
32
|
+
const messageId = (0, history_1.parseMessageId)(params.messageId ?? params.msgId ?? params.message_id, "edit messageId");
|
|
33
|
+
if (messageId === undefined) {
|
|
34
|
+
throw new Error("clawgram: edit requires a messageId — the id of the message to rewrite");
|
|
35
|
+
}
|
|
36
|
+
// `readMessageText` reads `message` then `text`, exactly as `send` does, so
|
|
37
|
+
// the same call shape works for both actions.
|
|
38
|
+
const text = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
|
|
39
|
+
if (!text.trim()) {
|
|
40
|
+
// Telegram has no "edit to nothing": an empty edit is refused server-side,
|
|
41
|
+
// and a caller who meant to remove the message asked for the wrong action.
|
|
42
|
+
// Saying so here beats an MTProto error about message content.
|
|
43
|
+
throw new Error("clawgram: edit requires the new text — an empty edit is not a deletion");
|
|
44
|
+
}
|
|
45
|
+
return { target, messageId, text };
|
|
46
|
+
}
|
package/dist/gramjs-client.js
CHANGED
|
@@ -427,6 +427,36 @@ class GramJsClientManager {
|
|
|
427
427
|
...replyParams,
|
|
428
428
|
});
|
|
429
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Rewrites a message this account already sent.
|
|
432
|
+
*
|
|
433
|
+
* Rendering mirrors `sendText` deliberately: an edit that parsed its text
|
|
434
|
+
* differently from the send would change the formatting of a message nobody
|
|
435
|
+
* asked to reformat — markdown shipped as literal asterisks is exactly the
|
|
436
|
+
* failure that made `sendText` render HTML itself.
|
|
437
|
+
*
|
|
438
|
+
* No chunking here, unlike `sendText`. A send too long for Telegram becomes
|
|
439
|
+
* several messages; an edit cannot — there is one message to rewrite. Over
|
|
440
|
+
* the limit the call fails, and that is the honest outcome.
|
|
441
|
+
*
|
|
442
|
+
* Telegram's own refusals are left to reach the caller as they are:
|
|
443
|
+
* MESSAGE_AUTHOR_REQUIRED (not this account's message),
|
|
444
|
+
* MESSAGE_EDIT_TIME_EXPIRED (past the window Telegram allows) and
|
|
445
|
+
* MESSAGE_NOT_MODIFIED (the new text equals the old one). None of them is
|
|
446
|
+
* retryable, and dressing them up would hide which one happened.
|
|
447
|
+
*/
|
|
448
|
+
async editText(args) {
|
|
449
|
+
const resolved = await this.resolvePeer(args.target);
|
|
450
|
+
return this.client.editMessage(resolved.peer, {
|
|
451
|
+
message: args.messageId,
|
|
452
|
+
text: args.parseMode === "html" ? (0, html_render_1.renderTelegramHtml)(args.text) : args.text,
|
|
453
|
+
...(args.parseMode === "none"
|
|
454
|
+
? { parseMode: false }
|
|
455
|
+
: args.parseMode
|
|
456
|
+
? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
|
|
457
|
+
: {}),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
430
460
|
/**
|
|
431
461
|
* Reads what a chat is: title, type, member count, description, pinned
|
|
432
462
|
* message.
|
package/dist/media.js
CHANGED
|
@@ -17,6 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.describeMedia = describeMedia;
|
|
19
19
|
exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
|
|
20
|
+
exports.fetchMediaUnderstanding = fetchMediaUnderstanding;
|
|
20
21
|
exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
|
|
21
22
|
exports.describePrivateDirProblem = describePrivateDirProblem;
|
|
22
23
|
exports.ensurePrivateDir = ensurePrivateDir;
|
|
@@ -125,6 +126,26 @@ function inboundMediaUnderstanding(media) {
|
|
|
125
126
|
return "description";
|
|
126
127
|
return undefined;
|
|
127
128
|
}
|
|
129
|
+
/** A document is downloaded only after the agent explicitly names its message. */
|
|
130
|
+
function fetchMediaUnderstanding(media) {
|
|
131
|
+
const inbound = inboundMediaUnderstanding(media);
|
|
132
|
+
if (inbound)
|
|
133
|
+
return inbound;
|
|
134
|
+
const fileName = media?.fileName?.toLowerCase() ?? "";
|
|
135
|
+
if (media?.kind === "document" && (media.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
136
|
+
|| fileName.endsWith(".docx"))) {
|
|
137
|
+
return "document";
|
|
138
|
+
}
|
|
139
|
+
if (media?.kind === "document" && (media.mimeType === "application/pdf" || fileName.endsWith(".pdf"))) {
|
|
140
|
+
return "pdf";
|
|
141
|
+
}
|
|
142
|
+
if (media?.kind === "document" && (media.mimeType?.startsWith("text/")
|
|
143
|
+
|| ["txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "yaml", "yml", "toml", "ini", "cfg", "conf", "xml", "html", "htm", "log", "rtf"]
|
|
144
|
+
.some((extension) => fileName.endsWith(`.${extension}`)))) {
|
|
145
|
+
return "document";
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
128
149
|
/**
|
|
129
150
|
* Downloads an inbound attachment to a temporary file.
|
|
130
151
|
*
|
|
@@ -147,13 +168,24 @@ async function downloadInboundMediaToTempFile(params) {
|
|
|
147
168
|
return undefined;
|
|
148
169
|
}
|
|
149
170
|
const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
|
|
150
|
-
|
|
171
|
+
const downloaded = await downloadMessageMediaToFile({
|
|
151
172
|
client: params.client,
|
|
152
173
|
message: params.message,
|
|
153
174
|
maxBytes: params.maxBytes,
|
|
154
175
|
dir,
|
|
155
176
|
fileNameFor: ({ extension }) => `attachment.${extension}`,
|
|
156
177
|
});
|
|
178
|
+
// This path asks only for `inboundMediaUnderstanding`, so a document cannot
|
|
179
|
+
// get here. Keep the runtime guard as the public downloader also serves the
|
|
180
|
+
// explicit `fetch-media` action, which is allowed to request DOCX files.
|
|
181
|
+
if (!downloaded || downloaded.understanding === "document" || downloaded.understanding === "pdf") {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
path: downloaded.path,
|
|
186
|
+
mimeType: downloaded.mimeType,
|
|
187
|
+
understanding: downloaded.understanding,
|
|
188
|
+
};
|
|
157
189
|
}
|
|
158
190
|
/**
|
|
159
191
|
* Why a directory that merely exists is not good enough.
|
|
@@ -238,7 +270,7 @@ async function ensurePrivateDir(dir) {
|
|
|
238
270
|
*/
|
|
239
271
|
async function downloadMessageMediaToFile(params) {
|
|
240
272
|
const described = describeMedia(params.message?.media);
|
|
241
|
-
const understanding = inboundMediaUnderstanding(described);
|
|
273
|
+
const understanding = params.understanding ?? inboundMediaUnderstanding(described);
|
|
242
274
|
if (!described || !understanding) {
|
|
243
275
|
return undefined;
|
|
244
276
|
}
|
|
@@ -316,6 +348,11 @@ function extensionFor(media, understanding) {
|
|
|
316
348
|
return "mp3";
|
|
317
349
|
if (media.mimeType === "audio/mp4")
|
|
318
350
|
return "m4a";
|
|
351
|
+
if (understanding === "pdf")
|
|
352
|
+
return "pdf";
|
|
353
|
+
if (understanding === "document") {
|
|
354
|
+
return media.mimeType?.startsWith("text/") ? "txt" : "docx";
|
|
355
|
+
}
|
|
319
356
|
return "ogg";
|
|
320
357
|
}
|
|
321
358
|
/**
|
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.29.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -468,7 +468,7 @@
|
|
|
468
468
|
"items": {
|
|
469
469
|
"type": "string"
|
|
470
470
|
},
|
|
471
|
-
"description": "Chat ids the account may SEND to (2.18.0): send, upload-file, react and core's delivery path. Same shape as readChats — absent means no restriction, [] denies everything, [\"*\"] allows every chat. A phone number is refused in every configuration, wildcard included: messaging a raw number starts a conversation with someone who never interacted with the account. Without this list an injected turn can message strangers from the owner's personal account or carry a work chat's content into a DM one send at a time."
|
|
471
|
+
"description": "Chat ids the account may SEND to (2.18.0): send, upload-file, react, edit and core's delivery path. Same shape as readChats — absent means no restriction, [] denies everything, [\"*\"] allows every chat. A phone number is refused in every configuration, wildcard included: messaging a raw number starts a conversation with someone who never interacted with the account. Without this list an injected turn can message strangers from the owner's personal account or carry a work chat's content into a DM one send at a time."
|
|
472
472
|
},
|
|
473
473
|
"manageChats": {
|
|
474
474
|
"type": "array",
|
package/package.json
CHANGED