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.
- package/dist/actions-read.js +21 -13
- package/dist/attachments.js +12 -0
- package/dist/channel.js +2 -2
- package/dist/docx-text.js +153 -0
- package/dist/media.js +116 -5
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/actions-read.js
CHANGED
|
@@ -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
|
-
// этом хосте живёт ещё и раннер
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
/**
|
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
|
],
|
|
@@ -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/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
|
-
|
|
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 {
|
|
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
|
|
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
|
/**
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawgram",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
9
|
+
"version": "2.28.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"json5": "2.2.3",
|
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.28.1",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED