@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/activity.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram activity lifecycle normalization and extension dispatch
|
|
3
3
|
* Zones: pi agent lifecycle, extension API, operational delivery
|
|
4
|
-
* Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring,
|
|
4
|
+
* Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, ordered public-output projection, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring, Telegram rendering implementation, raw transport clients, and consumer-extension behavior
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -91,6 +91,9 @@ export type TelegramActivityPayload =
|
|
|
91
91
|
export type TelegramActivityEvent = TelegramActivityEnvelope &
|
|
92
92
|
TelegramActivityPayload;
|
|
93
93
|
|
|
94
|
+
export type TelegramAssistantSegmentEvent = TelegramActivityEnvelope &
|
|
95
|
+
Extract<TelegramActivityPayload, { type: "assistant-segment" }>;
|
|
96
|
+
|
|
94
97
|
export interface TelegramActivityContext {
|
|
95
98
|
activityId: string;
|
|
96
99
|
sequence: number;
|
|
@@ -383,6 +386,7 @@ export function createTelegramActivityDispatcher(deps: {
|
|
|
383
386
|
/** @internal */
|
|
384
387
|
export function createTelegramActivityBridgeRuntime(deps: {
|
|
385
388
|
generation: string;
|
|
389
|
+
observeEvent?: (event: TelegramActivityEvent) => void;
|
|
386
390
|
recordFailure?: (
|
|
387
391
|
handlerId: string,
|
|
388
392
|
event: TelegramActivityEvent,
|
|
@@ -401,6 +405,11 @@ export function createTelegramActivityBridgeRuntime(deps: {
|
|
|
401
405
|
dispatcher: createTelegramActivityDispatcher({
|
|
402
406
|
recordFailure: deps.recordFailure,
|
|
403
407
|
}),
|
|
408
|
+
observeEvent: deps.observeEvent,
|
|
409
|
+
recordObserverFailure: deps.recordFailure
|
|
410
|
+
? (event, error) =>
|
|
411
|
+
deps.recordFailure!("@llblab/pi-telegram/proactive", event, error)
|
|
412
|
+
: undefined,
|
|
404
413
|
now: deps.now,
|
|
405
414
|
});
|
|
406
415
|
},
|
|
@@ -507,6 +516,11 @@ interface PendingAssistantSegment {
|
|
|
507
516
|
export function createTelegramActivityRuntime(deps: {
|
|
508
517
|
generation: string;
|
|
509
518
|
dispatcher: TelegramActivityDispatcher;
|
|
519
|
+
observeEvent?: (event: TelegramActivityEvent) => void;
|
|
520
|
+
recordObserverFailure?: (
|
|
521
|
+
event: TelegramActivityEvent,
|
|
522
|
+
error: unknown,
|
|
523
|
+
) => void;
|
|
510
524
|
now?: () => number;
|
|
511
525
|
}): TelegramActivityRuntime {
|
|
512
526
|
const now = deps.now ?? Date.now;
|
|
@@ -542,14 +556,20 @@ export function createTelegramActivityRuntime(deps: {
|
|
|
542
556
|
const emit = (event: TelegramActivityPayload): void => {
|
|
543
557
|
const currentActivityId = ensureActivity();
|
|
544
558
|
sequence += 1;
|
|
545
|
-
|
|
559
|
+
const normalizedEvent = {
|
|
546
560
|
...event,
|
|
547
561
|
activityId: currentActivityId,
|
|
548
562
|
sequence,
|
|
549
563
|
source: activitySource,
|
|
550
564
|
...(activityTarget ? { target: activityTarget } : {}),
|
|
551
565
|
timestamp: now(),
|
|
552
|
-
} as TelegramActivityEvent
|
|
566
|
+
} as TelegramActivityEvent;
|
|
567
|
+
try {
|
|
568
|
+
deps.observeEvent?.(normalizedEvent);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
deps.recordObserverFailure?.(normalizedEvent, error);
|
|
571
|
+
}
|
|
572
|
+
deps.dispatcher.dispatch(normalizedEvent);
|
|
553
573
|
};
|
|
554
574
|
const flushPendingSegment = (
|
|
555
575
|
placement: "intermediate" | "final" | "terminal-partial",
|
|
@@ -681,3 +701,80 @@ export function createTelegramActivityRuntime(deps: {
|
|
|
681
701
|
},
|
|
682
702
|
};
|
|
683
703
|
}
|
|
704
|
+
|
|
705
|
+
// --- Public Assistant Output Projection ---
|
|
706
|
+
|
|
707
|
+
export interface TelegramAssistantOutputRuntime {
|
|
708
|
+
start: () => void;
|
|
709
|
+
accept: (event: TelegramAssistantSegmentEvent) => void;
|
|
710
|
+
waitForIdle: () => Promise<void>;
|
|
711
|
+
stop: () => void;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(deps: {
|
|
715
|
+
isEnabled: () => boolean;
|
|
716
|
+
captureAuthority?: () => TAuthority;
|
|
717
|
+
isAuthorityActive?: (authority: TAuthority) => boolean;
|
|
718
|
+
canDeliver: (event: TelegramAssistantSegmentEvent) => boolean;
|
|
719
|
+
send: (
|
|
720
|
+
event: TelegramAssistantSegmentEvent,
|
|
721
|
+
authority: TAuthority,
|
|
722
|
+
isAuthorityActive: () => boolean,
|
|
723
|
+
) => Promise<void>;
|
|
724
|
+
recordFailure?: (
|
|
725
|
+
event: TelegramAssistantSegmentEvent,
|
|
726
|
+
error: unknown,
|
|
727
|
+
) => void;
|
|
728
|
+
}): TelegramAssistantOutputRuntime {
|
|
729
|
+
let generation = 0;
|
|
730
|
+
let running = false;
|
|
731
|
+
let tail: Promise<void> = Promise.resolve();
|
|
732
|
+
const admitted = new Set<string>();
|
|
733
|
+
const isEligibleSource = (
|
|
734
|
+
source: TelegramAssistantSegmentEvent["source"],
|
|
735
|
+
): boolean => source === "local" || source === "autonomous";
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
start() {
|
|
739
|
+
generation += 1;
|
|
740
|
+
running = true;
|
|
741
|
+
admitted.clear();
|
|
742
|
+
tail = Promise.resolve();
|
|
743
|
+
},
|
|
744
|
+
accept(event) {
|
|
745
|
+
if (!running || !deps.isEnabled()) return;
|
|
746
|
+
if (!isEligibleSource(event.source) || !event.text.trim()) return;
|
|
747
|
+
const key = `${event.activityId}:${event.sequence}`;
|
|
748
|
+
if (admitted.has(key)) return;
|
|
749
|
+
admitted.add(key);
|
|
750
|
+
const admittedGeneration = generation;
|
|
751
|
+
const admittedAuthority = deps.captureAuthority?.();
|
|
752
|
+
tail = tail.then(async () => {
|
|
753
|
+
const isAdmittedAuthorityActive = () =>
|
|
754
|
+
running &&
|
|
755
|
+
generation === admittedGeneration &&
|
|
756
|
+
deps.isEnabled() &&
|
|
757
|
+
(deps.isAuthorityActive === undefined ||
|
|
758
|
+
deps.isAuthorityActive(admittedAuthority as TAuthority));
|
|
759
|
+
if (!isAdmittedAuthorityActive() || !deps.canDeliver(event)) return;
|
|
760
|
+
try {
|
|
761
|
+
await deps.send(
|
|
762
|
+
event,
|
|
763
|
+
admittedAuthority as TAuthority,
|
|
764
|
+
isAdmittedAuthorityActive,
|
|
765
|
+
);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
deps.recordFailure?.(event, error);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
waitForIdle() {
|
|
772
|
+
return tail;
|
|
773
|
+
},
|
|
774
|
+
stop() {
|
|
775
|
+
generation += 1;
|
|
776
|
+
running = false;
|
|
777
|
+
admitted.clear();
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}
|
package/lib/bindings.ts
CHANGED
|
@@ -19,6 +19,7 @@ import * as Preview from "./preview.ts";
|
|
|
19
19
|
import * as Prompts from "./prompts.ts";
|
|
20
20
|
import * as Queue from "./queue.ts";
|
|
21
21
|
import * as Replies from "./replies.ts";
|
|
22
|
+
import * as Routing from "./routing.ts";
|
|
22
23
|
import * as Runtime from "./runtime.ts";
|
|
23
24
|
import * as Setup from "./setup.ts";
|
|
24
25
|
import * as Status from "./status.ts";
|
|
@@ -35,6 +36,60 @@ type TelegramRuntimeEventRecorder = (
|
|
|
35
36
|
type TelegramBridgeStatusUpdater =
|
|
36
37
|
Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
|
|
37
38
|
|
|
39
|
+
export interface TelegramAssistantOutputBindingRuntime<
|
|
40
|
+
TTransportStamp,
|
|
41
|
+
> {
|
|
42
|
+
runtime: Activity.TelegramAssistantOutputRuntime;
|
|
43
|
+
observeEvent: (event: Activity.TelegramActivityEvent) => void;
|
|
44
|
+
authority: Routing.TelegramAssistantOutputAuthorityRuntime<TTransportStamp>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createTelegramAssistantOutputBindingRuntime<
|
|
48
|
+
TTransportStamp,
|
|
49
|
+
>(deps: {
|
|
50
|
+
isEnabled: () => boolean;
|
|
51
|
+
authority: {
|
|
52
|
+
getPreferredTarget: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"] | undefined;
|
|
53
|
+
getFallbackChatId: () => number | undefined;
|
|
54
|
+
getTransportStamp: () => TTransportStamp;
|
|
55
|
+
isTransportStampActive: (stamp: TTransportStamp) => boolean;
|
|
56
|
+
ownsDirect: () => boolean;
|
|
57
|
+
getDirectEpoch: () => number | string | undefined;
|
|
58
|
+
isFollowerRegistered: () => boolean;
|
|
59
|
+
getFollowerGeneration: () => string | undefined;
|
|
60
|
+
};
|
|
61
|
+
sender: Parameters<
|
|
62
|
+
typeof OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>
|
|
63
|
+
>[0];
|
|
64
|
+
recordRuntimeEvent: TelegramRuntimeEventRecorder;
|
|
65
|
+
}): TelegramAssistantOutputBindingRuntime<TTransportStamp> {
|
|
66
|
+
const authority =
|
|
67
|
+
Routing.createTelegramAssistantOutputAuthorityRuntime(deps.authority);
|
|
68
|
+
const send =
|
|
69
|
+
OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>(
|
|
70
|
+
deps.sender,
|
|
71
|
+
);
|
|
72
|
+
const runtime = Activity.createTelegramAssistantOutputRuntime({
|
|
73
|
+
isEnabled: deps.isEnabled,
|
|
74
|
+
...authority,
|
|
75
|
+
send,
|
|
76
|
+
recordFailure(event, error) {
|
|
77
|
+
deps.recordRuntimeEvent("proactive-push", error, {
|
|
78
|
+
activityId: event.activityId,
|
|
79
|
+
sequence: event.sequence,
|
|
80
|
+
placement: event.placement,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
runtime,
|
|
86
|
+
authority,
|
|
87
|
+
observeEvent(event) {
|
|
88
|
+
if (event.type === "assistant-segment") runtime.accept(event);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
38
93
|
interface TelegramCommandsAndToolsBindingDeps {
|
|
39
94
|
pi: Pi.ExtensionAPI;
|
|
40
95
|
configStore: Config.TelegramConfigStore;
|
|
@@ -43,6 +98,7 @@ interface TelegramCommandsAndToolsBindingDeps {
|
|
|
43
98
|
activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
|
|
44
99
|
lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
|
|
45
100
|
stopPolling?: () => Promise<void | string>;
|
|
101
|
+
getDisconnectThreadName?: () => string | undefined;
|
|
46
102
|
onTransportChanged?: () => Promise<void> | void;
|
|
47
103
|
getStatusLines: (
|
|
48
104
|
options?: Status.TelegramBridgeStatusLineOptions,
|
|
@@ -70,6 +126,7 @@ export function registerTelegramCommandsAndTools({
|
|
|
70
126
|
activeTurnRuntime,
|
|
71
127
|
lockedPollingRuntime,
|
|
72
128
|
stopPolling,
|
|
129
|
+
getDisconnectThreadName,
|
|
73
130
|
onTransportChanged,
|
|
74
131
|
getStatusLines,
|
|
75
132
|
buttonActionStore,
|
|
@@ -186,6 +243,7 @@ export function registerTelegramCommandsAndTools({
|
|
|
186
243
|
hasBotToken: configStore.hasBotToken,
|
|
187
244
|
startPolling: lockedPollingRuntime.start,
|
|
188
245
|
stopPolling: stopPolling ?? lockedPollingRuntime.stop,
|
|
246
|
+
getDisconnectThreadName,
|
|
189
247
|
updateStatus,
|
|
190
248
|
getProfileNames: () =>
|
|
191
249
|
Config.getTelegramProfileNames(configStore.getStoredConfig()),
|
|
@@ -217,6 +275,10 @@ export function registerTelegramCommandsAndTools({
|
|
|
217
275
|
interface TelegramLifecycleBindingDeps {
|
|
218
276
|
pi: Pi.ExtensionAPI;
|
|
219
277
|
activityRuntime: Activity.TelegramActivityRuntime;
|
|
278
|
+
assistantOutputRuntime: Pick<
|
|
279
|
+
Activity.TelegramAssistantOutputRuntime,
|
|
280
|
+
"start" | "stop"
|
|
281
|
+
>;
|
|
220
282
|
sessionLifecycleRuntime: Pick<
|
|
221
283
|
Lifecycle.TelegramLifecycleRegistrationDeps,
|
|
222
284
|
"onSessionStart" | "onSessionShutdown" | "onModelSelect"
|
|
@@ -285,7 +347,12 @@ interface TelegramLifecycleBindingDeps {
|
|
|
285
347
|
proactivePushChatIdGetter: () => number | undefined;
|
|
286
348
|
proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
|
|
287
349
|
isProactivePushEnabled: () => boolean;
|
|
288
|
-
|
|
350
|
+
getAssistantRenderingMode: () => "rich" | "html";
|
|
351
|
+
recordMessageOwnership?: (input: {
|
|
352
|
+
chatId: number;
|
|
353
|
+
messageId: number;
|
|
354
|
+
target?: Queue.TelegramQueueTarget;
|
|
355
|
+
}) => void;
|
|
289
356
|
canSendAgentActivity: (ctx: Pi.ExtensionContext) => boolean;
|
|
290
357
|
isSessionContextActive: (ctx: Pi.ExtensionContext) => boolean;
|
|
291
358
|
isTurnTransportActive?: (turn: Queue.PendingTelegramTurn) => boolean;
|
|
@@ -296,6 +363,7 @@ interface TelegramLifecycleBindingDeps {
|
|
|
296
363
|
export function registerTelegramLifecycleRuntimeHooks({
|
|
297
364
|
pi,
|
|
298
365
|
activityRuntime,
|
|
366
|
+
assistantOutputRuntime,
|
|
299
367
|
sessionLifecycleRuntime,
|
|
300
368
|
configStore,
|
|
301
369
|
abort,
|
|
@@ -322,7 +390,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
322
390
|
proactivePushChatIdGetter,
|
|
323
391
|
proactivePushTargetGetter,
|
|
324
392
|
isProactivePushEnabled,
|
|
325
|
-
|
|
393
|
+
getAssistantRenderingMode,
|
|
394
|
+
recordMessageOwnership,
|
|
326
395
|
canSendAgentActivity,
|
|
327
396
|
isSessionContextActive = () => true,
|
|
328
397
|
isTurnTransportActive,
|
|
@@ -343,6 +412,13 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
343
412
|
sendTextReply,
|
|
344
413
|
recordRuntimeEvent,
|
|
345
414
|
});
|
|
415
|
+
const richAttachmentSender =
|
|
416
|
+
OutboundAttachments.createTelegramRichOutboundAttachmentSender({
|
|
417
|
+
sendMultipart: callMultipart,
|
|
418
|
+
getRenderingMode: getAssistantRenderingMode,
|
|
419
|
+
recordOwnership: recordMessageOwnership,
|
|
420
|
+
recordRuntimeEvent,
|
|
421
|
+
});
|
|
346
422
|
const sendGuestAttachment = async (
|
|
347
423
|
turn: Queue.PendingTelegramTurn,
|
|
348
424
|
attachment: Queue.QueuedAttachment,
|
|
@@ -499,16 +575,13 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
499
575
|
sendMarkdownReply,
|
|
500
576
|
sendTextReply,
|
|
501
577
|
sendQueuedAttachments: queuedAttachmentSender,
|
|
578
|
+
sendRichAttachmentReply: richAttachmentSender,
|
|
502
579
|
answerGuestQuery,
|
|
503
580
|
sendGuestReply,
|
|
504
581
|
sendGuestAttachment,
|
|
505
582
|
sendGuestVoiceReply,
|
|
506
583
|
planOutboundReply: outboundReplyPlanner,
|
|
507
584
|
sendOutboundReplyArtifacts: outboundReplyArtifactSender,
|
|
508
|
-
getDefaultChatId: proactivePushChatIdGetter,
|
|
509
|
-
getDefaultTarget: proactivePushTargetGetter,
|
|
510
|
-
isProactivePushEnabled,
|
|
511
|
-
canSendProactivePush,
|
|
512
585
|
recordRuntimeEvent,
|
|
513
586
|
getActiveToolExecutions: lifecycle.getActiveToolExecutions,
|
|
514
587
|
setActiveToolExecutions: lifecycle.setActiveToolExecutions,
|
|
@@ -565,12 +638,14 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
565
638
|
},
|
|
566
639
|
async onSessionStart(event, ctx) {
|
|
567
640
|
previewRuntime.invalidate();
|
|
641
|
+
assistantOutputRuntime.start();
|
|
568
642
|
activityRuntime.onSessionStart?.();
|
|
569
643
|
await sessionLifecycleRuntime.onSessionStart(event, ctx);
|
|
570
644
|
},
|
|
571
645
|
async onSessionShutdown(event, ctx) {
|
|
572
646
|
if (!isSessionContextActive(ctx)) return;
|
|
573
647
|
activityRuntime.onSessionShutdown();
|
|
648
|
+
assistantOutputRuntime.stop();
|
|
574
649
|
compactionObserver.onSessionShutdown();
|
|
575
650
|
await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
|
|
576
651
|
},
|
package/lib/bus-follower.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
|
98
98
|
options?: { target?: TelegramTarget },
|
|
99
99
|
) => Promise<boolean>;
|
|
100
100
|
setContext: (ctx: TContext) => void;
|
|
101
|
+
disconnectFromLeader?: () => Promise<boolean>;
|
|
101
102
|
stop: () => void;
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -108,6 +109,10 @@ export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
|
|
|
108
109
|
>;
|
|
109
110
|
instanceId: string;
|
|
110
111
|
suspendPolling: () => Promise<void>;
|
|
112
|
+
isLeader?: () => boolean;
|
|
113
|
+
getLeaderBinding?: () => TelegramBusFollowerPromotedBinding | undefined;
|
|
114
|
+
getActiveContext?: () => { cwd?: string } | undefined;
|
|
115
|
+
getActiveProfileName?: () => string | undefined;
|
|
111
116
|
recordRuntimeEvent: (
|
|
112
117
|
category: string,
|
|
113
118
|
error: unknown,
|
|
@@ -139,6 +144,8 @@ export interface TelegramBusFollowerRegistrationState {
|
|
|
139
144
|
getSlot: () => string | undefined;
|
|
140
145
|
getThreadName: () => string | undefined;
|
|
141
146
|
getGeneration: () => string | undefined;
|
|
147
|
+
getEligibleElectionSlots: () => readonly string[];
|
|
148
|
+
setEligibleElectionSlots: (slots: readonly string[]) => void;
|
|
142
149
|
setRegistered: (
|
|
143
150
|
registered: boolean,
|
|
144
151
|
target?: TelegramTarget,
|
|
@@ -151,12 +158,20 @@ export interface TelegramBusForwardedUpdateReceiverRuntime {
|
|
|
151
158
|
stop: () => Promise<void>;
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
export interface TelegramBusFollowerClientRuntimeDeps {
|
|
161
|
+
export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
|
|
155
162
|
socketPath: TelegramBusSocketPathSource;
|
|
156
163
|
instanceId: string;
|
|
157
164
|
getApiAuthSecret?: () => string | undefined;
|
|
158
165
|
getForwardingAuthSecret?: () => string | undefined;
|
|
159
166
|
getRegistrationGeneration?: () => string | undefined;
|
|
167
|
+
getForwardCommentBatchPosition?: (
|
|
168
|
+
message: TMessage,
|
|
169
|
+
) => "comment" | "forward" | undefined;
|
|
170
|
+
recordRuntimeEvent?: (
|
|
171
|
+
category: string,
|
|
172
|
+
error: unknown,
|
|
173
|
+
details?: Record<string, unknown>,
|
|
174
|
+
) => void;
|
|
160
175
|
timeoutMs?: number;
|
|
161
176
|
}
|
|
162
177
|
|
|
@@ -239,9 +254,11 @@ export function createTelegramBusFollowerPromotionHandler<
|
|
|
239
254
|
error: unknown,
|
|
240
255
|
details?: Record<string, unknown>,
|
|
241
256
|
) => void;
|
|
257
|
+
getNowMs?: () => number;
|
|
258
|
+
getPid?: () => number;
|
|
242
259
|
}): TelegramBusFollowerPromotionHandler<TContext> {
|
|
243
|
-
return async (ctx, binding, election) =>
|
|
244
|
-
input.startLeader(ctx, election, async () => {
|
|
260
|
+
return async (ctx, binding, election) => {
|
|
261
|
+
const promoted = await input.startLeader(ctx, election, async () => {
|
|
245
262
|
const promotedRecord =
|
|
246
263
|
await Threads.promoteTelegramFollowerBindingToLeader({
|
|
247
264
|
store: input.topicTargetStore,
|
|
@@ -266,6 +283,39 @@ export function createTelegramBusFollowerPromotionHandler<
|
|
|
266
283
|
);
|
|
267
284
|
}
|
|
268
285
|
});
|
|
286
|
+
if (promoted && typeof binding.target?.threadId === "number") {
|
|
287
|
+
const profileKey = Threads.getTelegramThreadOwnerKey({
|
|
288
|
+
kind: "leader",
|
|
289
|
+
cwd: ctx.cwd,
|
|
290
|
+
instanceId: input.instanceId,
|
|
291
|
+
telegramProfile: input.getActiveProfileName(),
|
|
292
|
+
});
|
|
293
|
+
Threads.setTelegramLeaderSessionHandoff({
|
|
294
|
+
pid: input.getPid?.() ?? process.pid,
|
|
295
|
+
instanceId: input.instanceId,
|
|
296
|
+
createdAtMs: input.getNowMs?.() ?? Date.now(),
|
|
297
|
+
profileKey,
|
|
298
|
+
target: {
|
|
299
|
+
chatId: binding.target.chatId,
|
|
300
|
+
threadId: binding.target.threadId,
|
|
301
|
+
},
|
|
302
|
+
slot: binding.slot,
|
|
303
|
+
threadName: binding.threadName,
|
|
304
|
+
});
|
|
305
|
+
input.recordRuntimeEvent(
|
|
306
|
+
"bus",
|
|
307
|
+
"Promoted leader binding retained for session replacement",
|
|
308
|
+
{
|
|
309
|
+
phase: "follower-promoted-session-handoff",
|
|
310
|
+
chatId: binding.target.chatId,
|
|
311
|
+
threadId: binding.target.threadId,
|
|
312
|
+
slot: binding.slot,
|
|
313
|
+
threadName: binding.threadName,
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return promoted;
|
|
318
|
+
};
|
|
269
319
|
}
|
|
270
320
|
|
|
271
321
|
export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
|
|
@@ -309,7 +359,11 @@ export interface TelegramBusFollowerPromotedBinding {
|
|
|
309
359
|
export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
310
360
|
registrationState: Pick<
|
|
311
361
|
TelegramBusFollowerRegistrationState,
|
|
312
|
-
|
|
362
|
+
| "getTarget"
|
|
363
|
+
| "getSlot"
|
|
364
|
+
| "getThreadName"
|
|
365
|
+
| "getEligibleElectionSlots"
|
|
366
|
+
| "setRegistered"
|
|
313
367
|
>;
|
|
314
368
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
315
369
|
getLeaderState: () => TelegramBusFollowerLeaderState;
|
|
@@ -350,6 +404,10 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
|
|
|
350
404
|
reactionUpdate: TReactionUpdate,
|
|
351
405
|
ctx: TContext,
|
|
352
406
|
) => Promise<void> | void;
|
|
407
|
+
prepareForwardedMessage?: (
|
|
408
|
+
message: TMessage,
|
|
409
|
+
position: "comment" | "forward",
|
|
410
|
+
) => void;
|
|
353
411
|
handleForwardedMessage?: (
|
|
354
412
|
message: TMessage,
|
|
355
413
|
ctx: TContext,
|
|
@@ -564,7 +622,7 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
564
622
|
TReactionUpdate,
|
|
565
623
|
TCallbackQuery,
|
|
566
624
|
TMessage = unknown,
|
|
567
|
-
>(deps: TelegramBusFollowerClientRuntimeDeps) {
|
|
625
|
+
>(deps: TelegramBusFollowerClientRuntimeDeps<TMessage>) {
|
|
568
626
|
const createRequestId = createTelegramBusRequestIdFactory(deps.instanceId);
|
|
569
627
|
const sharedClientDeps = {
|
|
570
628
|
socketPath: deps.socketPath,
|
|
@@ -587,6 +645,9 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
587
645
|
>({
|
|
588
646
|
...sharedClientDeps,
|
|
589
647
|
getAuthSecret: deps.getForwardingAuthSecret,
|
|
648
|
+
getForwardCommentBatchPosition:
|
|
649
|
+
deps.getForwardCommentBatchPosition,
|
|
650
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
590
651
|
}),
|
|
591
652
|
targetController: createTelegramBusFollowerTargetController({
|
|
592
653
|
...sharedClientDeps,
|
|
@@ -689,6 +750,41 @@ export function createTelegramBusFollowerSessionReplacementSuspender(
|
|
|
689
750
|
threadId: target.threadId,
|
|
690
751
|
},
|
|
691
752
|
);
|
|
753
|
+
} else if (deps.isLeader?.()) {
|
|
754
|
+
const leaderBinding = deps.getLeaderBinding?.();
|
|
755
|
+
if (typeof leaderBinding?.target?.threadId === "number") {
|
|
756
|
+
const activeContext = deps.getActiveContext?.();
|
|
757
|
+
const profileKey = Threads.getTelegramThreadOwnerKey({
|
|
758
|
+
kind: "leader",
|
|
759
|
+
cwd: activeContext?.cwd,
|
|
760
|
+
instanceId: deps.instanceId,
|
|
761
|
+
telegramProfile: deps.getActiveProfileName?.(),
|
|
762
|
+
});
|
|
763
|
+
Threads.setTelegramLeaderSessionHandoff({
|
|
764
|
+
pid: getPid(),
|
|
765
|
+
instanceId: deps.instanceId,
|
|
766
|
+
createdAtMs: getNowMs(),
|
|
767
|
+
profileKey,
|
|
768
|
+
target: {
|
|
769
|
+
chatId: leaderBinding.target.chatId,
|
|
770
|
+
threadId: leaderBinding.target.threadId,
|
|
771
|
+
},
|
|
772
|
+
slot: leaderBinding.slot,
|
|
773
|
+
threadName: leaderBinding.threadName,
|
|
774
|
+
});
|
|
775
|
+
deps.recordRuntimeEvent(
|
|
776
|
+
"bus",
|
|
777
|
+
"Telegram leader binding suspended for session replacement",
|
|
778
|
+
{
|
|
779
|
+
phase: "leader-session-handoff",
|
|
780
|
+
instanceId: deps.instanceId,
|
|
781
|
+
chatId: leaderBinding.target.chatId,
|
|
782
|
+
threadId: leaderBinding.target.threadId,
|
|
783
|
+
slot: leaderBinding.slot,
|
|
784
|
+
threadName: leaderBinding.threadName,
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
}
|
|
692
788
|
}
|
|
693
789
|
await deps.suspendPolling();
|
|
694
790
|
};
|
|
@@ -751,12 +847,19 @@ export function createTelegramBusFollowerRegistrationState(): TelegramBusFollowe
|
|
|
751
847
|
let slot: string | undefined;
|
|
752
848
|
let threadName: string | undefined;
|
|
753
849
|
let generation: string | undefined;
|
|
850
|
+
let eligibleElectionSlots: string[] = [];
|
|
754
851
|
return {
|
|
755
852
|
isRegistered: () => registered,
|
|
756
853
|
getTarget: () => (target ? { ...target } : undefined),
|
|
757
854
|
getSlot: () => slot,
|
|
758
855
|
getThreadName: () => threadName,
|
|
759
856
|
getGeneration: () => generation,
|
|
857
|
+
getEligibleElectionSlots: () => [...eligibleElectionSlots],
|
|
858
|
+
setEligibleElectionSlots: (slots) => {
|
|
859
|
+
eligibleElectionSlots = Array.from(
|
|
860
|
+
new Set(slots.filter((slot) => /^[A-Z]$/.test(slot))),
|
|
861
|
+
).sort();
|
|
862
|
+
},
|
|
760
863
|
setRegistered: (next, nextTarget, metadata) => {
|
|
761
864
|
registered = next;
|
|
762
865
|
target = next ? (nextTarget ? { ...nextTarget } : undefined) : undefined;
|
|
@@ -868,7 +971,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
868
971
|
deps.getRegistrationRuntime().stop();
|
|
869
972
|
deps.setLifecyclePhase("electing");
|
|
870
973
|
safeUpdateStatus(ctx);
|
|
871
|
-
deps.recordRuntimeEvent("bus", "Telegram follower
|
|
974
|
+
deps.recordRuntimeEvent("bus", "Telegram follower attempting promotion", {
|
|
872
975
|
phase: "follower-promotion-electing",
|
|
873
976
|
});
|
|
874
977
|
const promoted = await deps.promoteToLeader(ctx, binding, election);
|
|
@@ -887,6 +990,51 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
887
990
|
);
|
|
888
991
|
if (!promoted) scheduleRecovery(reason, ctx, binding);
|
|
889
992
|
};
|
|
993
|
+
const attemptPreferredPromotion = async (
|
|
994
|
+
reason: unknown,
|
|
995
|
+
ctx: TContext,
|
|
996
|
+
binding: TelegramBusFollowerPromotedBinding,
|
|
997
|
+
candidateState: TelegramBusFollowerLeaderState,
|
|
998
|
+
): Promise<void> => {
|
|
999
|
+
const slot = binding.slot;
|
|
1000
|
+
const lowerEligibleSlot = slot
|
|
1001
|
+
? deps.registrationState
|
|
1002
|
+
.getEligibleElectionSlots()
|
|
1003
|
+
.find((candidate) => candidate < slot)
|
|
1004
|
+
: undefined;
|
|
1005
|
+
if (lowerEligibleSlot) {
|
|
1006
|
+
deps.recordRuntimeEvent(
|
|
1007
|
+
"bus",
|
|
1008
|
+
"Telegram follower deferring to a lower-slot election candidate",
|
|
1009
|
+
{
|
|
1010
|
+
phase: "follower-promotion-slot-priority",
|
|
1011
|
+
slot,
|
|
1012
|
+
lowerEligibleSlot,
|
|
1013
|
+
},
|
|
1014
|
+
);
|
|
1015
|
+
await sleep(promotionGraceMs);
|
|
1016
|
+
candidateState = deps.getLeaderState();
|
|
1017
|
+
if (candidateState.kind === "active-elsewhere") {
|
|
1018
|
+
if (
|
|
1019
|
+
!(await tryRegisterWithLeader(
|
|
1020
|
+
ctx,
|
|
1021
|
+
candidateState.lock,
|
|
1022
|
+
"follower-register-preferred-successor",
|
|
1023
|
+
binding,
|
|
1024
|
+
))
|
|
1025
|
+
) {
|
|
1026
|
+
scheduleRecovery(reason, ctx, binding);
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (candidateState.kind !== "stale" && candidateState.kind !== "inactive")
|
|
1032
|
+
return;
|
|
1033
|
+
await promoteToLeader(reason, ctx, binding, {
|
|
1034
|
+
expectedOwner:
|
|
1035
|
+
candidateState.kind === "stale" ? candidateState.lock : undefined,
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
890
1038
|
const recover = async (
|
|
891
1039
|
error: unknown,
|
|
892
1040
|
ctx: TContext,
|
|
@@ -943,19 +1091,15 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
943
1091
|
scheduleRecovery(error, ctx, initialBinding);
|
|
944
1092
|
return;
|
|
945
1093
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1094
|
+
await attemptPreferredPromotion(
|
|
1095
|
+
error,
|
|
1096
|
+
ctx,
|
|
1097
|
+
initialBinding,
|
|
1098
|
+
graceState,
|
|
1099
|
+
);
|
|
952
1100
|
return;
|
|
953
1101
|
}
|
|
954
|
-
|
|
955
|
-
await promoteToLeader(error, ctx, initialBinding, {
|
|
956
|
-
expectedOwner: state.kind === "stale" ? state.lock : undefined,
|
|
957
|
-
});
|
|
958
|
-
}
|
|
1102
|
+
await attemptPreferredPromotion(error, ctx, initialBinding, state);
|
|
959
1103
|
} catch (promotionError) {
|
|
960
1104
|
deps.setLifecyclePhase(undefined);
|
|
961
1105
|
safeUpdateStatus(ctx);
|
|
@@ -1035,6 +1179,17 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1035
1179
|
sentAtMs: getNowMs(),
|
|
1036
1180
|
},
|
|
1037
1181
|
});
|
|
1182
|
+
if (response?.kind === "bus.ack" && response.ok) {
|
|
1183
|
+
const heartbeatResult = isRecord(response.result)
|
|
1184
|
+
? response.result
|
|
1185
|
+
: undefined;
|
|
1186
|
+
const slots = Array.isArray(heartbeatResult?.eligibleElectionSlots)
|
|
1187
|
+
? heartbeatResult.eligibleElectionSlots.filter(
|
|
1188
|
+
(slot): slot is string => typeof slot === "string",
|
|
1189
|
+
)
|
|
1190
|
+
: [];
|
|
1191
|
+
deps.registrationState?.setEligibleElectionSlots(slots);
|
|
1192
|
+
}
|
|
1038
1193
|
if (response?.kind === "bus.ack" && !response.ok) {
|
|
1039
1194
|
throw new Error(
|
|
1040
1195
|
response.message ?? "Telegram bus follower heartbeat was rejected.",
|
|
@@ -1172,6 +1327,33 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1172
1327
|
setContext(ctx) {
|
|
1173
1328
|
activeContext = ctx;
|
|
1174
1329
|
},
|
|
1330
|
+
async disconnectFromLeader() {
|
|
1331
|
+
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
1335
|
+
socketPath: activeLeaderSocketPath,
|
|
1336
|
+
timeoutMs: registrationTimeoutMs,
|
|
1337
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
1338
|
+
endpoint: activeLeaderSocketPath,
|
|
1339
|
+
operation: "operation",
|
|
1340
|
+
}),
|
|
1341
|
+
envelope: {
|
|
1342
|
+
kind: "follower.disconnect",
|
|
1343
|
+
requestId: deps.createRequestId(),
|
|
1344
|
+
auth: activeAuthSecret,
|
|
1345
|
+
instanceId: deps.instanceId,
|
|
1346
|
+
registrationGeneration: activeRegistrationGeneration,
|
|
1347
|
+
sentAtMs: getNowMs(),
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
if (response?.kind === "bus.ack" && response.ok) return true;
|
|
1351
|
+
throw new Error(
|
|
1352
|
+
response?.kind === "bus.ack"
|
|
1353
|
+
? (response.message ?? "Telegram follower disconnect was rejected.")
|
|
1354
|
+
: "Telegram follower disconnect was not acknowledged.",
|
|
1355
|
+
);
|
|
1356
|
+
},
|
|
1175
1357
|
stop,
|
|
1176
1358
|
};
|
|
1177
1359
|
}
|
|
@@ -1250,6 +1432,12 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<
|
|
|
1250
1432
|
ctx,
|
|
1251
1433
|
);
|
|
1252
1434
|
} else if (envelope.kind === "leader.forwardMessage") {
|
|
1435
|
+
if (envelope.forwardCommentBatchPosition) {
|
|
1436
|
+
deps.prepareForwardedMessage?.(
|
|
1437
|
+
envelope.message as TMessage,
|
|
1438
|
+
envelope.forwardCommentBatchPosition,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1253
1441
|
if (!deps.handleForwardedMessage) {
|
|
1254
1442
|
throw new Error(
|
|
1255
1443
|
"Telegram bus receiver cannot handle this envelope.",
|