@llblab/pi-telegram 0.22.0 → 0.23.0

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/lib/locks.ts CHANGED
@@ -5,16 +5,20 @@
5
5
  */
6
6
 
7
7
  import {
8
+ chmodSync,
8
9
  existsSync,
9
- linkSync,
10
+ lstatSync,
10
11
  mkdirSync,
12
+ mkdtempSync,
11
13
  readFileSync,
14
+ readdirSync,
12
15
  renameSync,
16
+ rmSync,
13
17
  unlinkSync,
14
18
  writeFileSync,
15
19
  } from "node:fs";
16
20
  import { randomUUID } from "node:crypto";
17
- import { dirname } from "node:path";
21
+ import { basename, dirname, join } from "node:path";
18
22
  import { resolveTelegramLocksPath } from "./paths.ts";
19
23
 
20
24
  export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
@@ -178,14 +182,37 @@ interface TelegramLockTransactionOwner {
178
182
  generation: string;
179
183
  }
180
184
 
185
+ const TELEGRAM_TRANSACTION_OWNER_PATTERN =
186
+ /^owner\.([A-Za-z0-9-]+)\.json$/u;
187
+
188
+ function getLockTransactionOwnerFile(generation: string): string {
189
+ return `owner.${generation}.json`;
190
+ }
191
+
192
+ function getLockTransactionOwnerPath(path: string): string {
193
+ const stat = lstatSync(path);
194
+ if (stat.isDirectory()) {
195
+ const entries = readdirSync(path);
196
+ if (
197
+ entries.length === 1 &&
198
+ (TELEGRAM_TRANSACTION_OWNER_PATTERN.test(entries[0]) ||
199
+ TELEGRAM_TRANSACTION_RECLAIM_PATTERN.test(entries[0]))
200
+ ) {
201
+ return join(path, entries[0]);
202
+ }
203
+ throw new Error(`Unverifiable Telegram lock transaction guard: ${path}`);
204
+ }
205
+ if (stat.isFile()) return path;
206
+ throw new Error(`Unsupported Telegram lock transaction guard: ${path}`);
207
+ }
208
+
181
209
  function readLockTransactionOwner(
182
210
  path: string,
183
211
  ): TelegramLockTransactionOwner | undefined {
184
212
  try {
185
- const value = JSON.parse(readFileSync(path, "utf8")) as Record<
186
- string,
187
- unknown
188
- >;
213
+ const value = JSON.parse(
214
+ readFileSync(getLockTransactionOwnerPath(path), "utf8"),
215
+ ) as Record<string, unknown>;
189
216
  if (
190
217
  typeof value.pid !== "number" ||
191
218
  typeof value.acquiredAtMs !== "number" ||
@@ -193,6 +220,10 @@ function readLockTransactionOwner(
193
220
  ) {
194
221
  return undefined;
195
222
  }
223
+ const ownerMatch = TELEGRAM_TRANSACTION_OWNER_PATTERN.exec(
224
+ basename(getLockTransactionOwnerPath(path)),
225
+ );
226
+ if (ownerMatch && ownerMatch[1] !== value.generation) return undefined;
196
227
  return {
197
228
  pid: value.pid,
198
229
  acquiredAtMs: value.acquiredAtMs,
@@ -203,6 +234,33 @@ function readLockTransactionOwner(
203
234
  }
204
235
  }
205
236
 
237
+ function createLockTransactionContentionError(path: string): Error {
238
+ return Object.assign(
239
+ new Error(`Telegram lock transaction guard already exists: ${path}`),
240
+ { code: "EEXIST" },
241
+ );
242
+ }
243
+
244
+ function isLockTransactionContentionError(
245
+ error: unknown,
246
+ path: string,
247
+ ): boolean {
248
+ const code = (error as { code?: unknown })?.code;
249
+ if (
250
+ code === "EEXIST" ||
251
+ code === "ENOTEMPTY" ||
252
+ code === "ENOTDIR" ||
253
+ code === "EISDIR"
254
+ ) {
255
+ return true;
256
+ }
257
+ return existsSync(path) && (code === "EPERM" || code === "EACCES");
258
+ }
259
+
260
+ function removeLockTransactionGuard(path: string): void {
261
+ rmSync(path, { recursive: true, force: true });
262
+ }
263
+
206
264
  function createLockTransactionGuard(
207
265
  path: string,
208
266
  ): TelegramLockTransactionOwner {
@@ -211,18 +269,20 @@ function createLockTransactionGuard(
211
269
  acquiredAtMs: Date.now(),
212
270
  generation: randomUUID(),
213
271
  };
214
- const stagedPath = `${path}.${owner.generation}.tmp`;
272
+ const stagedPath = mkdtempSync(`${path}.staged.`);
215
273
  try {
216
- writeFileSync(stagedPath, `${JSON.stringify(owner)}\n`, {
217
- encoding: "utf8",
218
- flag: "wx",
219
- mode: 0o600,
220
- });
221
- linkSync(stagedPath, path);
274
+ chmodSync(stagedPath, 0o700);
275
+ writeFileSync(
276
+ join(stagedPath, getLockTransactionOwnerFile(owner.generation)),
277
+ `${JSON.stringify(owner)}\n`,
278
+ { encoding: "utf8", flag: "wx", mode: 0o600 },
279
+ );
280
+ if (existsSync(path)) throw createLockTransactionContentionError(path);
281
+ renameSync(stagedPath, path);
222
282
  return owner;
223
283
  } finally {
224
284
  try {
225
- unlinkSync(stagedPath);
285
+ removeLockTransactionGuard(stagedPath);
226
286
  } catch {
227
287
  /* best effort */
228
288
  }
@@ -247,13 +307,19 @@ function releaseLockTransactionGuard(
247
307
  `Telegram lock transaction guard changed ownership: ${path}`,
248
308
  );
249
309
  }
310
+ const releasedPath = `${path}.released.${randomUUID()}`;
250
311
  for (
251
312
  let attempt = 0;
252
313
  attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS;
253
314
  attempt += 1
254
315
  ) {
255
316
  try {
256
- unlinkSync(path);
317
+ renameSync(path, releasedPath);
318
+ try {
319
+ removeLockTransactionGuard(releasedPath);
320
+ } catch {
321
+ /* released debris cannot retain transaction authority */
322
+ }
257
323
  return;
258
324
  } catch (error) {
259
325
  if ((error as { code?: unknown })?.code === "ENOENT") return;
@@ -273,40 +339,254 @@ function isAbandonedLockTransaction(path: string): boolean {
273
339
  return owner ? !isProcessAlive(owner.pid) : false;
274
340
  }
275
341
 
276
- function recoverAbandonedLockTransaction(
342
+ const TELEGRAM_TRANSACTION_RECLAIM_PATTERN =
343
+ /^owner\.reclaim\.(\d+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/u;
344
+ const TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS = Symbol.for(
345
+ "@llblab/pi-telegram/active-transaction-reclaims",
346
+ );
347
+
348
+ type TelegramTransactionGlobal = typeof globalThis & {
349
+ [TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS]?: Set<string>;
350
+ };
351
+
352
+ export interface TelegramFileTransactionOptions {
353
+ recoveryRename?: typeof renameSync;
354
+ }
355
+
356
+ function getActiveTransactionReclaims(): Set<string> {
357
+ const root = globalThis as TelegramTransactionGlobal;
358
+ return (root[TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS] ??= new Set());
359
+ }
360
+
361
+ function reclaimAbandonedDirectoryGuard(
277
362
  path: string,
278
- ): TelegramLockTransactionOwner | undefined {
279
- if (!isAbandonedLockTransaction(path)) return undefined;
280
- const recoveryGuardPath = `${path}.recovery`;
281
- let recoveryOwner: TelegramLockTransactionOwner;
363
+ options: TelegramFileTransactionOptions = {},
364
+ ): boolean {
282
365
  try {
283
- recoveryOwner = createLockTransactionGuard(recoveryGuardPath);
366
+ if (!lstatSync(path).isDirectory()) return false;
367
+ } catch {
368
+ return false;
369
+ }
370
+ const entries = readdirSync(path);
371
+ if (entries.length !== 1) return false;
372
+ const entry = entries[0];
373
+ let observedPid: number;
374
+ let observedReclaimGeneration: string | undefined;
375
+ if (TELEGRAM_TRANSACTION_OWNER_PATTERN.test(entry)) {
376
+ const owner = readLockTransactionOwner(path);
377
+ if (!owner) return false;
378
+ observedPid = owner.pid;
379
+ } else {
380
+ const match = TELEGRAM_TRANSACTION_RECLAIM_PATTERN.exec(entry);
381
+ if (!match) return false;
382
+ observedPid = Number.parseInt(match[1], 10);
383
+ observedReclaimGeneration = match[2];
384
+ }
385
+ const activeReclaims = getActiveTransactionReclaims();
386
+ if (
387
+ observedPid === process.pid &&
388
+ observedReclaimGeneration !== undefined
389
+ ) {
390
+ if (activeReclaims.has(observedReclaimGeneration)) return false;
391
+ } else if (isProcessAlive(observedPid)) {
392
+ return false;
393
+ }
394
+
395
+ const renameRecovery = options.recoveryRename ?? renameSync;
396
+ const sourcePath = join(path, entry);
397
+ const reclaimGeneration = randomUUID();
398
+ const reclaimPath = join(
399
+ path,
400
+ `owner.reclaim.${process.pid}.${reclaimGeneration}.json`,
401
+ );
402
+ try {
403
+ // Claim inside the still-occupied guard before making its stable path free.
404
+ renameRecovery(sourcePath, reclaimPath);
284
405
  } catch (error) {
285
- if ((error as { code?: unknown })?.code === "EEXIST") return undefined;
406
+ if ((error as { code?: unknown })?.code === "ENOENT") return false;
286
407
  throw error;
287
408
  }
288
- let recoveredOwner: TelegramLockTransactionOwner | undefined;
409
+
410
+ const renameWithRetry = (fromPath: string, toPath: string): boolean => {
411
+ for (
412
+ let attempt = 0;
413
+ attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS;
414
+ attempt += 1
415
+ ) {
416
+ try {
417
+ renameRecovery(fromPath, toPath);
418
+ return true;
419
+ } catch (error) {
420
+ if ((error as { code?: unknown })?.code === "ENOENT") return false;
421
+ if (
422
+ !isRetryableLockWriteError(error) ||
423
+ attempt === TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS - 1
424
+ ) {
425
+ throw error;
426
+ }
427
+ sleepSync(TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS * (attempt + 1));
428
+ }
429
+ }
430
+ return false;
431
+ };
432
+
433
+ activeReclaims.add(reclaimGeneration);
434
+ const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
289
435
  try {
290
- if (!isAbandonedLockTransaction(path)) return undefined;
291
- const stalePath = `${path}.stale.${process.pid}.${Date.now()}`;
436
+ try {
437
+ if (!renameWithRetry(path, stalePath)) return false;
438
+ } catch (renameError) {
439
+ try {
440
+ if (!renameWithRetry(reclaimPath, sourcePath)) throw renameError;
441
+ } catch (rollbackError) {
442
+ throw new AggregateError(
443
+ [renameError, rollbackError],
444
+ `Failed to reclaim or restore Telegram lock transaction guard: ${path}`,
445
+ );
446
+ }
447
+ throw renameError;
448
+ }
449
+ } finally {
450
+ activeReclaims.delete(reclaimGeneration);
451
+ }
452
+ try {
453
+ removeLockTransactionGuard(stalePath);
454
+ } catch {
455
+ /* stale debris cannot retain transaction authority */
456
+ }
457
+ return true;
458
+ }
459
+
460
+ function acquireRecoverableDirectoryGuard(
461
+ path: string,
462
+ options: TelegramFileTransactionOptions = {},
463
+ ): TelegramLockTransactionOwner | undefined {
464
+ for (let attempt = 0; attempt < 2; attempt += 1) {
465
+ try {
466
+ return createLockTransactionGuard(path);
467
+ } catch (error) {
468
+ if (!isLockTransactionContentionError(error, path)) throw error;
469
+ if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
470
+ }
471
+ }
472
+ return undefined;
473
+ }
474
+
475
+ function removeAbandonedLegacyRecoveryGuard(
476
+ path: string,
477
+ options: TelegramFileTransactionOptions = {},
478
+ ): boolean {
479
+ try {
480
+ if (!lstatSync(path).isFile() || !isAbandonedLockTransaction(path))
481
+ return false;
482
+ } catch {
483
+ return false;
484
+ }
485
+ const migrationGuardPath = `${path}.migration`;
486
+ const migrationOwner = acquireRecoverableDirectoryGuard(
487
+ migrationGuardPath,
488
+ options,
489
+ );
490
+ if (!migrationOwner) return false;
491
+ try {
492
+ try {
493
+ if (!lstatSync(path).isFile() || !isAbandonedLockTransaction(path))
494
+ return false;
495
+ } catch {
496
+ return false;
497
+ }
498
+ const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
292
499
  try {
293
500
  renameSync(path, stalePath);
294
501
  } catch (error) {
295
- if ((error as { code?: unknown })?.code === "ENOENT") return undefined;
502
+ if ((error as { code?: unknown })?.code === "ENOENT") return false;
296
503
  throw error;
297
504
  }
298
505
  try {
299
- unlinkSync(stalePath);
506
+ removeLockTransactionGuard(stalePath);
300
507
  } catch {
301
- /* best effort */
508
+ /* stale debris cannot retain transaction authority */
302
509
  }
510
+ return true;
511
+ } finally {
512
+ releaseLockTransactionGuard(migrationGuardPath, migrationOwner);
513
+ }
514
+ }
515
+
516
+ function acquireLegacyRecoveryGuard(
517
+ path: string,
518
+ options: TelegramFileTransactionOptions = {},
519
+ ): TelegramLockTransactionOwner | undefined {
520
+ let owner = acquireRecoverableDirectoryGuard(path, options);
521
+ if (owner) return owner;
522
+ if (!removeAbandonedLegacyRecoveryGuard(path, options)) return undefined;
523
+ owner = acquireRecoverableDirectoryGuard(path, options);
524
+ return owner;
525
+ }
526
+
527
+ function createRecoveredLockTransactionGuard(
528
+ path: string,
529
+ ): TelegramLockTransactionOwner | undefined {
530
+ try {
531
+ return createLockTransactionGuard(path);
532
+ } catch (error) {
533
+ if (isLockTransactionContentionError(error, path)) return undefined;
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ function recoverAbandonedLockTransaction(
539
+ path: string,
540
+ options: TelegramFileTransactionOptions = {},
541
+ ): TelegramLockTransactionOwner | undefined {
542
+ if (!isAbandonedLockTransaction(path)) return undefined;
543
+ let isDirectory: boolean;
544
+ try {
545
+ isDirectory = lstatSync(path).isDirectory();
546
+ } catch {
547
+ return undefined;
548
+ }
549
+ if (isDirectory) {
550
+ if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
551
+ const recoveredOwner = createRecoveredLockTransactionGuard(path);
303
552
  try {
304
- recoveredOwner = createLockTransactionGuard(path);
553
+ reclaimAbandonedDirectoryGuard(`${path}.recovery`, options);
305
554
  return recoveredOwner;
306
555
  } catch (error) {
307
- if ((error as { code?: unknown })?.code === "EEXIST") return undefined;
556
+ if (recoveredOwner) {
557
+ try {
558
+ releaseLockTransactionGuard(path, recoveredOwner);
559
+ } catch {
560
+ /* preserve the recovery cleanup failure */
561
+ }
562
+ }
308
563
  throw error;
309
564
  }
565
+ }
566
+
567
+ const recoveryGuardPath = `${path}.recovery`;
568
+ const recoveryOwner = acquireLegacyRecoveryGuard(
569
+ recoveryGuardPath,
570
+ options,
571
+ );
572
+ if (!recoveryOwner) return undefined;
573
+ let recoveredOwner: TelegramLockTransactionOwner | undefined;
574
+ try {
575
+ if (!isAbandonedLockTransaction(path)) return undefined;
576
+ const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
577
+ try {
578
+ renameSync(path, stalePath);
579
+ } catch (error) {
580
+ if ((error as { code?: unknown })?.code === "ENOENT") return undefined;
581
+ throw error;
582
+ }
583
+ try {
584
+ removeLockTransactionGuard(stalePath);
585
+ } catch {
586
+ /* stale debris cannot retain transaction authority */
587
+ }
588
+ recoveredOwner = createRecoveredLockTransactionGuard(path);
589
+ return recoveredOwner;
310
590
  } finally {
311
591
  try {
312
592
  releaseLockTransactionGuard(recoveryGuardPath, recoveryOwner);
@@ -323,7 +603,10 @@ function recoverAbandonedLockTransaction(
323
603
  }
324
604
  }
325
605
 
326
- function acquireLockTransaction(path: string): TelegramLockTransactionOwner {
606
+ function acquireLockTransaction(
607
+ path: string,
608
+ options: TelegramFileTransactionOptions = {},
609
+ ): TelegramLockTransactionOwner {
327
610
  mkdirSync(dirname(path), { recursive: true });
328
611
  for (
329
612
  let attempt = 0;
@@ -333,8 +616,8 @@ function acquireLockTransaction(path: string): TelegramLockTransactionOwner {
333
616
  try {
334
617
  return createLockTransactionGuard(path);
335
618
  } catch (error) {
336
- if ((error as { code?: unknown })?.code !== "EEXIST") throw error;
337
- const recoveredOwner = recoverAbandonedLockTransaction(path);
619
+ if (!isLockTransactionContentionError(error, path)) throw error;
620
+ const recoveredOwner = recoverAbandonedLockTransaction(path, options);
338
621
  if (recoveredOwner !== undefined) return recoveredOwner;
339
622
  if (attempt === TELEGRAM_LOCK_TRANSACTION_ATTEMPTS - 1) {
340
623
  throw new Error(
@@ -350,8 +633,9 @@ function acquireLockTransaction(path: string): TelegramLockTransactionOwner {
350
633
  export function withTelegramFileTransaction<T>(
351
634
  transactionPath: string,
352
635
  operation: () => T,
636
+ options: TelegramFileTransactionOptions = {},
353
637
  ): T {
354
- const owner = acquireLockTransaction(transactionPath);
638
+ const owner = acquireLockTransaction(transactionPath, options);
355
639
  try {
356
640
  return operation();
357
641
  } finally {
package/lib/logs.ts CHANGED
@@ -159,7 +159,6 @@ export function createTelegramRuntimeJsonlLog(
159
159
  const path = resolvePath();
160
160
  const previousPath = resolvePreviousPath();
161
161
  pending = pending
162
- .catch(() => undefined)
163
162
  .then(() => {
164
163
  ensureParent(path);
165
164
  withTelegramFileTransaction(`${path}.transaction`, () => {
@@ -178,7 +177,8 @@ export function createTelegramRuntimeJsonlLog(
178
177
  }
179
178
  appendFileSync(path, line, { mode: 0o600 });
180
179
  });
181
- });
180
+ })
181
+ .catch(() => undefined);
182
182
  };
183
183
 
184
184
  return {
@@ -203,7 +203,11 @@ export function createTelegramRuntimeJsonlLog(
203
203
  }
204
204
  },
205
205
  record(event) {
206
- appendLine(safeJsonLine({ kind: "event", ...event }) + "\n");
206
+ try {
207
+ appendLine(safeJsonLine({ kind: "event", ...event }) + "\n");
208
+ } catch {
209
+ // Diagnostics must never break Telegram runtime behavior.
210
+ }
207
211
  },
208
212
  };
209
213
  }
package/lib/media.ts CHANGED
@@ -682,11 +682,107 @@ export async function downloadTelegramMessageFiles(
682
682
  return downloaded;
683
683
  }
684
684
 
685
+ function collectTelegramRichBlockFileInfos(
686
+ blocks: unknown,
687
+ messageId: number,
688
+ ): TelegramFileInfo[] {
689
+ if (!Array.isArray(blocks)) return [];
690
+ const files: TelegramFileInfo[] = [];
691
+ let mediaIndex = 0;
692
+ const visit = (entries: unknown): void => {
693
+ if (!Array.isArray(entries)) return;
694
+ for (const entry of entries) {
695
+ if (typeof entry !== "object" || entry === null) continue;
696
+ const type = getObjectField(entry, "type");
697
+ if (
698
+ type === "photo" ||
699
+ type === "animation" ||
700
+ type === "audio" ||
701
+ type === "video" ||
702
+ type === "voice_note"
703
+ ) {
704
+ mediaIndex += 1;
705
+ }
706
+ if (type === "photo") {
707
+ const photos = getObjectField(entry, "photo");
708
+ if (Array.isArray(photos)) {
709
+ const photo = photos
710
+ .filter(
711
+ (value): value is TelegramPhotoSize =>
712
+ typeof value === "object" &&
713
+ value !== null &&
714
+ typeof getObjectField(value, "file_id") === "string",
715
+ )
716
+ .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
717
+ .at(-1);
718
+ if (photo) {
719
+ files.push({
720
+ file_id: photo.file_id,
721
+ fileName: `photo-${messageId}-${mediaIndex}.jpg`,
722
+ mimeType: "image/jpeg",
723
+ kind: "photo",
724
+ isImage: true,
725
+ });
726
+ }
727
+ }
728
+ }
729
+ const fileField =
730
+ type === "animation"
731
+ ? "animation"
732
+ : type === "audio"
733
+ ? "audio"
734
+ : type === "video"
735
+ ? "video"
736
+ : type === "voice_note"
737
+ ? "voice_note"
738
+ : undefined;
739
+ if (fileField) {
740
+ const media = getObjectField(entry, fileField);
741
+ const fileId = getObjectField(media, "file_id");
742
+ const mimeType = getObjectField(media, "mime_type");
743
+ const fileName = getObjectField(media, "file_name");
744
+ if (typeof fileId === "string") {
745
+ const kind =
746
+ type === "voice_note" ? "voice" : (type as TelegramAttachmentKind);
747
+ const fallbackExtension =
748
+ kind === "voice" ? ".ogg" : kind === "audio" ? ".mp3" : ".mp4";
749
+ files.push({
750
+ file_id: fileId,
751
+ fileName:
752
+ typeof fileName === "string"
753
+ ? fileName
754
+ : `${kind}-${messageId}-${mediaIndex}${guessExtensionFromMime(
755
+ typeof mimeType === "string" ? mimeType : undefined,
756
+ fallbackExtension,
757
+ )}`,
758
+ mimeType: typeof mimeType === "string" ? mimeType : undefined,
759
+ kind,
760
+ isImage: false,
761
+ });
762
+ }
763
+ }
764
+ visit(getObjectField(entry, "blocks"));
765
+ const items = getObjectField(entry, "items");
766
+ if (Array.isArray(items)) {
767
+ for (const item of items) visit(getObjectField(item, "blocks"));
768
+ }
769
+ }
770
+ };
771
+ visit(blocks);
772
+ return files;
773
+ }
774
+
685
775
  export function collectTelegramFileInfos(
686
776
  messages: TelegramMediaMessage[],
687
777
  ): TelegramFileInfo[] {
688
778
  const files: TelegramFileInfo[] = [];
689
779
  for (const message of messages) {
780
+ files.push(
781
+ ...collectTelegramRichBlockFileInfos(
782
+ message.rich_message?.blocks,
783
+ message.message_id,
784
+ ),
785
+ );
690
786
  if (Array.isArray(message.photo) && message.photo.length > 0) {
691
787
  const photo = [...message.photo]
692
788
  .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
@@ -786,5 +882,10 @@ export function collectTelegramFileInfos(
786
882
  });
787
883
  }
788
884
  }
789
- return files;
885
+ const seenFileIds = new Set<string>();
886
+ return files.filter((file) => {
887
+ if (seenFileIds.has(file.file_id)) return false;
888
+ seenFileIds.add(file.file_id);
889
+ return true;
890
+ });
790
891
  }
@@ -159,7 +159,10 @@ export function buildProactivePushSettingsText(
159
159
  return [
160
160
  `${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
161
161
  "",
162
- "Send successful local Pi task results to Telegram when the bridge is connected.",
162
+ "Control whether public assistant output from local/autonomous work is projected to Telegram.",
163
+ "",
164
+ "<code>-</code> <code>on</code> (default): send each completed public block, including visible checkpoints and the final answer, while connected.",
165
+ "<code>-</code> <code>off</code>: keep local/autonomous assistant blocks in Pi; Telegram-originated replies still use their normal delivery path.",
163
166
  ].join("\n");
164
167
  }
165
168