clawgram 2.28.0 → 2.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- if (fetchParams.mode === "read") {
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: readError ?? null,
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
  /**
@@ -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: `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.",
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 the attachment on any message inside its read scope via `download-file`: images come back described, voice notes transcribed, and either can be returned as a file path for reuse.",
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
  ],
@@ -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("&amp;", "&")
66
+ .replaceAll("&lt;", "<")
67
+ .replaceAll("&gt;", ">")
68
+ .replaceAll("&quot;", '"')
69
+ .replaceAll("&apos;", "'")
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/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
- return downloadMessageMediaToFile({
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
  /**
@@ -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.28.0",
5
+ "version": "2.28.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.28.0",
3
+ "version": "2.28.1",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {