clawgram 2.29.0 → 2.29.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/README.md CHANGED
@@ -261,7 +261,7 @@ loud where it does occur.
261
261
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
262
262
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
263
263
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
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
+ | `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.22.0; core delivery covered since 2.25.0) |
265
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) |
266
266
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
267
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 |
@@ -133,116 +133,133 @@ async function handleReadAction(ctx) {
133
133
  : node_path_1.default.join(node_os_1.default.tmpdir(), `clawgram-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
134
134
  await (0, media_1.ensurePrivateDir)(mediaRoot);
135
135
  const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
136
+ const described = (0, media_1.describeMedia)(found.message?.media);
137
+ // A PDF is not read here: the agent is handed the path for its PDF tool,
138
+ // so even in `read` mode the file has to outlive the answer. It goes to
139
+ // the shared directory, which is pruned by age — a private temp directory
140
+ // is removed only by the `read` branch below, and a PDF skips that branch,
141
+ // so every PDF read used to stay on disk for good (audit r3 C2-02).
142
+ const keepsFile = fetchParams.mode !== "read" || (0, media_1.fetchMediaUnderstanding)(described) === "pdf";
136
143
  let fetchDir = sharedFetchDir;
137
- if (fetchParams.mode === "read") {
138
- const { mkdtemp } = await import("node:fs/promises");
139
- fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
144
+ if (keepsFile) {
145
+ await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
140
146
  }
141
147
  else {
142
- await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
148
+ const { mkdtemp } = await import("node:fs/promises");
149
+ fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
143
150
  }
144
- const described = (0, media_1.describeMedia)(found.message?.media);
145
- const downloaded = await (0, media_1.downloadMessageMediaToFile)({
146
- client: fetchGram.getClient(),
147
- message: found.message,
148
- maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
149
- dir: fetchDir,
150
- understanding: (0, media_1.fetchMediaUnderstanding)(described),
151
- fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
152
- chatId: fetchChatId,
153
- messageId: fetchParams.messageId,
154
- extension,
155
- fileName: media.fileName,
156
- }),
157
- });
158
- if (!downloaded) {
159
- // Three different nothings, and the agent has to be able to tell
160
- // them apart: a message with no attachment, an attachment this
161
- // channel does not read (a video, a spreadsheet), and one too
162
- // large to be worth the transfer. Saying "could not fetch" to all
163
- // three is how "she ignored the picture" starts.
164
- const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
165
- const error = !described
166
- ? "no-media"
167
- : tooLarge
168
- ? "media-too-large"
169
- : "unsupported-media";
170
- actionLog.info("clawgram fetch-media returned nothing", {
151
+ // Every way out of here removes the private temp directory — the early
152
+ // "nothing to fetch" answer and a download that throws included. Only the
153
+ // success branch used to, so a message without media, a video, or a
154
+ // failed download each left a directory behind for good (audit r3 V1-10).
155
+ try {
156
+ const downloaded = await (0, media_1.downloadMessageMediaToFile)({
157
+ client: fetchGram.getClient(),
158
+ message: found.message,
159
+ maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
160
+ dir: fetchDir,
161
+ understanding: (0, media_1.fetchMediaUnderstanding)(described),
162
+ fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
163
+ chatId: fetchChatId,
164
+ messageId: fetchParams.messageId,
165
+ extension,
166
+ fileName: media.fileName,
167
+ }),
168
+ });
169
+ if (!downloaded) {
170
+ // Three different nothings, and the agent has to be able to tell
171
+ // them apart: a message with no attachment, an attachment this
172
+ // channel does not read (a video, a spreadsheet), and one too
173
+ // large to be worth the transfer. Saying "could not fetch" to all
174
+ // three is how "she ignored the picture" starts.
175
+ const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
176
+ const error = !described
177
+ ? "no-media"
178
+ : tooLarge
179
+ ? "media-too-large"
180
+ : "unsupported-media";
181
+ actionLog.info("clawgram fetch-media returned nothing", {
182
+ accountId: fetchAccountId,
183
+ chatId: fetchChatId,
184
+ messageId: fetchParams.messageId,
185
+ kind: described?.kind ?? null,
186
+ error,
187
+ });
188
+ return (0, core_1.jsonResult)({
189
+ ok: false,
190
+ accountId: fetchAccountId,
191
+ chatId: fetchChatId,
192
+ messageId: String(fetchParams.messageId),
193
+ media: described ?? null,
194
+ error,
195
+ });
196
+ }
197
+ let read;
198
+ let readError;
199
+ if (fetchParams.mode !== "file") {
200
+ try {
201
+ read = await (0, attachments_1.understandAttachmentFile)({
202
+ runtime: pluginRuntime,
203
+ cfg,
204
+ filePath: downloaded.path,
205
+ mimeType: downloaded.mimeType,
206
+ understanding: downloaded.understanding,
207
+ fileName: downloaded.media.fileName,
208
+ });
209
+ if (!read) {
210
+ readError = "read-empty";
211
+ }
212
+ }
213
+ catch (err) {
214
+ // The bytes are already here. A failed reading is worth
215
+ // reporting, but it does not undo a successful fetch: the file
216
+ // still exists and can still be forwarded.
217
+ readError = String(err);
218
+ }
219
+ }
220
+ // `read` mode is the inbound contract — the words, not the file — so
221
+ // the bytes go away with the answer. Any other mode keeps them:
222
+ // that is the whole point of asking for a path.
223
+ const pdfNeedsFile = downloaded.understanding === "pdf";
224
+ const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
225
+ actionLog.info("clawgram fetch-media completed", {
171
226
  accountId: fetchAccountId,
172
227
  chatId: fetchChatId,
173
228
  messageId: fetchParams.messageId,
174
- kind: described?.kind ?? null,
175
- error,
229
+ mode: fetchParams.mode,
230
+ kind: downloaded.media.kind,
231
+ understanding: downloaded.understanding,
232
+ characters: read?.length ?? 0,
233
+ readError: finalReadError ?? null,
176
234
  });
177
235
  return (0, core_1.jsonResult)({
178
- ok: false,
236
+ ok: true,
179
237
  accountId: fetchAccountId,
180
238
  chatId: fetchChatId,
181
239
  messageId: String(fetchParams.messageId),
182
- media: described ?? null,
183
- error,
240
+ mode: fetchParams.mode,
241
+ media: downloaded.media,
242
+ understanding: downloaded.understanding,
243
+ filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
244
+ text: read,
245
+ readError: finalReadError,
184
246
  });
185
247
  }
186
- let read;
187
- let readError;
188
- if (fetchParams.mode !== "file") {
189
- try {
190
- read = await (0, attachments_1.understandAttachmentFile)({
191
- runtime: pluginRuntime,
192
- cfg,
193
- filePath: downloaded.path,
194
- mimeType: downloaded.mimeType,
195
- understanding: downloaded.understanding,
196
- fileName: downloaded.media.fileName,
197
- });
198
- if (!read) {
199
- readError = "read-empty";
248
+ finally {
249
+ // Only the private temp directory is ever removed here — never the
250
+ // shared one, which other fetches still point at.
251
+ if (!keepsFile) {
252
+ try {
253
+ const { rm } = await import("node:fs/promises");
254
+ await rm(fetchDir, { recursive: true, force: true });
255
+ }
256
+ catch {
257
+ // A private temp directory left behind is litter, not a leak of
258
+ // anything the agent was not already shown; failing the call over it
259
+ // would throw away a reading that already succeeded.
200
260
  }
201
- }
202
- catch (err) {
203
- // The bytes are already here. A failed reading is worth
204
- // reporting, but it does not undo a successful fetch: the file
205
- // still exists and can still be forwarded.
206
- readError = String(err);
207
- }
208
- }
209
- // `read` mode is the inbound contract — the words, not the file — so
210
- // the bytes go away with the answer. Any other mode keeps them:
211
- // that is the whole point of asking for a path.
212
- const pdfNeedsFile = downloaded.understanding === "pdf";
213
- if (fetchParams.mode === "read" && !pdfNeedsFile) {
214
- try {
215
- const { rm } = await import("node:fs/promises");
216
- await rm(fetchDir, { recursive: true, force: true });
217
- }
218
- catch {
219
- // A file left behind is pruned within a day; failing the call
220
- // over it would throw away a reading that already succeeded.
221
261
  }
222
262
  }
223
- const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
224
- actionLog.info("clawgram fetch-media completed", {
225
- accountId: fetchAccountId,
226
- chatId: fetchChatId,
227
- messageId: fetchParams.messageId,
228
- mode: fetchParams.mode,
229
- kind: downloaded.media.kind,
230
- understanding: downloaded.understanding,
231
- characters: read?.length ?? 0,
232
- readError: finalReadError ?? null,
233
- });
234
- return (0, core_1.jsonResult)({
235
- ok: true,
236
- accountId: fetchAccountId,
237
- chatId: fetchChatId,
238
- messageId: String(fetchParams.messageId),
239
- mode: fetchParams.mode,
240
- media: downloaded.media,
241
- understanding: downloaded.understanding,
242
- filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
243
- text: read,
244
- readError: finalReadError,
245
- });
246
263
  }
247
264
  /**
248
265
  * The scaffold every chat-shaped read shares.
@@ -31,7 +31,7 @@ async function handleSendAction(ctx) {
31
31
  if (!reactionAccountId) {
32
32
  throw new Error("clawgram: no configured account found");
33
33
  }
34
- // Реакция — видимое действие от имени владельца в чужом чате, и
34
+ // Реакция — видимое действие от имени этой учётки в чужом чате, и
35
35
  // адресуется она так же, как сообщение: та же область (A5-12).
36
36
  if (!(0, send_scope_1.isChatSendable)(reactionParams.target, (0, account_scopes_1.resolveAccountSendChats)(cfg, reactionAccountId))) {
37
37
  (0, account_scopes_1.refuseOutboundOutsideScope)("react", reactionAccountId, String(reactionParams.target));
@@ -82,7 +82,7 @@ function buildGroupReplyAddress(input) {
82
82
  // No handle and no name means no greeting, not a numeric one. The id
83
83
  // used to be the fallback, and in a basic group — where GramJS attaches
84
84
  // no sender profile to the message — every reply of a day opened with
85
- // «890975818, …» (07.09.2026). A person does not know their own
85
+ // «<numeric id>, …» (07.09.2026). A person does not know their own
86
86
  // telegram id and reads it as a malfunction.
87
87
  return undefined;
88
88
  }
@@ -499,7 +499,7 @@ async function handleInboundEvent(event, ctx) {
499
499
  // and in a basic group (or after a restart) that cache is empty: the
500
500
  // turn then had nothing but the numeric id, the reply greeting fell
501
501
  // back to it, and a management chat spent 07.09.2026 being addressed
502
- // as «890975818, …». One source of truth for the name: the address
502
+ // as «<numeric id>, …». One source of truth for the name: the address
503
503
  // the channel would prepend is also what the agent reads, so the
504
504
  // model cannot greet «Вася Ш.» while the channel greets «@vasya».
505
505
  await resolveNamelessSender(normalized, rawMessage, client);
package/dist/media.js CHANGED
@@ -28,6 +28,7 @@ exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
28
28
  exports.loadOutboundMedia = loadOutboundMedia;
29
29
  const node_fs_1 = require("node:fs");
30
30
  const node_path_1 = __importDefault(require("node:path"));
31
+ const docx_text_1 = require("./docx-text");
31
32
  const util_1 = require("./util");
32
33
  /**
33
34
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
@@ -131,19 +132,17 @@ function fetchMediaUnderstanding(media) {
131
132
  const inbound = inboundMediaUnderstanding(media);
132
133
  if (inbound)
133
134
  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"))) {
135
+ if (media?.kind !== "document")
136
+ return undefined;
137
+ // One definition of "a document we can read": the same predicates decide
138
+ // how it is read in `understandAttachmentFile`, so a format added there
139
+ // cannot be downloaded here and then decoded as the wrong thing (r3 C2-03).
140
+ if ((0, docx_text_1.isDocxDocument)(media.mimeType, media.fileName))
137
141
  return "document";
138
- }
139
- if (media?.kind === "document" && (media.mimeType === "application/pdf" || fileName.endsWith(".pdf"))) {
142
+ if (media.mimeType === "application/pdf" || media.fileName?.toLowerCase().endsWith(".pdf"))
140
143
  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}`)))) {
144
+ if ((0, docx_text_1.isTextDocument)(media.mimeType, media.fileName))
145
145
  return "document";
146
- }
147
146
  return undefined;
148
147
  }
149
148
  /**
package/dist/outbound.js CHANGED
@@ -212,7 +212,7 @@ function createOutbound(runtimes) {
212
212
  }
213
213
  // Молчаливый ответ: подпись с токеном молчания означает «ничего не
214
214
  // говорить», и отправлять файл с ним в подписи — тем более.
215
- const mediaCaption = ctx.caption ?? ctx.text;
215
+ let mediaCaption = ctx.caption ?? ctx.text;
216
216
  if (mediaCaption?.trim() && (0, helpers_1.isSilentReplyText)(mediaCaption)) {
217
217
  actionLog.info("clawgram suppressing silent outbound media", {
218
218
  accountId: ctx.accountId,
@@ -227,12 +227,30 @@ function createOutbound(runtimes) {
227
227
  chatId: ctx.to,
228
228
  replyToId: ctx.replyToId,
229
229
  });
230
- // Чего здесь НЕТ намеренно:
231
- // — подавление эха хода (`hadTurnSendJustNow`): у текста дубль стоит
232
- // лишнего сообщения, а у медиа отказ стоит потерянного файла —
233
- // картинку агент готовил, и второй раз она не появится;
234
- // — подавление служебных сообщений ядра в группах: они текстовые,
235
- // медиа-доставка ими не бывает.
230
+ // Служебное уведомление ядра, склеенное с payload, уходило подписью:
231
+ // фильтр телеметрии стоял на трёх текстовых дверях и не стоял здесь
232
+ // (аудит r3 C0-11, «чинить каждый путь»). Файл при этом не теряется —
233
+ // уходит без подписи: картинку агент готовил, второй раз её не будет.
234
+ const captionNotice = mediaCaption?.trim()
235
+ ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({
236
+ targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
237
+ text: mediaCaption,
238
+ to: ctx.to,
239
+ operatorIds: (0, account_registry_1.operatorIdsFor)(ctx.accountId),
240
+ })
241
+ : undefined;
242
+ if (captionNotice) {
243
+ actionLog.warn("clawgram suppressing system notice in media caption", {
244
+ accountId: ctx.accountId,
245
+ rawTo: ctx.to,
246
+ noticeKind: captionNotice,
247
+ captionLength: mediaCaption.length,
248
+ });
249
+ mediaCaption = undefined;
250
+ }
251
+ // Чего здесь НЕТ намеренно: подавления эха хода (`hadTurnSendJustNow`):
252
+ // у текста дубль стоит лишнего сообщения, а у медиа отказ стоит
253
+ // потерянного файла.
236
254
  const messageThreadId = (0, helpers_2.parseOptionalThreadId)(ctx.threadId);
237
255
  // Same normalization `sendText` does two functions up. Without it the
238
256
  // channel prefix reaches peer resolution and the send throws — which is
@@ -11,8 +11,8 @@ const history_1 = require("./history");
11
11
  * Reading has had a declared scope since 2.x (`readChats`), management has
12
12
  * one (`manageChats`), and sending had none: `send`, `upload-file` and
13
13
  * `react` resolved whatever target the caller named and delivered it. The
14
- * account is a person's own Telegram account, so an injected turn could
15
- * message strangers under the owner's name, or carry a work chat's content
14
+ * account is a real Telegram account, so an injected turn could message
15
+ * strangers under this account's name, or carry a work chat's content
16
16
  * into an attacker's DM one `send` at a time (finding A5-12).
17
17
  *
18
18
  * Two decisions worth stating, because both could reasonably have gone the
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.28.0",
3
+ "version": "2.29.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clawgram",
9
- "version": "2.28.0",
9
+ "version": "2.29.1",
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.29.0",
5
+ "version": "2.29.1",
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, 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."
471
+ "description": "Chat ids the account may SEND to (2.22.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 this 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.29.0",
3
+ "version": "2.29.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": {
@@ -8,7 +8,8 @@
8
8
  "verify:actions": "node scripts/verify/action-probe.cjs",
9
9
  "build": "tsc -p tsconfig.json",
10
10
  "build:test": "tsc -p tsconfig.test.json",
11
- "test": "npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
11
+ "test": "node scripts/check-pii.mjs && npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
12
+ "check:pii": "node scripts/check-pii.mjs",
12
13
  "prepublishOnly": "npm run build && npm test",
13
14
  "release:clawhub": "node scripts/publish-clawhub.mjs",
14
15
  "clawgram-cli": "node dist/clawgram-cli.js",