@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/AGENTS.md +12 -7
- package/BACKLOG.md +1 -23
- package/CHANGELOG.md +53 -1
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +17 -15
- package/docs/locks.md +22 -12
- package/docs/multi-instance-bus.md +17 -16
- package/docs/outbound.md +16 -0
- package/docs/public-api.md +9 -4
- package/index.ts +74 -59
- package/lib/activity.ts +100 -3
- package/lib/bindings.ts +81 -6
- package/lib/bus-follower.ts +205 -17
- package/lib/bus-leader.ts +339 -133
- package/lib/bus.ts +82 -19
- package/lib/commands.ts +28 -4
- package/lib/config.ts +40 -28
- package/lib/locks.ts +318 -34
- package/lib/logs.ts +7 -3
- package/lib/media.ts +102 -1
- package/lib/menu-settings.ts +4 -1
- package/lib/outbound-attachments.ts +150 -1
- package/lib/outbound.ts +106 -1
- package/lib/polling.ts +5 -0
- package/lib/queue.ts +41 -48
- package/lib/routing.ts +88 -1
- package/lib/sync.ts +40 -3
- package/lib/telegram-api.ts +77 -13
- package/lib/text-groups.ts +183 -16
- package/lib/thread-reconciler.ts +66 -22
- package/lib/threads.ts +131 -23
- package/lib/turns.ts +102 -13
- package/lib/updates.ts +2 -0
- package/package.json +1 -1
package/lib/locks.ts
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
chmodSync,
|
|
8
9
|
existsSync,
|
|
9
|
-
|
|
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(
|
|
186
|
-
|
|
187
|
-
|
|
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}
|
|
272
|
+
const stagedPath = mkdtempSync(`${path}.staged.`);
|
|
215
273
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
const recoveryGuardPath = `${path}.recovery`;
|
|
281
|
-
let recoveryOwner: TelegramLockTransactionOwner;
|
|
363
|
+
options: TelegramFileTransactionOptions = {},
|
|
364
|
+
): boolean {
|
|
282
365
|
try {
|
|
283
|
-
|
|
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 === "
|
|
406
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
286
407
|
throw error;
|
|
287
408
|
}
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
502
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
296
503
|
throw error;
|
|
297
504
|
}
|
|
298
505
|
try {
|
|
299
|
-
|
|
506
|
+
removeLockTransactionGuard(stalePath);
|
|
300
507
|
} catch {
|
|
301
|
-
/*
|
|
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
|
-
|
|
553
|
+
reclaimAbandonedDirectoryGuard(`${path}.recovery`, options);
|
|
305
554
|
return recoveredOwner;
|
|
306
555
|
} catch (error) {
|
|
307
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/menu-settings.ts
CHANGED
|
@@ -159,7 +159,10 @@ export function buildProactivePushSettingsText(
|
|
|
159
159
|
return [
|
|
160
160
|
`${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
|
|
161
161
|
"",
|
|
162
|
-
"
|
|
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
|
|