clawgram 2.27.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.
@@ -119,29 +119,35 @@ async function handleReadAction(ctx) {
119
119
  // that path would pull the file out from under an earlier `both`
120
120
  // fetch of the same message that handed the caller a path.
121
121
  // Не общий /tmp: там файлы видит каждый локальный пользователь, а на
122
- // этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
123
- // принадлежит агенту; если он не задан, остаётся /tmp — но права
124
- // 0700/0600 ставятся в любом случае (A5-13).
125
- // Каталог состояния принадлежит агенту; при явно заданном
126
- // OPENCLAW_STATE_DIR вложения не покидают его.
122
+ // этом хосте живёт ещё и раннер деплоя (A5-13). При заданном
123
+ // OPENCLAW_STATE_DIR вложения не покидают каталог состояния; иначе
124
+ // корень лежит в /tmp и несёт uid процесса в имени.
125
+ //
126
+ // Прежде это был `/tmp/clawgram-fetched` — постоянное имя в каталоге,
127
+ // который делят все пользователи хоста. Когда агент переехал на свою
128
+ // учётку, имя осталось занято каталогом прежней, и каждая картинка с
129
+ // 05.09 по 07.09.2026 падала с `EACCES`. Имя с uid разводит учётки, а
130
+ // `ensurePrivateDir` проверяет, что каталог действительно наш и закрыт.
127
131
  const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
128
132
  ? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
129
- : node_os_1.default.tmpdir();
133
+ : node_path_1.default.join(node_os_1.default.tmpdir(), `clawgram-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
134
+ await (0, media_1.ensurePrivateDir)(mediaRoot);
130
135
  const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
131
136
  let fetchDir = sharedFetchDir;
132
137
  if (fetchParams.mode === "read") {
133
- const { mkdtemp, mkdir } = await import("node:fs/promises");
134
- await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
138
+ const { mkdtemp } = await import("node:fs/promises");
135
139
  fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
136
140
  }
137
141
  else {
138
142
  await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
139
143
  }
144
+ const described = (0, media_1.describeMedia)(found.message?.media);
140
145
  const downloaded = await (0, media_1.downloadMessageMediaToFile)({
141
146
  client: fetchGram.getClient(),
142
147
  message: found.message,
143
148
  maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
144
149
  dir: fetchDir,
150
+ understanding: (0, media_1.fetchMediaUnderstanding)(described),
145
151
  fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
146
152
  chatId: fetchChatId,
147
153
  messageId: fetchParams.messageId,
@@ -155,7 +161,6 @@ async function handleReadAction(ctx) {
155
161
  // channel does not read (a video, a spreadsheet), and one too
156
162
  // large to be worth the transfer. Saying "could not fetch" to all
157
163
  // three is how "she ignored the picture" starts.
158
- const described = (0, media_1.describeMedia)(found.message?.media);
159
164
  const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
160
165
  const error = !described
161
166
  ? "no-media"
@@ -188,6 +193,7 @@ async function handleReadAction(ctx) {
188
193
  filePath: downloaded.path,
189
194
  mimeType: downloaded.mimeType,
190
195
  understanding: downloaded.understanding,
196
+ fileName: downloaded.media.fileName,
191
197
  });
192
198
  if (!read) {
193
199
  readError = "read-empty";
@@ -203,7 +209,8 @@ async function handleReadAction(ctx) {
203
209
  // `read` mode is the inbound contract — the words, not the file — so
204
210
  // the bytes go away with the answer. Any other mode keeps them:
205
211
  // that is the whole point of asking for a path.
206
- if (fetchParams.mode === "read") {
212
+ const pdfNeedsFile = downloaded.understanding === "pdf";
213
+ if (fetchParams.mode === "read" && !pdfNeedsFile) {
207
214
  try {
208
215
  const { rm } = await import("node:fs/promises");
209
216
  await rm(fetchDir, { recursive: true, force: true });
@@ -213,6 +220,7 @@ async function handleReadAction(ctx) {
213
220
  // over it would throw away a reading that already succeeded.
214
221
  }
215
222
  }
223
+ const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
216
224
  actionLog.info("clawgram fetch-media completed", {
217
225
  accountId: fetchAccountId,
218
226
  chatId: fetchChatId,
@@ -221,7 +229,7 @@ async function handleReadAction(ctx) {
221
229
  kind: downloaded.media.kind,
222
230
  understanding: downloaded.understanding,
223
231
  characters: read?.length ?? 0,
224
- readError: readError ?? null,
232
+ readError: finalReadError ?? null,
225
233
  });
226
234
  return (0, core_1.jsonResult)({
227
235
  ok: true,
@@ -231,9 +239,9 @@ async function handleReadAction(ctx) {
231
239
  mode: fetchParams.mode,
232
240
  media: downloaded.media,
233
241
  understanding: downloaded.understanding,
234
- filePath: fetchParams.mode === "read" ? undefined : downloaded.path,
242
+ filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
235
243
  text: read,
236
- readError,
244
+ readError: finalReadError,
237
245
  });
238
246
  }
239
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,7 +17,10 @@ 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;
22
+ exports.describePrivateDirProblem = describePrivateDirProblem;
23
+ exports.ensurePrivateDir = ensurePrivateDir;
21
24
  exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
22
25
  exports.pruneFetchedMedia = pruneFetchedMedia;
23
26
  exports.isLocalMediaPath = isLocalMediaPath;
@@ -123,6 +126,26 @@ function inboundMediaUnderstanding(media) {
123
126
  return "description";
124
127
  return undefined;
125
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
+ }
126
149
  /**
127
150
  * Downloads an inbound attachment to a temporary file.
128
151
  *
@@ -145,13 +168,97 @@ async function downloadInboundMediaToTempFile(params) {
145
168
  return undefined;
146
169
  }
147
170
  const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
148
- return downloadMessageMediaToFile({
171
+ const downloaded = await downloadMessageMediaToFile({
149
172
  client: params.client,
150
173
  message: params.message,
151
174
  maxBytes: params.maxBytes,
152
175
  dir,
153
176
  fileNameFor: ({ extension }) => `attachment.${extension}`,
154
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
+ };
189
+ }
190
+ /**
191
+ * Why a directory that merely exists is not good enough.
192
+ *
193
+ * `mkdir(..., { recursive: true })` is a no-op on an existing directory and
194
+ * `chmod` on a directory owned by somebody else fails — and that failure used
195
+ * to be swallowed. The write then hit `EACCES` and the agent was handed the
196
+ * bare errno with nothing to act on.
197
+ *
198
+ * That is not hypothetical: the shared fetch directory had a fixed name in
199
+ * world-writable `/tmp`, and when the agent moved to its own account
200
+ * (04.09.2026) the old account's leftover kept the name. Every picture sent
201
+ * to the agent failed from 05.09 to 07.09 with
202
+ * `EACCES: permission denied, open '/tmp/clawgram-fetched/…'`, and the agent
203
+ * told its owner its "disk access was not restored" — the closest reading it
204
+ * could make of an errno.
205
+ *
206
+ * The same shape is a way in, not only an accident: any local user (this host
207
+ * also runs a deploy runner) could pre-create that predictable path and read
208
+ * every attachment written into it. So the check is ownership and mode, not
209
+ * existence.
210
+ */
211
+ function describePrivateDirProblem(input) {
212
+ if (input.isSymbolicLink) {
213
+ return `clawgram: ${input.path} is a symlink — refusing to write attachments through it`;
214
+ }
215
+ if (!input.isDirectory) {
216
+ return `clawgram: ${input.path} exists and is not a directory`;
217
+ }
218
+ if (input.selfUid !== undefined && input.uid !== input.selfUid) {
219
+ return `clawgram: ${input.path} belongs to uid ${input.uid}, this process runs as ${input.selfUid}`
220
+ + " — a leftover from another account is holding the path; remove it or give it to this account";
221
+ }
222
+ const bits = input.mode & 0o777;
223
+ if ((bits & 0o077) !== 0) {
224
+ return `clawgram: ${input.path} is readable beyond this account (mode ${bits.toString(8)})`;
225
+ }
226
+ return undefined;
227
+ }
228
+ /**
229
+ * Creates the directory, makes it private, and proves it — see above.
230
+ */
231
+ async function ensurePrivateDir(dir) {
232
+ const { mkdir, chmod, lstat } = await import("node:fs/promises");
233
+ // `EEXIST` means something already holds the name — a file, a symlink,
234
+ // another account's directory. The check below says which, and that is
235
+ // the whole point; an errno is what the agent could not act on. Any other
236
+ // failure (no space, read-only mount) is still the caller's problem.
237
+ await mkdir(dir, { recursive: true, mode: 0o700 }).catch((err) => {
238
+ if (err?.code !== "EEXIST") {
239
+ throw err;
240
+ }
241
+ });
242
+ // Only tighten what is already our directory: `chmod` on a stray file
243
+ // would change a mode that is none of our business, and on somebody
244
+ // else's directory it fails anyway — silently, which is how this stayed
245
+ // invisible for three days.
246
+ const found = await lstat(dir);
247
+ if (found.isDirectory()) {
248
+ await chmod(dir, 0o700).catch(() => undefined);
249
+ }
250
+ const stats = await lstat(dir);
251
+ const problem = describePrivateDirProblem({
252
+ path: dir,
253
+ isDirectory: stats.isDirectory(),
254
+ isSymbolicLink: stats.isSymbolicLink(),
255
+ uid: stats.uid,
256
+ mode: stats.mode,
257
+ selfUid: typeof process.getuid === "function" ? process.getuid() : undefined,
258
+ });
259
+ if (problem) {
260
+ throw new Error(problem);
261
+ }
155
262
  }
156
263
  /**
157
264
  * Downloads an attachment into a directory the caller names and owns.
@@ -163,7 +270,7 @@ async function downloadInboundMediaToTempFile(params) {
163
270
  */
164
271
  async function downloadMessageMediaToFile(params) {
165
272
  const described = describeMedia(params.message?.media);
166
- const understanding = inboundMediaUnderstanding(described);
273
+ const understanding = params.understanding ?? inboundMediaUnderstanding(described);
167
274
  if (!described || !understanding) {
168
275
  return undefined;
169
276
  }
@@ -178,7 +285,7 @@ async function downloadMessageMediaToFile(params) {
178
285
  if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
179
286
  return undefined;
180
287
  }
181
- const { mkdir, writeFile, chmod } = await import("node:fs/promises");
288
+ const { writeFile, chmod } = await import("node:fs/promises");
182
289
  const { join } = await import("node:path");
183
290
  // Личная переписка на диске: каталог и файл принадлежат только агенту.
184
291
  // По умолчанию (umask 022) выходило 0755/0644, то есть вложения из личных
@@ -186,8 +293,7 @@ async function downloadMessageMediaToFile(params) {
186
293
  // gitlab-runner (A5-13). `mode` у mkdir и writeFile маскируется umask,
187
294
  // поэтому права выставляются отдельным chmod, как это уже делается для
188
295
  // конфига в update-config.ts.
189
- await mkdir(params.dir, { recursive: true, mode: 0o700 });
190
- await chmod(params.dir, 0o700).catch(() => undefined);
296
+ await ensurePrivateDir(params.dir);
191
297
  const extension = extensionFor(described, understanding);
192
298
  const path = join(params.dir, params.fileNameFor({ media: described, extension }));
193
299
  await writeFile(path, buffer, { mode: 0o600 });
@@ -242,6 +348,11 @@ function extensionFor(media, understanding) {
242
348
  return "mp3";
243
349
  if (media.mimeType === "audio/mp4")
244
350
  return "m4a";
351
+ if (understanding === "pdf")
352
+ return "pdf";
353
+ if (understanding === "document") {
354
+ return media.mimeType?.startsWith("text/") ? "txt" : "docx";
355
+ }
245
356
  return "ogg";
246
357
  }
247
358
  /**
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clawgram",
9
- "version": "2.27.0",
9
+ "version": "2.28.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "json5": "2.2.3",
@@ -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.27.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.27.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": {