clawgram 2.29.0 → 2.29.2

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 |
@@ -132,117 +132,135 @@ async function handleReadAction(ctx) {
132
132
  ? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
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
+ await (0, media_1.sweepOrphanMediaDirs)(mediaRoot, Date.now());
135
136
  const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
137
+ const described = (0, media_1.describeMedia)(found.message?.media);
138
+ // A PDF is not read here: the agent is handed the path for its PDF tool,
139
+ // so even in `read` mode the file has to outlive the answer. It goes to
140
+ // the shared directory, which is pruned by age — a private temp directory
141
+ // is removed only by the `read` branch below, and a PDF skips that branch,
142
+ // so every PDF read used to stay on disk for good (audit r3 C2-02).
143
+ const keepsFile = fetchParams.mode !== "read" || (0, media_1.fetchMediaUnderstanding)(described) === "pdf";
136
144
  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-"));
145
+ if (keepsFile) {
146
+ await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
140
147
  }
141
148
  else {
142
- await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
149
+ const { mkdtemp } = await import("node:fs/promises");
150
+ fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
143
151
  }
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", {
152
+ // Every way out of here removes the private temp directory — the early
153
+ // "nothing to fetch" answer and a download that throws included. Only the
154
+ // success branch used to, so a message without media, a video, or a
155
+ // failed download each left a directory behind for good (audit r3 V1-10).
156
+ try {
157
+ const downloaded = await (0, media_1.downloadMessageMediaToFile)({
158
+ client: fetchGram.getClient(),
159
+ message: found.message,
160
+ maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
161
+ dir: fetchDir,
162
+ understanding: (0, media_1.fetchMediaUnderstanding)(described),
163
+ fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
164
+ chatId: fetchChatId,
165
+ messageId: fetchParams.messageId,
166
+ extension,
167
+ fileName: media.fileName,
168
+ }),
169
+ });
170
+ if (!downloaded) {
171
+ // Three different nothings, and the agent has to be able to tell
172
+ // them apart: a message with no attachment, an attachment this
173
+ // channel does not read (a video, a spreadsheet), and one too
174
+ // large to be worth the transfer. Saying "could not fetch" to all
175
+ // three is how "she ignored the picture" starts.
176
+ const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
177
+ const error = !described
178
+ ? "no-media"
179
+ : tooLarge
180
+ ? "media-too-large"
181
+ : "unsupported-media";
182
+ actionLog.info("clawgram fetch-media returned nothing", {
183
+ accountId: fetchAccountId,
184
+ chatId: fetchChatId,
185
+ messageId: fetchParams.messageId,
186
+ kind: described?.kind ?? null,
187
+ error,
188
+ });
189
+ return (0, core_1.jsonResult)({
190
+ ok: false,
191
+ accountId: fetchAccountId,
192
+ chatId: fetchChatId,
193
+ messageId: String(fetchParams.messageId),
194
+ media: described ?? null,
195
+ error,
196
+ });
197
+ }
198
+ let read;
199
+ let readError;
200
+ if (fetchParams.mode !== "file") {
201
+ try {
202
+ read = await (0, attachments_1.understandAttachmentFile)({
203
+ runtime: pluginRuntime,
204
+ cfg,
205
+ filePath: downloaded.path,
206
+ mimeType: downloaded.mimeType,
207
+ understanding: downloaded.understanding,
208
+ fileName: downloaded.media.fileName,
209
+ });
210
+ if (!read) {
211
+ readError = "read-empty";
212
+ }
213
+ }
214
+ catch (err) {
215
+ // The bytes are already here. A failed reading is worth
216
+ // reporting, but it does not undo a successful fetch: the file
217
+ // still exists and can still be forwarded.
218
+ readError = String(err);
219
+ }
220
+ }
221
+ // `read` mode is the inbound contract — the words, not the file — so
222
+ // the bytes go away with the answer. Any other mode keeps them:
223
+ // that is the whole point of asking for a path.
224
+ const pdfNeedsFile = downloaded.understanding === "pdf";
225
+ const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
226
+ actionLog.info("clawgram fetch-media completed", {
171
227
  accountId: fetchAccountId,
172
228
  chatId: fetchChatId,
173
229
  messageId: fetchParams.messageId,
174
- kind: described?.kind ?? null,
175
- error,
230
+ mode: fetchParams.mode,
231
+ kind: downloaded.media.kind,
232
+ understanding: downloaded.understanding,
233
+ characters: read?.length ?? 0,
234
+ readError: finalReadError ?? null,
176
235
  });
177
236
  return (0, core_1.jsonResult)({
178
- ok: false,
237
+ ok: true,
179
238
  accountId: fetchAccountId,
180
239
  chatId: fetchChatId,
181
240
  messageId: String(fetchParams.messageId),
182
- media: described ?? null,
183
- error,
241
+ mode: fetchParams.mode,
242
+ media: downloaded.media,
243
+ understanding: downloaded.understanding,
244
+ filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
245
+ text: read,
246
+ readError: finalReadError,
184
247
  });
185
248
  }
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";
249
+ finally {
250
+ // Only the private temp directory is ever removed here — never the
251
+ // shared one, which other fetches still point at.
252
+ if (!keepsFile) {
253
+ try {
254
+ const { rm } = await import("node:fs/promises");
255
+ await rm(fetchDir, { recursive: true, force: true });
256
+ }
257
+ catch {
258
+ // A private temp directory left behind is litter, not a leak of
259
+ // anything the agent was not already shown; failing the call over it
260
+ // would throw away a reading that already succeeded.
200
261
  }
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
262
  }
222
263
  }
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
264
  }
247
265
  /**
248
266
  * 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));
@@ -81,6 +81,7 @@ async function readInboundAttachment(params) {
81
81
  if (!media || !message) {
82
82
  return undefined;
83
83
  }
84
+ await (0, media_1.sweepOrphanMediaDirs)(node_os_1.default.tmpdir(), Date.now());
84
85
  let downloaded;
85
86
  try {
86
87
  downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
@@ -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
@@ -15,6 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.ORPHAN_MEDIA_DIR_TTL_MS = void 0;
18
19
  exports.describeMedia = describeMedia;
19
20
  exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
20
21
  exports.fetchMediaUnderstanding = fetchMediaUnderstanding;
@@ -23,11 +24,14 @@ exports.describePrivateDirProblem = describePrivateDirProblem;
23
24
  exports.ensurePrivateDir = ensurePrivateDir;
24
25
  exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
25
26
  exports.pruneFetchedMedia = pruneFetchedMedia;
27
+ exports.pruneOrphanMediaDirs = pruneOrphanMediaDirs;
28
+ exports.sweepOrphanMediaDirs = sweepOrphanMediaDirs;
26
29
  exports.isLocalMediaPath = isLocalMediaPath;
27
30
  exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
28
31
  exports.loadOutboundMedia = loadOutboundMedia;
29
32
  const node_fs_1 = require("node:fs");
30
33
  const node_path_1 = __importDefault(require("node:path"));
34
+ const docx_text_1 = require("./docx-text");
31
35
  const util_1 = require("./util");
32
36
  /**
33
37
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
@@ -131,19 +135,17 @@ function fetchMediaUnderstanding(media) {
131
135
  const inbound = inboundMediaUnderstanding(media);
132
136
  if (inbound)
133
137
  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"))) {
138
+ if (media?.kind !== "document")
139
+ return undefined;
140
+ // One definition of "a document we can read": the same predicates decide
141
+ // how it is read in `understandAttachmentFile`, so a format added there
142
+ // cannot be downloaded here and then decoded as the wrong thing (r3 C2-03).
143
+ if ((0, docx_text_1.isDocxDocument)(media.mimeType, media.fileName))
137
144
  return "document";
138
- }
139
- if (media?.kind === "document" && (media.mimeType === "application/pdf" || fileName.endsWith(".pdf"))) {
145
+ if (media.mimeType === "application/pdf" || media.fileName?.toLowerCase().endsWith(".pdf"))
140
146
  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}`)))) {
147
+ if ((0, docx_text_1.isTextDocument)(media.mimeType, media.fileName))
145
148
  return "document";
146
- }
147
149
  return undefined;
148
150
  }
149
151
  /**
@@ -168,17 +170,32 @@ async function downloadInboundMediaToTempFile(params) {
168
170
  return undefined;
169
171
  }
170
172
  const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
171
- const downloaded = await downloadMessageMediaToFile({
172
- client: params.client,
173
- message: params.message,
174
- maxBytes: params.maxBytes,
175
- dir,
176
- fileNameFor: ({ extension }) => `attachment.${extension}`,
177
- });
173
+ // The caller removes only a path it was handed. Every other way out — an
174
+ // empty download, a document, a throw — removes the directory here, or it
175
+ // stays in the temp directory for good (audit r3 V1-10, the inbound path).
176
+ const discard = async () => {
177
+ const { rm } = await import("node:fs/promises");
178
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
179
+ };
180
+ let downloaded;
181
+ try {
182
+ downloaded = await downloadMessageMediaToFile({
183
+ client: params.client,
184
+ message: params.message,
185
+ maxBytes: params.maxBytes,
186
+ dir,
187
+ fileNameFor: ({ extension }) => `attachment.${extension}`,
188
+ });
189
+ }
190
+ catch (err) {
191
+ await discard();
192
+ throw err;
193
+ }
178
194
  // This path asks only for `inboundMediaUnderstanding`, so a document cannot
179
195
  // get here. Keep the runtime guard as the public downloader also serves the
180
196
  // explicit `fetch-media` action, which is allowed to request DOCX files.
181
197
  if (!downloaded || downloaded.understanding === "document" || downloaded.understanding === "pdf") {
198
+ await discard();
182
199
  return undefined;
183
200
  }
184
201
  return {
@@ -336,6 +353,57 @@ async function pruneFetchedMedia(dir, maxAgeMs, now) {
336
353
  }
337
354
  return removed;
338
355
  }
356
+ /**
357
+ * A private `clawgram-media-*` directory lives for one call: the inbound
358
+ * reader and `fetch-media mode=read` remove it when they are done. One that is
359
+ * an hour old was left by a crash, or by a version before 2.29.1, which kept a
360
+ * directory on every error branch and every PDF read (audit r3 C2-02, V1-10).
361
+ */
362
+ exports.ORPHAN_MEDIA_DIR_TTL_MS = 60 * 60 * 1000;
363
+ const ORPHAN_SWEEP_EVERY_MS = 10 * 60 * 1000;
364
+ const lastOrphanSweep = new Map();
365
+ /** Removes `clawgram-media-*` directories in `root` older than `maxAgeMs`. */
366
+ async function pruneOrphanMediaDirs(root, maxAgeMs, now) {
367
+ const { readdir, stat, rm } = await import("node:fs/promises");
368
+ const { join } = await import("node:path");
369
+ let entries;
370
+ try {
371
+ entries = await readdir(root);
372
+ }
373
+ catch {
374
+ return 0;
375
+ }
376
+ let removed = 0;
377
+ for (const entry of entries) {
378
+ if (!entry.startsWith("clawgram-media-"))
379
+ continue;
380
+ const path = join(root, entry);
381
+ try {
382
+ const info = await stat(path);
383
+ // In a shared /tmp another account's directory fails `rm` on the
384
+ // sticky bit and is skipped; only our own leftovers go.
385
+ if (info.isDirectory() && now - info.mtimeMs > maxAgeMs) {
386
+ await rm(path, { recursive: true, force: true });
387
+ removed += 1;
388
+ }
389
+ }
390
+ catch {
391
+ // Gone already, or not ours.
392
+ }
393
+ }
394
+ return removed;
395
+ }
396
+ /**
397
+ * `pruneOrphanMediaDirs` at most once per ten minutes per root: it reads the
398
+ * whole temp directory, and it runs on the path of every inbound attachment.
399
+ */
400
+ async function sweepOrphanMediaDirs(root, now) {
401
+ const last = lastOrphanSweep.get(root);
402
+ if (last !== undefined && now - last < ORPHAN_SWEEP_EVERY_MS)
403
+ return 0;
404
+ lastOrphanSweep.set(root, now);
405
+ return pruneOrphanMediaDirs(root, exports.ORPHAN_MEDIA_DIR_TTL_MS, now).catch(() => 0);
406
+ }
339
407
  function extensionFor(media, understanding) {
340
408
  if (understanding === "description") {
341
409
  if (media.mimeType === "image/png")
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.2",
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.2",
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.2",
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.2",
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",