@llblab/pi-telegram 0.18.4 → 0.18.5
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 +5 -3
- package/BACKLOG.md +6 -22
- package/CHANGELOG.md +9 -2
- package/README.md +1 -1
- package/docs/multi-instance-bus.md +3 -3
- package/index.ts +22 -1
- package/lib/bus-follower.ts +40 -39
- package/lib/bus-leader.ts +19 -20
- package/lib/bus-transport.ts +227 -0
- package/lib/bus.ts +170 -46
- package/lib/polling.ts +58 -5
- package/lib/preview.ts +20 -39
- package/lib/runtime-log.ts +19 -1
- package/lib/status.ts +50 -0
- package/package.json +1 -1
package/lib/bus.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* cross-instance forwarding helpers, and the live follower registry model.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
10
10
|
import {
|
|
11
11
|
createConnection,
|
|
@@ -16,6 +16,19 @@ import {
|
|
|
16
16
|
import { homedir, platform as getPlatform } from "node:os";
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
18
|
|
|
19
|
+
import {
|
|
20
|
+
classifyTelegramBusTransportError,
|
|
21
|
+
createTelegramBusTransportTimeoutError,
|
|
22
|
+
delayTelegramBusTransportRetry,
|
|
23
|
+
getTelegramBusEndpointDiagnostics,
|
|
24
|
+
getTelegramBusFollowerEndpoint,
|
|
25
|
+
getTelegramBusLeaderEndpoint,
|
|
26
|
+
getTelegramBusTransportRetryPolicy,
|
|
27
|
+
isTelegramBusPipePath,
|
|
28
|
+
isRetryableTelegramBusTransportError,
|
|
29
|
+
type TelegramBusTransportEventRecorder,
|
|
30
|
+
type TelegramBusTransportRetryPolicy,
|
|
31
|
+
} from "./bus-transport.ts";
|
|
19
32
|
import type { TelegramTarget } from "./target.ts";
|
|
20
33
|
|
|
21
34
|
export type TelegramBusRole = "leader" | "follower";
|
|
@@ -30,30 +43,11 @@ export function createTelegramBusAuthSecret(): string {
|
|
|
30
43
|
return randomBytes(32).toString("base64url");
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
function getTelegramBusPipePath(input: {
|
|
34
|
-
agentDir: string;
|
|
35
|
-
scope: string;
|
|
36
|
-
}): string {
|
|
37
|
-
const digest = createHash("sha256")
|
|
38
|
-
.update(resolve(input.agentDir))
|
|
39
|
-
.digest("base64url")
|
|
40
|
-
.slice(0, 16);
|
|
41
|
-
const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
|
|
42
|
-
return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isWindowsPipePath(socketPath: string): boolean {
|
|
46
|
-
return /^\\\\[.?]\\pipe\\/i.test(socketPath);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
46
|
export function getTelegramBusSocketPath(
|
|
50
47
|
agentDir = getAgentDir(),
|
|
51
48
|
platform = getPlatform(),
|
|
52
49
|
): string {
|
|
53
|
-
|
|
54
|
-
return getTelegramBusPipePath({ agentDir, scope: "bus" });
|
|
55
|
-
}
|
|
56
|
-
return join(agentDir, "tmp", "telegram", "bus.sock");
|
|
50
|
+
return getTelegramBusLeaderEndpoint({ agentDir, platform });
|
|
57
51
|
}
|
|
58
52
|
|
|
59
53
|
export function getTelegramBusFollowerSocketPath(
|
|
@@ -61,19 +55,7 @@ export function getTelegramBusFollowerSocketPath(
|
|
|
61
55
|
agentDir = getAgentDir(),
|
|
62
56
|
platform = getPlatform(),
|
|
63
57
|
): string {
|
|
64
|
-
|
|
65
|
-
return getTelegramBusPipePath({
|
|
66
|
-
agentDir,
|
|
67
|
-
scope: `follower-${instanceId}`,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return join(
|
|
71
|
-
agentDir,
|
|
72
|
-
"tmp",
|
|
73
|
-
"telegram",
|
|
74
|
-
"followers",
|
|
75
|
-
`${instanceId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.sock`,
|
|
76
|
-
);
|
|
58
|
+
return getTelegramBusFollowerEndpoint({ agentDir, platform, instanceId });
|
|
77
59
|
}
|
|
78
60
|
|
|
79
61
|
export interface TelegramBusInstanceRegistration {
|
|
@@ -91,6 +73,35 @@ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration
|
|
|
91
73
|
lastHeartbeatMs: number;
|
|
92
74
|
}
|
|
93
75
|
|
|
76
|
+
export function getTelegramFollowerTargetOwnership(input: {
|
|
77
|
+
target: TelegramTarget;
|
|
78
|
+
followers: TelegramBusFollowerView[];
|
|
79
|
+
activeThreadRecords?: Array<{
|
|
80
|
+
status?: string;
|
|
81
|
+
instanceId?: string;
|
|
82
|
+
target: TelegramTarget;
|
|
83
|
+
}>;
|
|
84
|
+
currentInstanceId?: string;
|
|
85
|
+
}): { instanceId: string } | undefined {
|
|
86
|
+
const liveFollower = input.followers.find((follower) => {
|
|
87
|
+
return (
|
|
88
|
+
follower.target?.chatId === input.target.chatId &&
|
|
89
|
+
follower.target.threadId === input.target.threadId
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
if (liveFollower) return { instanceId: liveFollower.instanceId };
|
|
93
|
+
const record = input.activeThreadRecords?.find((candidate) => {
|
|
94
|
+
return (
|
|
95
|
+
candidate.status === "active" &&
|
|
96
|
+
candidate.instanceId &&
|
|
97
|
+
candidate.instanceId !== input.currentInstanceId &&
|
|
98
|
+
candidate.target.chatId === input.target.chatId &&
|
|
99
|
+
candidate.target.threadId === input.target.threadId
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
return record?.instanceId ? { instanceId: record.instanceId } : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export function isTelegramFollowerApiCallAllowed(input: {
|
|
95
106
|
follower: TelegramBusFollowerView;
|
|
96
107
|
method: string;
|
|
@@ -310,12 +321,15 @@ export interface TelegramBusLocalServerDeps {
|
|
|
310
321
|
| Promise<TelegramBusEnvelope | undefined>
|
|
311
322
|
| TelegramBusEnvelope
|
|
312
323
|
| undefined;
|
|
324
|
+
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
313
325
|
}
|
|
314
326
|
|
|
315
327
|
export interface TelegramBusLocalClientOptions {
|
|
316
328
|
socketPath: string;
|
|
317
329
|
envelope: TelegramBusEnvelope;
|
|
318
330
|
timeoutMs?: number;
|
|
331
|
+
retry?: TelegramBusTransportRetryPolicy;
|
|
332
|
+
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
export interface TelegramBusForeignOwnedForwarderDeps {
|
|
@@ -362,6 +376,10 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
362
376
|
socketPath: deps.socketPath,
|
|
363
377
|
envelope,
|
|
364
378
|
timeoutMs: deps.timeoutMs,
|
|
379
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
380
|
+
endpoint: deps.socketPath,
|
|
381
|
+
operation: "operation",
|
|
382
|
+
}),
|
|
365
383
|
});
|
|
366
384
|
return response?.kind === "bus.ack" && response.ok;
|
|
367
385
|
};
|
|
@@ -449,6 +467,10 @@ export function createTelegramBusFollowerTargetController(
|
|
|
449
467
|
socketPath: follower.busSocketPath,
|
|
450
468
|
envelope,
|
|
451
469
|
timeoutMs: deps.timeoutMs,
|
|
470
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
471
|
+
endpoint: follower.busSocketPath,
|
|
472
|
+
operation: "operation",
|
|
473
|
+
}),
|
|
452
474
|
});
|
|
453
475
|
return response?.kind === "bus.ack" && response.ok;
|
|
454
476
|
},
|
|
@@ -517,7 +539,11 @@ export function createTelegramBusLocalServer(
|
|
|
517
539
|
return {
|
|
518
540
|
start: async () => {
|
|
519
541
|
if (server) return;
|
|
520
|
-
const usesWindowsPipe =
|
|
542
|
+
const usesWindowsPipe = isTelegramBusPipePath(deps.socketPath);
|
|
543
|
+
deps.recordTransportEvent?.(
|
|
544
|
+
"server-start",
|
|
545
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
546
|
+
);
|
|
521
547
|
if (!usesWindowsPipe) {
|
|
522
548
|
const socketDir = dirname(deps.socketPath);
|
|
523
549
|
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
@@ -533,16 +559,40 @@ export function createTelegramBusLocalServer(
|
|
|
533
559
|
const lines = buffer.split("\n");
|
|
534
560
|
buffer = lines.pop() ?? "";
|
|
535
561
|
for (const line of lines) {
|
|
536
|
-
void handleTelegramBusSocketLine(
|
|
562
|
+
void handleTelegramBusSocketLine(
|
|
563
|
+
line,
|
|
564
|
+
socket,
|
|
565
|
+
deps.handleEnvelope,
|
|
566
|
+
deps.recordTransportEvent,
|
|
567
|
+
deps.socketPath,
|
|
568
|
+
);
|
|
537
569
|
}
|
|
538
570
|
});
|
|
539
571
|
socket.on("close", () => sockets.delete(socket));
|
|
540
|
-
socket.on("error", () =>
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
572
|
+
socket.on("error", (error) => {
|
|
573
|
+
deps.recordTransportEvent?.("server-socket-error", {
|
|
574
|
+
...getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
575
|
+
...classifyTelegramBusTransportError(error),
|
|
576
|
+
});
|
|
577
|
+
closeSocket(socket);
|
|
578
|
+
});
|
|
545
579
|
});
|
|
580
|
+
try {
|
|
581
|
+
await new Promise<void>((resolve, reject) => {
|
|
582
|
+
server?.once("error", reject);
|
|
583
|
+
server?.listen(deps.socketPath, resolve);
|
|
584
|
+
});
|
|
585
|
+
deps.recordTransportEvent?.(
|
|
586
|
+
"server-started",
|
|
587
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
588
|
+
);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
deps.recordTransportEvent?.("server-start-failed", {
|
|
591
|
+
...getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
592
|
+
...classifyTelegramBusTransportError(error),
|
|
593
|
+
});
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
546
596
|
if (!usesWindowsPipe) chmodSync(deps.socketPath, 0o600);
|
|
547
597
|
},
|
|
548
598
|
stop: async () => {
|
|
@@ -554,14 +604,27 @@ export function createTelegramBusLocalServer(
|
|
|
554
604
|
activeServer.close(() => resolve()),
|
|
555
605
|
);
|
|
556
606
|
}
|
|
557
|
-
if (!
|
|
607
|
+
if (!isTelegramBusPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
|
|
558
608
|
unlinkSync(deps.socketPath);
|
|
559
609
|
}
|
|
610
|
+
deps.recordTransportEvent?.(
|
|
611
|
+
"server-stopped",
|
|
612
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
613
|
+
);
|
|
560
614
|
},
|
|
561
615
|
};
|
|
562
616
|
}
|
|
563
617
|
|
|
564
|
-
|
|
618
|
+
function getTelegramBusEnvelopeDiagnostics(
|
|
619
|
+
envelope: TelegramBusEnvelope,
|
|
620
|
+
): Record<string, unknown> {
|
|
621
|
+
return {
|
|
622
|
+
envelopeKind: envelope.kind,
|
|
623
|
+
requestId: envelope.requestId,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function sendTelegramBusLocalEnvelopeOnce(
|
|
565
628
|
options: TelegramBusLocalClientOptions,
|
|
566
629
|
): Promise<TelegramBusEnvelope | undefined> {
|
|
567
630
|
const timeoutMs = options.timeoutMs ?? 1000;
|
|
@@ -578,7 +641,11 @@ export function sendTelegramBusLocalEnvelope(
|
|
|
578
641
|
};
|
|
579
642
|
const timeout = setTimeout(() => {
|
|
580
643
|
settle(() =>
|
|
581
|
-
reject(
|
|
644
|
+
reject(
|
|
645
|
+
createTelegramBusTransportTimeoutError(
|
|
646
|
+
"Timed out waiting for Telegram bus response",
|
|
647
|
+
),
|
|
648
|
+
),
|
|
582
649
|
);
|
|
583
650
|
}, timeoutMs);
|
|
584
651
|
timeout.unref?.();
|
|
@@ -598,6 +665,39 @@ export function sendTelegramBusLocalEnvelope(
|
|
|
598
665
|
});
|
|
599
666
|
}
|
|
600
667
|
|
|
668
|
+
export async function sendTelegramBusLocalEnvelope(
|
|
669
|
+
options: TelegramBusLocalClientOptions,
|
|
670
|
+
): Promise<TelegramBusEnvelope | undefined> {
|
|
671
|
+
const attempts = Math.max(1, options.retry?.attempts ?? 1);
|
|
672
|
+
const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
|
|
673
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
674
|
+
try {
|
|
675
|
+
return await sendTelegramBusLocalEnvelopeOnce(options);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const info = classifyTelegramBusTransportError(error);
|
|
678
|
+
options.recordTransportEvent?.("client-failed", {
|
|
679
|
+
...getTelegramBusEndpointDiagnostics(options.socketPath),
|
|
680
|
+
...getTelegramBusEnvelopeDiagnostics(options.envelope),
|
|
681
|
+
attempt,
|
|
682
|
+
attempts,
|
|
683
|
+
...info,
|
|
684
|
+
});
|
|
685
|
+
if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
options.recordTransportEvent?.("client-retry", {
|
|
689
|
+
...getTelegramBusEndpointDiagnostics(options.socketPath),
|
|
690
|
+
...getTelegramBusEnvelopeDiagnostics(options.envelope),
|
|
691
|
+
attempt,
|
|
692
|
+
attempts,
|
|
693
|
+
delayMs,
|
|
694
|
+
...info,
|
|
695
|
+
});
|
|
696
|
+
await delayTelegramBusTransportRetry(delayMs);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
601
701
|
export interface TelegramBusFollowerRegistry {
|
|
602
702
|
register: (
|
|
603
703
|
registration: TelegramBusInstanceRegistration,
|
|
@@ -610,6 +710,7 @@ export interface TelegramBusFollowerRegistry {
|
|
|
610
710
|
getByTarget: (target: TelegramTarget) => TelegramBusFollowerView | undefined;
|
|
611
711
|
list: () => TelegramBusFollowerView[];
|
|
612
712
|
remove: (instanceId: string) => boolean;
|
|
713
|
+
clear: () => void;
|
|
613
714
|
pruneStale: (
|
|
614
715
|
nowMs: number,
|
|
615
716
|
staleAfterMs: number,
|
|
@@ -660,6 +761,7 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
|
|
|
660
761
|
},
|
|
661
762
|
list: () => [...followers.values()].map(clone),
|
|
662
763
|
remove: (instanceId) => followers.delete(instanceId),
|
|
764
|
+
clear: () => followers.clear(),
|
|
663
765
|
pruneStale: (nowMs, staleAfterMs) => {
|
|
664
766
|
const removed: TelegramBusFollowerView[] = [];
|
|
665
767
|
for (const [instanceId, follower] of followers.entries()) {
|
|
@@ -676,9 +778,15 @@ async function handleTelegramBusSocketLine(
|
|
|
676
778
|
line: string,
|
|
677
779
|
socket: Socket,
|
|
678
780
|
handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
|
|
781
|
+
recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
|
|
782
|
+
socketPath: string,
|
|
679
783
|
): Promise<void> {
|
|
680
784
|
const envelope = parseTelegramBusEnvelope(line);
|
|
681
785
|
if (!envelope) {
|
|
786
|
+
recordTransportEvent?.("server-invalid-envelope", {
|
|
787
|
+
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
788
|
+
byteLength: Buffer.byteLength(line),
|
|
789
|
+
});
|
|
682
790
|
socket.write(
|
|
683
791
|
encodeTelegramBusEnvelope({
|
|
684
792
|
kind: "bus.ack",
|
|
@@ -689,8 +797,24 @@ async function handleTelegramBusSocketLine(
|
|
|
689
797
|
);
|
|
690
798
|
return;
|
|
691
799
|
}
|
|
692
|
-
|
|
693
|
-
|
|
800
|
+
try {
|
|
801
|
+
const response = await handleEnvelope(envelope);
|
|
802
|
+
if (response) socket.write(encodeTelegramBusEnvelope(response));
|
|
803
|
+
} catch (error) {
|
|
804
|
+
recordTransportEvent?.("server-handler-failed", {
|
|
805
|
+
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
806
|
+
...getTelegramBusEnvelopeDiagnostics(envelope),
|
|
807
|
+
...classifyTelegramBusTransportError(error),
|
|
808
|
+
});
|
|
809
|
+
socket.write(
|
|
810
|
+
encodeTelegramBusEnvelope({
|
|
811
|
+
kind: "bus.ack",
|
|
812
|
+
requestId: envelope.requestId,
|
|
813
|
+
ok: false,
|
|
814
|
+
message: "Telegram bus handler failed.",
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
694
818
|
}
|
|
695
819
|
|
|
696
820
|
function parseRegisterEnvelope(
|
package/lib/polling.ts
CHANGED
|
@@ -20,7 +20,7 @@ const TELEGRAM_INITIAL_SYNC_LIMIT = 1;
|
|
|
20
20
|
const TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS = 0;
|
|
21
21
|
const TELEGRAM_LONG_POLL_LIMIT = 10;
|
|
22
22
|
const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
|
|
23
|
-
const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS =
|
|
23
|
+
const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
|
|
24
24
|
const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
|
|
25
25
|
const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
|
|
26
26
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
@@ -426,6 +426,12 @@ export async function probeTelegramStartupThreadCapability(
|
|
|
426
426
|
return threadModeEnabled;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
function hasTelegramClassicRestoreFailure(
|
|
430
|
+
state: TelegramThreadCapabilityState,
|
|
431
|
+
): boolean {
|
|
432
|
+
return state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false;
|
|
433
|
+
}
|
|
434
|
+
|
|
429
435
|
function hasTelegramThreadCapabilityBindings(
|
|
430
436
|
store: TelegramThreadCapabilityStore,
|
|
431
437
|
): boolean {
|
|
@@ -451,6 +457,7 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
451
457
|
await deps.topicTargetStore.load();
|
|
452
458
|
if (!deps.isBusConfigured()) return;
|
|
453
459
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
460
|
+
const previousBotState = deps.topicTargetStore.getBotState();
|
|
454
461
|
if (!threadModeEnabled) {
|
|
455
462
|
if (
|
|
456
463
|
hasTelegramThreadCapabilityBindings(deps.topicTargetStore) &&
|
|
@@ -470,11 +477,26 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
470
477
|
await deps.topicTargetStore.persist();
|
|
471
478
|
deps.setTopicModeUnavailable(true);
|
|
472
479
|
deps.stopFollowerRegistration();
|
|
473
|
-
if (
|
|
480
|
+
if (
|
|
481
|
+
deps.getPollingStartedWithTelegramBus() ||
|
|
482
|
+
hasTelegramClassicRestoreFailure(previousBotState)
|
|
483
|
+
) {
|
|
474
484
|
deps.stopLeaderHealth();
|
|
475
485
|
await deps.stopBusPolling();
|
|
476
486
|
deps.setPollingStartedWithTelegramBus(false);
|
|
477
|
-
|
|
487
|
+
try {
|
|
488
|
+
await deps.startClassicPolling(ctx);
|
|
489
|
+
} catch (classicError) {
|
|
490
|
+
deps.topicTargetStore.setBotState({
|
|
491
|
+
threadMode: "disabled",
|
|
492
|
+
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
493
|
+
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
494
|
+
});
|
|
495
|
+
await deps.topicTargetStore.persist();
|
|
496
|
+
deps.recordEvent("bus", classicError, {
|
|
497
|
+
phase: `${phase}-classic-restore`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
478
500
|
}
|
|
479
501
|
deps.updateStatus(ctx);
|
|
480
502
|
return;
|
|
@@ -509,6 +531,12 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
509
531
|
try {
|
|
510
532
|
await deps.startClassicPolling(ctx);
|
|
511
533
|
} catch (classicError) {
|
|
534
|
+
deps.topicTargetStore.setBotState({
|
|
535
|
+
threadMode: "disabled",
|
|
536
|
+
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
537
|
+
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
538
|
+
});
|
|
539
|
+
await deps.topicTargetStore.persist();
|
|
512
540
|
deps.recordEvent("bus", classicError, {
|
|
513
541
|
phase: `${phase}-classic-restore`,
|
|
514
542
|
});
|
|
@@ -583,6 +611,19 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
|
|
|
583
611
|
): Promise<boolean | undefined> => {
|
|
584
612
|
await deps.topicTargetStore.load();
|
|
585
613
|
if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
|
|
614
|
+
if (hasTelegramThreadCapabilityBindings(deps.topicTargetStore)) {
|
|
615
|
+
deps.recordEvent(
|
|
616
|
+
"bus",
|
|
617
|
+
"Telegram Threaded Mode disabled; follower takeover blocked",
|
|
618
|
+
{
|
|
619
|
+
phase: "follower-register-thread-mode-disabled",
|
|
620
|
+
reason: "active-thread-bindings-present",
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
throw new Error(
|
|
624
|
+
"Telegram Threaded Mode is disabled; the current leader remains the classic polling owner.",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
586
627
|
return undefined;
|
|
587
628
|
}
|
|
588
629
|
if (!deps.isBusRuntimeEnabled()) return undefined;
|
|
@@ -653,9 +694,21 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
653
694
|
return;
|
|
654
695
|
}
|
|
655
696
|
if (threadModeEnabled) consecutiveDisabledProbes = 0;
|
|
656
|
-
const
|
|
697
|
+
const botState = deps.topicTargetStore.getBotState();
|
|
698
|
+
const current = botState.threadMode;
|
|
657
699
|
if (threadModeEnabled && current === "enabled") return;
|
|
658
|
-
if (!threadModeEnabled && current === "disabled")
|
|
700
|
+
if (!threadModeEnabled && current === "disabled") {
|
|
701
|
+
if (!deps.ownsLock(ctx) || !hasTelegramClassicRestoreFailure(botState)) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
await applyTelegramThreadCapability(
|
|
705
|
+
ctx,
|
|
706
|
+
false,
|
|
707
|
+
"capability-monitor-disabled-confirmed",
|
|
708
|
+
deps,
|
|
709
|
+
);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
659
712
|
if (
|
|
660
713
|
!threadModeEnabled &&
|
|
661
714
|
hasTelegramThreadCapabilityBindings(deps.topicTargetStore)
|
package/lib/preview.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "./target.ts";
|
|
13
13
|
import { shouldSuppressPreviewForVoice } from "./voice.ts";
|
|
14
14
|
|
|
15
|
-
const TELEGRAM_PREVIEW_THROTTLE_MS = 0;
|
|
16
15
|
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
|
|
17
16
|
const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
|
|
18
17
|
|
|
@@ -26,7 +25,6 @@ export interface TelegramPreviewState {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
|
|
29
|
-
flushTimer?: ReturnType<typeof setTimeout>;
|
|
30
28
|
flushPromise?: Promise<void>;
|
|
31
29
|
flushRequested?: boolean;
|
|
32
30
|
}
|
|
@@ -36,7 +34,6 @@ export type TelegramPreviewReplyMarkup = unknown;
|
|
|
36
34
|
export interface TelegramPreviewRuntimeDeps {
|
|
37
35
|
getState: () => TelegramPreviewRuntimeState | undefined;
|
|
38
36
|
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
|
|
39
|
-
clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
|
|
40
37
|
maxMessageLength: number;
|
|
41
38
|
getDraftSupport: () => TelegramDraftSupport;
|
|
42
39
|
setDraftSupport: (support: TelegramDraftSupport) => void;
|
|
@@ -132,13 +129,7 @@ export interface TelegramPreviewControllerDeps {
|
|
|
132
129
|
},
|
|
133
130
|
) => Promise<unknown>;
|
|
134
131
|
canSend?: () => boolean;
|
|
135
|
-
throttleMs?: number;
|
|
136
132
|
maxDraftId?: number;
|
|
137
|
-
setTimer?: (
|
|
138
|
-
callback: () => void,
|
|
139
|
-
ms: number,
|
|
140
|
-
) => ReturnType<typeof setTimeout>;
|
|
141
|
-
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
142
133
|
recordRuntimeEvent?: (
|
|
143
134
|
category: string,
|
|
144
135
|
error: unknown,
|
|
@@ -154,7 +145,7 @@ export interface TelegramPreviewController {
|
|
|
154
145
|
resetState: () => void;
|
|
155
146
|
clear: (
|
|
156
147
|
chatId: number,
|
|
157
|
-
options?: { target?: TelegramTarget },
|
|
148
|
+
options?: { awaitFlush?: boolean; target?: TelegramTarget },
|
|
158
149
|
) => Promise<void>;
|
|
159
150
|
flush: (
|
|
160
151
|
chatId: number,
|
|
@@ -171,7 +162,8 @@ export interface TelegramPreviewController {
|
|
|
171
162
|
) => Promise<boolean>;
|
|
172
163
|
}
|
|
173
164
|
|
|
174
|
-
export type TelegramPreviewControllerRuntimeDeps =
|
|
165
|
+
export type TelegramPreviewControllerRuntimeDeps =
|
|
166
|
+
TelegramPreviewControllerDeps;
|
|
175
167
|
|
|
176
168
|
export function createTelegramPreviewControllerRuntime(
|
|
177
169
|
deps: TelegramPreviewControllerRuntimeDeps,
|
|
@@ -181,10 +173,7 @@ export function createTelegramPreviewControllerRuntime(
|
|
|
181
173
|
maxMessageLength: deps.maxMessageLength,
|
|
182
174
|
initialDraftSupport: deps.initialDraftSupport,
|
|
183
175
|
sendDraft: deps.sendDraft,
|
|
184
|
-
throttleMs: deps.throttleMs,
|
|
185
176
|
maxDraftId: deps.maxDraftId,
|
|
186
|
-
setTimer: deps.setTimer,
|
|
187
|
-
clearTimer: deps.clearTimer,
|
|
188
177
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
189
178
|
});
|
|
190
179
|
}
|
|
@@ -223,7 +212,7 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
|
|
|
223
212
|
getState: () => TelegramPreviewRuntimeState | undefined;
|
|
224
213
|
clear: (
|
|
225
214
|
chatId: number,
|
|
226
|
-
options?: { target?: TelegramTarget },
|
|
215
|
+
options?: { awaitFlush?: boolean; target?: TelegramTarget },
|
|
227
216
|
) => Promise<void>;
|
|
228
217
|
discard?: () => void;
|
|
229
218
|
sendMarkdownReply: (
|
|
@@ -283,12 +272,6 @@ export function createTelegramPreviewController(
|
|
|
283
272
|
deps: TelegramPreviewControllerDeps,
|
|
284
273
|
): TelegramPreviewController {
|
|
285
274
|
let state: TelegramPreviewRuntimeState | undefined;
|
|
286
|
-
const clearTimer = deps.clearTimer ?? clearTimeout;
|
|
287
|
-
const setTimer =
|
|
288
|
-
deps.setTimer ??
|
|
289
|
-
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
290
|
-
setTimeout(callback, ms));
|
|
291
|
-
const throttleMs = deps.throttleMs ?? TELEGRAM_PREVIEW_THROTTLE_MS;
|
|
292
275
|
const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
|
|
293
276
|
const maxMessageLength =
|
|
294
277
|
deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
|
|
@@ -299,11 +282,6 @@ export function createTelegramPreviewController(
|
|
|
299
282
|
setState: (nextState) => {
|
|
300
283
|
state = nextState;
|
|
301
284
|
},
|
|
302
|
-
clearScheduledFlush: (nextState) => {
|
|
303
|
-
if (!nextState.flushTimer) return;
|
|
304
|
-
clearTimer(nextState.flushTimer);
|
|
305
|
-
nextState.flushTimer = undefined;
|
|
306
|
-
},
|
|
307
285
|
maxMessageLength,
|
|
308
286
|
getDraftSupport: () => draftSupport,
|
|
309
287
|
setDraftSupport: (support) => {
|
|
@@ -334,15 +312,8 @@ export function createTelegramPreviewController(
|
|
|
334
312
|
flush: (chatId, options) =>
|
|
335
313
|
flushTelegramPreview(chatId, getRuntimeDeps(), options),
|
|
336
314
|
scheduleFlush: (chatId, options) => {
|
|
337
|
-
if (!state
|
|
338
|
-
|
|
339
|
-
void flushTelegramPreview(chatId, getRuntimeDeps(), options);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
state.flushTimer = setTimer(() => {
|
|
343
|
-
void flushTelegramPreview(chatId, getRuntimeDeps(), options);
|
|
344
|
-
}, throttleMs);
|
|
345
|
-
state.flushTimer.unref?.();
|
|
315
|
+
if (!state) return;
|
|
316
|
+
void flushTelegramPreview(chatId, getRuntimeDeps(), options);
|
|
346
317
|
},
|
|
347
318
|
finalize: (chatId, _replyToMessageId, options) =>
|
|
348
319
|
finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
|
|
@@ -464,7 +435,6 @@ export async function clearTelegramPreview(
|
|
|
464
435
|
): Promise<void> {
|
|
465
436
|
const state = deps.getState();
|
|
466
437
|
if (!state) return;
|
|
467
|
-
deps.clearScheduledFlush(state);
|
|
468
438
|
if (state.flushPromise && options.awaitFlush !== false) {
|
|
469
439
|
state.flushRequested = false;
|
|
470
440
|
await state.flushPromise.catch(() => {});
|
|
@@ -699,6 +669,10 @@ function findSafeTelegramRichMarkdownDraftEnd(markdown: string): number {
|
|
|
699
669
|
return safeEnd;
|
|
700
670
|
}
|
|
701
671
|
|
|
672
|
+
function hasTelegramPreviewVisibleContent(markdown: string): boolean {
|
|
673
|
+
return /[\p{L}\p{N}]/u.test(markdown);
|
|
674
|
+
}
|
|
675
|
+
|
|
702
676
|
export function getSafeTelegramRichMarkdownDraftPrefix(
|
|
703
677
|
markdown: string,
|
|
704
678
|
maxMessageLength: number,
|
|
@@ -710,13 +684,21 @@ export function getSafeTelegramRichMarkdownDraftPrefix(
|
|
|
710
684
|
? source.slice(0, maxMessageLength)
|
|
711
685
|
: source;
|
|
712
686
|
const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
|
|
713
|
-
if (safeEnd > 0)
|
|
687
|
+
if (safeEnd > 0) {
|
|
688
|
+
const safePrefix = limited.slice(0, safeEnd).trimEnd();
|
|
689
|
+
return hasTelegramPreviewVisibleContent(safePrefix)
|
|
690
|
+
? safePrefix
|
|
691
|
+
: undefined;
|
|
692
|
+
}
|
|
714
693
|
let candidateEnd = limited.length;
|
|
715
694
|
while (candidateEnd > 0) {
|
|
716
695
|
candidateEnd = limited.lastIndexOf(" ", candidateEnd - 1);
|
|
717
696
|
if (candidateEnd <= 0) return undefined;
|
|
718
697
|
const candidate = limited.slice(0, candidateEnd).trimEnd();
|
|
719
|
-
if (
|
|
698
|
+
if (
|
|
699
|
+
hasTelegramPreviewVisibleContent(candidate) &&
|
|
700
|
+
findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length
|
|
701
|
+
) {
|
|
720
702
|
return candidate || undefined;
|
|
721
703
|
}
|
|
722
704
|
}
|
|
@@ -795,7 +777,6 @@ export async function flushTelegramPreview(
|
|
|
795
777
|
await state.flushPromise;
|
|
796
778
|
return;
|
|
797
779
|
}
|
|
798
|
-
state.flushTimer = undefined;
|
|
799
780
|
state.flushPromise = (async () => {
|
|
800
781
|
do {
|
|
801
782
|
state.flushRequested = false;
|
package/lib/runtime-log.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
* Owns session-local append-only runtime evidence for debugging without becoming routing state
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
appendFile,
|
|
14
|
+
} from "node:fs";
|
|
8
15
|
import { homedir } from "node:os";
|
|
9
16
|
import { dirname, join, resolve } from "node:path";
|
|
10
17
|
|
|
@@ -17,6 +24,7 @@ export interface TelegramRuntimeJsonlEvent {
|
|
|
17
24
|
|
|
18
25
|
export interface TelegramRuntimeJsonlLogOptions {
|
|
19
26
|
path?: string;
|
|
27
|
+
previousPath?: string;
|
|
20
28
|
maxBytes?: number;
|
|
21
29
|
getNowMs?: () => number;
|
|
22
30
|
}
|
|
@@ -57,6 +65,8 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
57
65
|
options: TelegramRuntimeJsonlLogOptions = {},
|
|
58
66
|
): TelegramRuntimeJsonlLog {
|
|
59
67
|
const path = options.path ?? getTelegramRuntimeLogPath();
|
|
68
|
+
const previousPath =
|
|
69
|
+
options.previousPath ?? path.replace(/\.jsonl$/u, ".previous.jsonl");
|
|
60
70
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
|
|
61
71
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
62
72
|
let scopeKey: string | undefined;
|
|
@@ -66,8 +76,15 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
66
76
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
67
77
|
};
|
|
68
78
|
|
|
79
|
+
const preserveCurrentLog = () => {
|
|
80
|
+
if (!existsSync(path)) return;
|
|
81
|
+
mkdirSync(dirname(previousPath), { recursive: true, mode: 0o700 });
|
|
82
|
+
copyFileSync(path, previousPath);
|
|
83
|
+
};
|
|
84
|
+
|
|
69
85
|
const writeReset = (reason: string, scope?: Record<string, unknown>) => {
|
|
70
86
|
ensureParent();
|
|
87
|
+
preserveCurrentLog();
|
|
71
88
|
writeFileSync(
|
|
72
89
|
path,
|
|
73
90
|
safeJsonLine({
|
|
@@ -75,6 +92,7 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
75
92
|
kind: "reset",
|
|
76
93
|
reason,
|
|
77
94
|
scope,
|
|
95
|
+
previousPath,
|
|
78
96
|
}) + "\n",
|
|
79
97
|
{ mode: 0o600 },
|
|
80
98
|
);
|