@llblab/pi-telegram 0.27.11 → 0.28.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/sync.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
5
5
  */
6
6
 
7
- import type { TelegramTarget } from "./target.ts";
7
+ import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
8
8
  import * as ThreadReconciler from "./thread-reconciler.ts";
9
9
  import {
10
10
  getTelegramTargetFromApiBody,
@@ -70,6 +70,7 @@ export interface TelegramTopicLifecycleSyncDeps {
70
70
  recordThreadReconciliationPlan?: (
71
71
  plan: ThreadReconciler.ThreadReconciliationPlan,
72
72
  ) => void;
73
+ assertExecutionCurrent?: (message: unknown) => void;
73
74
  recordEvent?: (
74
75
  category: string,
75
76
  message: unknown,
@@ -162,6 +163,52 @@ export function markTelegramConfigSyncChange<
162
163
  return nextState;
163
164
  }
164
165
 
166
+ export interface TelegramSessionRestartThreadCleanupDeps<
167
+ TSyncState extends TelegramSyncState,
168
+ > extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
169
+ suspendPolling: () => Promise<void>;
170
+ }
171
+
172
+ export function createTelegramSessionRestartThreadCleanupHandler<
173
+ TSyncState extends TelegramSyncState,
174
+ >(
175
+ deps: TelegramSessionRestartThreadCleanupDeps<TSyncState>,
176
+ ): () => Promise<string> {
177
+ return createTelegramManualThreadDisconnectHandler({
178
+ ...deps,
179
+ async stopPolling() {
180
+ await deps.suspendPolling();
181
+ return "Telegram bridge suspended for session restart.";
182
+ },
183
+ });
184
+ }
185
+
186
+ export interface TelegramThreadDisconnectAssembly {
187
+ disconnect: () => Promise<string>;
188
+ cleanupForSessionRestart: () => Promise<string>;
189
+ }
190
+
191
+ export function createTelegramThreadDisconnectAssembly<
192
+ TSyncState extends TelegramSyncState,
193
+ >(
194
+ deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
195
+ stopPolling: () => Promise<string>;
196
+ suspendPolling: () => Promise<void>;
197
+ },
198
+ ): TelegramThreadDisconnectAssembly {
199
+ return {
200
+ disconnect: createTelegramManualThreadDisconnectHandler({
201
+ ...deps,
202
+ stopPolling: deps.stopPolling,
203
+ }),
204
+ cleanupForSessionRestart:
205
+ createTelegramSessionRestartThreadCleanupHandler({
206
+ ...deps,
207
+ suspendPolling: deps.suspendPolling,
208
+ }),
209
+ };
210
+ }
211
+
165
212
  export function createTelegramManualThreadDisconnectHandler<
166
213
  TSyncState extends TelegramSyncState,
167
214
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
@@ -260,6 +307,8 @@ export function createTelegramLeaderHealthRuntime<
260
307
  const intervalMs = deps.intervalMs ?? 60_000;
261
308
  const getNowMs = deps.getNowMs ?? Date.now;
262
309
  let interval: ReturnType<typeof setInterval> | undefined;
310
+ let generation = 0;
311
+ let tickPromise: Promise<void> | undefined;
263
312
 
264
313
  const markFresh = (): void => {
265
314
  let state = markTelegramSyncSliceFresh(
@@ -282,20 +331,67 @@ export function createTelegramLeaderHealthRuntime<
282
331
  action: "leader-health-tick",
283
332
  }) as TSyncState,
284
333
  );
285
- deps.recordEvent("telegram", error, { phase: "leader-health-tick" });
334
+ try {
335
+ deps.recordEvent("telegram", error, { phase: "leader-health-tick" });
336
+ } catch {
337
+ // Health diagnostics cannot create an unhandled timer rejection.
338
+ }
286
339
  };
287
340
 
288
341
  const stop = (): void => {
289
- if (!interval) return;
290
- clearInterval(interval);
342
+ generation += 1;
343
+ if (interval) clearInterval(interval);
291
344
  interval = undefined;
345
+ tickPromise = undefined;
346
+ };
347
+ const requestTick = (): Promise<void> => {
348
+ if (tickPromise) return tickPromise;
349
+ const expectedGeneration = generation;
350
+ let tracked: Promise<void>;
351
+ tracked = Promise.resolve()
352
+ .then(deps.callGetMe)
353
+ .then(
354
+ () => {
355
+ if (generation !== expectedGeneration) return;
356
+ try {
357
+ markFresh();
358
+ } catch (stateError) {
359
+ try {
360
+ deps.recordEvent("telegram", stateError, {
361
+ phase: "leader-health-state",
362
+ });
363
+ } catch {
364
+ // State and diagnostic failure remain contained by this owner.
365
+ }
366
+ }
367
+ },
368
+ (error) => {
369
+ if (generation !== expectedGeneration) return;
370
+ try {
371
+ markSuspect(error);
372
+ } catch (stateError) {
373
+ try {
374
+ deps.recordEvent("telegram", stateError, {
375
+ phase: "leader-health-state",
376
+ });
377
+ } catch {
378
+ // State and diagnostic failure remain contained by this owner.
379
+ }
380
+ }
381
+ },
382
+ )
383
+ .finally(() => {
384
+ if (tickPromise === tracked) tickPromise = undefined;
385
+ });
386
+ tickPromise = tracked;
387
+ return tracked;
292
388
  };
293
389
 
294
390
  return {
295
391
  start() {
296
392
  stop();
297
393
  interval = setInterval(() => {
298
- void deps.callGetMe().then(markFresh).catch(markSuspect);
394
+ void requestTick();
299
395
  }, intervalMs);
300
396
  interval.unref?.();
301
397
  },
@@ -381,6 +477,32 @@ export async function ensureTelegramLeaderThreadBinding(
381
477
  assertLeaderEpoch("start");
382
478
  await deps.topicTargetStore.load();
383
479
  assertLeaderEpoch("after-load");
480
+ const unavailableTargetKeys = new Set([
481
+ ...deps.topicTargetStore
482
+ .listSyncObservations()
483
+ .filter((observation) => observation.syncStatus === "deleted")
484
+ .map((observation) => getTelegramTargetKey(observation.target)),
485
+ ...deps.topicTargetStore
486
+ .listPendingCleanups()
487
+ .map((intent) => getTelegramTargetKey(intent.target)),
488
+ ]);
489
+ let invalidatedUnavailableTarget = false;
490
+ for (const record of deps.topicTargetStore.list()) {
491
+ if (
492
+ record.instanceId !== deps.instanceId ||
493
+ !unavailableTargetKeys.has(getTelegramTargetKey(record.target))
494
+ ) {
495
+ continue;
496
+ }
497
+ invalidatedUnavailableTarget =
498
+ deps.topicTargetStore.markStaleByTarget(record.target) ||
499
+ invalidatedUnavailableTarget;
500
+ }
501
+ if (invalidatedUnavailableTarget) {
502
+ assertLeaderEpoch("before-unavailable-persist");
503
+ await deps.topicTargetStore.persist();
504
+ assertLeaderEpoch("after-unavailable-persist");
505
+ }
384
506
  const priorTargets = deps.topicTargetStore.list().filter((record) => {
385
507
  return (
386
508
  record.instanceId === deps.instanceId &&
@@ -659,6 +781,7 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
659
781
  createTelegramTopicLifecycleSyncHandler<TMessage>(deps);
660
782
  return async (lifecycle) => {
661
783
  const nowMs = deps.getNowMs ?? Date.now;
784
+ deps.assertExecutionCurrent?.(lifecycle.message);
662
785
  deps.setSyncState(
663
786
  markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
664
787
  nowMs: nowMs(),
@@ -667,6 +790,7 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
667
790
  }) as TSyncState,
668
791
  );
669
792
  await syncTopicLifecycle(lifecycle);
793
+ deps.assertExecutionCurrent?.(lifecycle.message);
670
794
  deps.setSyncState(
671
795
  markTelegramSyncSliceFresh(deps.getSyncState(), "topic-state", {
672
796
  nowMs: nowMs(),
@@ -680,7 +804,9 @@ export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(
680
804
  deps: TelegramTopicLifecycleSyncDeps,
681
805
  ): TelegramTopicLifecycleSyncHandler<TMessage> {
682
806
  return async (lifecycle) => {
807
+ deps.assertExecutionCurrent?.(lifecycle.message);
683
808
  await deps.topicTargetStore.load();
809
+ deps.assertExecutionCurrent?.(lifecycle.message);
684
810
  const nowMs = Date.now();
685
811
  const plan = ThreadReconciler.planThreadReconciliation({
686
812
  nowMs,
@@ -648,8 +648,31 @@ function getTelegramRetryDelayMs(
648
648
  return Math.max(0, baseDelayMs * 2 ** attempt);
649
649
  }
650
650
 
651
- function sleepTelegramRetry(ms: number): Promise<void> {
652
- return new Promise((resolve) => setTimeout(resolve, ms));
651
+ function getTelegramApiAbortReason(signal: AbortSignal): unknown {
652
+ return signal.reason ?? new DOMException("Aborted", "AbortError");
653
+ }
654
+
655
+ function throwIfTelegramApiCallAborted(signal: AbortSignal | undefined): void {
656
+ if (signal?.aborted) throw getTelegramApiAbortReason(signal);
657
+ }
658
+
659
+ function sleepTelegramRetry(
660
+ ms: number,
661
+ signal?: AbortSignal,
662
+ ): Promise<void> {
663
+ if (signal?.aborted) return Promise.reject(getTelegramApiAbortReason(signal));
664
+ return new Promise((resolve, reject) => {
665
+ const timeout = setTimeout(() => {
666
+ signal?.removeEventListener("abort", onAbort);
667
+ resolve();
668
+ }, ms);
669
+ const onAbort = () => {
670
+ clearTimeout(timeout);
671
+ reject(getTelegramApiAbortReason(signal!));
672
+ };
673
+ signal?.addEventListener("abort", onAbort, { once: true });
674
+ if (signal?.aborted) onAbort();
675
+ });
653
676
  }
654
677
 
655
678
  function assertTelegramFileSizeWithinLimit(
@@ -1030,8 +1053,13 @@ async function callTelegramWithRetry<TResponse>(
1030
1053
  isTelegramApiMethodRetrySafe(method));
1031
1054
  const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
1032
1055
  const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
1033
- const sleep = options?.sleep ?? sleepTelegramRetry;
1056
+ const waitBeforeRetry = async (ms: number): Promise<void> => {
1057
+ if (options?.sleep) await options.sleep(ms);
1058
+ else await sleepTelegramRetry(ms, options?.signal);
1059
+ throwIfTelegramApiCallAborted(options?.signal);
1060
+ };
1034
1061
  for (let attempt = 0; ; attempt += 1) {
1062
+ throwIfTelegramApiCallAborted(options?.signal);
1035
1063
  try {
1036
1064
  return unwrapTelegramApiResult(
1037
1065
  method,
@@ -1042,16 +1070,16 @@ async function callTelegramWithRetry<TResponse>(
1042
1070
  );
1043
1071
  } catch (error) {
1044
1072
  const retryable =
1045
- isRetryableTelegramApiError(error) &&
1046
- !(
1047
- options?.retryRateLimit === false &&
1048
- error instanceof TelegramApiHttpError &&
1049
- error.status === 429
1050
- );
1073
+ isRetryableTelegramApiError(error) &&
1074
+ !(
1075
+ options?.retryRateLimit === false &&
1076
+ error instanceof TelegramApiHttpError &&
1077
+ error.status === 429
1078
+ );
1051
1079
  if (!retrySafe) {
1052
1080
  if (error instanceof TelegramApiHttpError && error.status === 429) {
1053
1081
  if (attempt >= maxAttempts - 1) throw error;
1054
- await sleep(
1082
+ await waitBeforeRetry(
1055
1083
  getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
1056
1084
  );
1057
1085
  continue;
@@ -1068,7 +1096,9 @@ async function callTelegramWithRetry<TResponse>(
1068
1096
  throw error;
1069
1097
  }
1070
1098
  if (attempt >= maxAttempts - 1 || !retryable) throw error;
1071
- await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
1099
+ await waitBeforeRetry(
1100
+ getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
1101
+ );
1072
1102
  }
1073
1103
  }
1074
1104
  }
@@ -38,6 +38,8 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
38
38
  dispatching?: boolean;
39
39
  suspended?: boolean;
40
40
  reschedule?: (delayMs?: number) => void;
41
+ dispatchNow?: () => Promise<void>;
42
+ dispatchPromise?: Promise<void>;
41
43
  dispatchLimit?: number;
42
44
  forwardPairCandidate?: TelegramForwardCommentBatchPosition;
43
45
  }
@@ -63,6 +65,7 @@ export interface TelegramTextGroupController<TMessage, TContext = unknown> {
63
65
  ctx: TContext,
64
66
  ) => unknown | Promise<unknown>;
65
67
  }) => boolean;
68
+ flushMessage: (messageId: number) => Promise<boolean>;
66
69
  suspend: () => void;
67
70
  resume: (context: TContext) => void;
68
71
  clear: () => void;
@@ -193,9 +196,18 @@ export function queueTelegramTextGroupMessage<
193
196
  const key = getTelegramTextGroupKey(options.message);
194
197
  if (!key) return false;
195
198
  const existing = options.groups.get(key);
196
- if (existing?.messages.some(
199
+ const duplicateIndex = existing?.messages.findIndex(
197
200
  (message) => message.message_id === options.message.message_id,
198
- )) return true;
201
+ );
202
+ if (existing && duplicateIndex !== undefined && duplicateIndex >= 0) {
203
+ existing.messages[duplicateIndex] = options.message;
204
+ existing.context = options.context;
205
+ existing.forwardPairCandidate = options.forwardPairCandidate;
206
+ if (!existing.suspended && !existing.dispatching && !existing.flushTimer) {
207
+ existing.reschedule?.();
208
+ }
209
+ return true;
210
+ }
199
211
  if (
200
212
  !existing &&
201
213
  !options.forceStart &&
@@ -208,46 +220,52 @@ export function queueTelegramTextGroupMessage<
208
220
  state.messages.push(options.message);
209
221
  state.context = options.context;
210
222
  state.forwardPairCandidate = options.forwardPairCandidate;
211
- const dispatchQueued = (): void => {
212
- state.flushTimer = undefined;
213
- const queued = options.groups.get(key);
214
- if (!queued || queued.context === undefined) return;
215
- if (queued.dispatching) {
216
- scheduleDispatch();
217
- return;
218
- }
219
- const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
220
- queued.dispatchLimit = undefined;
221
- const dispatchedMessages = queued.messages.slice(0, dispatchCount);
222
- const dispatchedIds = new Set(
223
- dispatchedMessages.map((message) => message.message_id),
224
- );
225
- queued.dispatching = true;
226
- void Promise.resolve(
227
- options.dispatchMessages(dispatchedMessages, queued.context),
228
- ).then(
229
- () => {
230
- if (options.groups.get(key) !== queued) return;
231
- queued.messages = queued.messages.filter(
232
- (message) => !dispatchedIds.has(message.message_id),
233
- );
234
- queued.dispatching = false;
235
- if (queued.messages.length === 0) options.groups.delete(key);
236
- else if (!queued.flushTimer) scheduleDispatch();
237
- },
238
- () => {
239
- if (options.groups.get(key) !== queued) return;
223
+ const dispatchQueued = (): Promise<void> => {
224
+ state.flushTimer = undefined;
225
+ const queued = options.groups.get(key);
226
+ if (!queued || queued.context === undefined) return Promise.resolve();
227
+ if (queued.dispatching) return queued.dispatchPromise ?? Promise.resolve();
228
+ const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
229
+ queued.dispatchLimit = undefined;
230
+ const dispatchedMessages = queued.messages.slice(0, dispatchCount);
231
+ const dispatchedIds = new Set(
232
+ dispatchedMessages.map((message) => message.message_id),
233
+ );
234
+ queued.dispatching = true;
235
+ const operation = Promise.resolve(
236
+ options.dispatchMessages(dispatchedMessages, queued.context),
237
+ ).then(
238
+ () => {
239
+ if (options.groups.get(key) !== queued) return;
240
+ queued.messages = queued.messages.filter(
241
+ (message) => !dispatchedIds.has(message.message_id),
242
+ );
243
+ queued.dispatching = false;
244
+ queued.dispatchPromise = undefined;
245
+ if (queued.messages.length === 0) options.groups.delete(key);
246
+ else if (!queued.flushTimer) scheduleDispatch();
247
+ },
248
+ (error) => {
249
+ if (options.groups.get(key) === queued) {
240
250
  queued.dispatching = false;
251
+ queued.dispatchPromise = undefined;
241
252
  if (!queued.flushTimer) scheduleDispatch();
242
- },
243
- );
253
+ }
254
+ throw error;
255
+ },
256
+ );
257
+ queued.dispatchPromise = operation;
258
+ return operation;
244
259
  };
245
260
  const scheduleDispatch = (delayMs = options.debounceMs): void => {
246
261
  if (state.suspended) return;
247
- state.flushTimer = options.setTimer(dispatchQueued, delayMs);
262
+ state.flushTimer = options.setTimer(() => {
263
+ void dispatchQueued().catch(() => undefined);
264
+ }, delayMs);
248
265
  state.flushTimer.unref?.();
249
266
  };
250
267
  state.reschedule = scheduleDispatch;
268
+ state.dispatchNow = dispatchQueued;
251
269
  if (state.flushTimer) options.clearTimer(state.flushTimer);
252
270
  scheduleDispatch(
253
271
  options.dispatchImmediately ? 0 : (options.delayMs ?? options.debounceMs),
@@ -390,6 +408,24 @@ export function createTelegramTextGroupController<
390
408
  : undefined,
391
409
  });
392
410
  },
411
+ async flushMessage(messageId) {
412
+ for (const state of groups.values()) {
413
+ if (!state.messages.some((message) => message.message_id === messageId)) {
414
+ continue;
415
+ }
416
+ if (state.flushTimer) clearTimer(state.flushTimer);
417
+ state.flushTimer = undefined;
418
+ await state.dispatchNow?.();
419
+ if (
420
+ state.messages.some((message) => message.message_id === messageId) &&
421
+ !state.dispatching
422
+ ) {
423
+ await state.dispatchNow?.();
424
+ }
425
+ return true;
426
+ }
427
+ return false;
428
+ },
393
429
  suspend: () => {
394
430
  for (const state of groups.values()) {
395
431
  state.suspended = true;
@@ -422,6 +458,7 @@ export function createTelegramTextGroupDispatchRuntime<
422
458
  textGroups: TelegramTextGroupController<TMessage, TContext>;
423
459
  dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
424
460
  dispatchSingleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
461
+ onDeferredMessage?: (message: TMessage) => void;
425
462
  }): TelegramTextGroupDispatchRuntime<TMessage, TContext> {
426
463
  return {
427
464
  handleMessage: async (message, ctx) => {
@@ -431,7 +468,10 @@ export function createTelegramTextGroupDispatchRuntime<
431
468
  dispatchMessages: (messages, queuedCtx) =>
432
469
  deps.dispatchMessages(messages, queuedCtx),
433
470
  });
434
- if (queuedTextGroup) return;
471
+ if (queuedTextGroup) {
472
+ deps.onDeferredMessage?.(message);
473
+ return;
474
+ }
435
475
  await deps.dispatchSingleMessage(message, ctx);
436
476
  },
437
477
  };
package/lib/threads.ts CHANGED
@@ -226,6 +226,8 @@ function getNextMonotonicSlot(
226
226
 
227
227
  export interface TelegramTopicTargetStore {
228
228
  load: () => Promise<void>;
229
+ /** Discard process-local projections and reload owner-published state. */
230
+ refresh?: () => Promise<void>;
229
231
  persist: () => Promise<void>;
230
232
  list: () => TelegramTopicTargetRecord[];
231
233
  getFollowerRecoveryHintByTarget?: (
@@ -1296,6 +1298,11 @@ export function createTelegramTopicTargetStore(
1296
1298
  if (dirty) return;
1297
1299
  await loadFromDisk();
1298
1300
  },
1301
+ refresh() {
1302
+ const refresh = persistQueue.then(loadFromDisk);
1303
+ persistQueue = refresh.catch(() => undefined);
1304
+ return refresh;
1305
+ },
1299
1306
  persist() {
1300
1307
  const persist = persistQueue.then(async () => {
1301
1308
  const path = getPath();
@@ -2459,7 +2466,7 @@ export interface TelegramCurrentInstanceThreadRuntime {
2459
2466
  getRestorationIdentity(): TelegramInstanceThreadIdentityCandidate;
2460
2467
  }
2461
2468
 
2462
- export function createTelegramCurrentInstanceThreadRuntime(deps: {
2469
+ export interface TelegramCurrentInstanceThreadRuntimeDeps {
2463
2470
  instanceId: string;
2464
2471
  listRecords(): readonly TelegramTopicTargetRecord[];
2465
2472
  getPreferredTarget(): TelegramTarget | undefined;
@@ -2467,7 +2474,11 @@ export function createTelegramCurrentInstanceThreadRuntime(deps: {
2467
2474
  | (TelegramInstanceThreadIdentityCandidate & { registered: boolean })
2468
2475
  | undefined;
2469
2476
  getLeader(): TelegramInstanceThreadIdentityCandidate | undefined;
2470
- }): TelegramCurrentInstanceThreadRuntime {
2477
+ }
2478
+
2479
+ export function createTelegramCurrentInstanceThreadRuntime(
2480
+ deps: TelegramCurrentInstanceThreadRuntimeDeps,
2481
+ ): TelegramCurrentInstanceThreadRuntime {
2471
2482
  const findRecord = function (): TelegramTopicTargetRecord | undefined {
2472
2483
  return findCurrentTelegramInstanceThreadRecord({
2473
2484
  records: deps.listRecords(),
@@ -2563,6 +2574,11 @@ export interface TelegramThreadStatusProjectionRuntime {
2563
2574
  followerTarget?: TelegramTarget;
2564
2575
  followerSlot?: string;
2565
2576
  followerThreadName?: string;
2577
+ leaderProtocol?: {
2578
+ protocolVersion: number;
2579
+ runtimeBuild: string;
2580
+ capabilities: string[];
2581
+ };
2566
2582
  };
2567
2583
  getTopicTargets(): ReturnType<typeof listTelegramThreadStatusTargets>;
2568
2584
  getThreadReservations(): ReturnType<
@@ -2575,7 +2591,7 @@ export interface TelegramThreadStatusProjectionRuntime {
2575
2591
  getInstanceThreadName(): string | undefined;
2576
2592
  }
2577
2593
 
2578
- export function createTelegramThreadStatusProjectionRuntime(deps: {
2594
+ export interface TelegramThreadStatusProjectionRuntimeDeps {
2579
2595
  getThreadMode(): "unknown" | "enabled" | "disabled";
2580
2596
  isBusPollingStarted(): boolean;
2581
2597
  isFollowerRegistered(): boolean;
@@ -2589,8 +2605,19 @@ export function createTelegramThreadStatusProjectionRuntime(deps: {
2589
2605
  getFollowerTarget(): TelegramTarget | undefined;
2590
2606
  getFollowerSlot(): string | undefined;
2591
2607
  getFollowerThreadName(): string | undefined;
2608
+ getLeaderProtocol?():
2609
+ | {
2610
+ protocolVersion: number;
2611
+ runtimeBuild: string;
2612
+ capabilities: string[];
2613
+ }
2614
+ | undefined;
2592
2615
  getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
2593
- }): TelegramThreadStatusProjectionRuntime {
2616
+ }
2617
+
2618
+ export function createTelegramThreadStatusProjectionRuntime(
2619
+ deps: TelegramThreadStatusProjectionRuntimeDeps,
2620
+ ): TelegramThreadStatusProjectionRuntime {
2594
2621
  return {
2595
2622
  getBusRole() {
2596
2623
  if (deps.getThreadMode() === "disabled") return undefined;
@@ -2606,6 +2633,7 @@ export function createTelegramThreadStatusProjectionRuntime(deps: {
2606
2633
  getLocalBus() {
2607
2634
  const leaderSocketPath = deps.getLeaderSocketPath();
2608
2635
  const followerSocketPath = deps.getFollowerSocketPath();
2636
+ const leaderProtocol = deps.getLeaderProtocol?.();
2609
2637
  return {
2610
2638
  leaderSocketPath,
2611
2639
  leaderTransport: deps.getTransportKind(leaderSocketPath),
@@ -2615,6 +2643,7 @@ export function createTelegramThreadStatusProjectionRuntime(deps: {
2615
2643
  followerTarget: deps.getFollowerTarget(),
2616
2644
  followerSlot: deps.getFollowerSlot(),
2617
2645
  followerThreadName: deps.getFollowerThreadName(),
2646
+ ...(leaderProtocol ? { leaderProtocol } : {}),
2618
2647
  };
2619
2648
  },
2620
2649
  getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()),
@@ -2633,11 +2662,84 @@ export function createTelegramThreadStatusProjectionRuntime(deps: {
2633
2662
  };
2634
2663
  }
2635
2664
 
2665
+ export interface TelegramCurrentThreadAssemblyDeps {
2666
+ instanceId: string;
2667
+ listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
2668
+ getActiveTurnTarget(): TelegramTarget | undefined;
2669
+ getFollowerTarget(): TelegramTarget | undefined;
2670
+ isFollowerRegistered(): boolean;
2671
+ getFollowerSlot(): string | undefined;
2672
+ getFollowerThreadName(): string | undefined;
2673
+ getLeaderIdentity(): TelegramInstanceThreadIdentityCandidate | undefined;
2674
+ getLeaderTarget(): TelegramTarget | undefined;
2675
+ getLeaderProtocol?: TelegramThreadStatusProjectionRuntimeDeps["getLeaderProtocol"];
2676
+ status: Pick<
2677
+ TelegramThreadStatusProjectionRuntimeDeps,
2678
+ | "getThreadMode"
2679
+ | "isBusPollingStarted"
2680
+ | "listFollowers"
2681
+ | "listReservations"
2682
+ | "listSyncObservations"
2683
+ | "getLeaderSocketPath"
2684
+ | "getFollowerSocketPath"
2685
+ | "getTransportKind"
2686
+ >;
2687
+ }
2688
+
2689
+ export interface TelegramCurrentThreadAssembly {
2690
+ current: TelegramCurrentInstanceThreadRuntime;
2691
+ status: TelegramThreadStatusProjectionRuntime;
2692
+ }
2693
+
2694
+ /** Own current-thread preference and its matching status projection. */
2695
+ export function createTelegramCurrentThreadAssembly(
2696
+ deps: TelegramCurrentThreadAssemblyDeps,
2697
+ ): TelegramCurrentThreadAssembly {
2698
+ const getFollower = () => {
2699
+ const target = deps.getFollowerTarget();
2700
+ if (!target) return undefined;
2701
+ return {
2702
+ registered: deps.isFollowerRegistered(),
2703
+ target,
2704
+ slot: deps.getFollowerSlot(),
2705
+ threadName: deps.getFollowerThreadName(),
2706
+ };
2707
+ };
2708
+ const current = createTelegramCurrentInstanceThreadRuntime({
2709
+ instanceId: deps.instanceId,
2710
+ listRecords: deps.listRecords,
2711
+ getPreferredTarget: () =>
2712
+ deps.getActiveTurnTarget() ??
2713
+ deps.getFollowerTarget() ??
2714
+ deps.getLeaderTarget(),
2715
+ getFollower,
2716
+ getLeader: deps.getLeaderIdentity,
2717
+ });
2718
+ return {
2719
+ current,
2720
+ status: createTelegramThreadStatusProjectionRuntime({
2721
+ ...deps.status,
2722
+ isFollowerRegistered: deps.isFollowerRegistered,
2723
+ listRecords: deps.listRecords,
2724
+ getFollowerTarget: deps.getFollowerTarget,
2725
+ getFollowerSlot: deps.getFollowerSlot,
2726
+ getFollowerThreadName: deps.getFollowerThreadName,
2727
+ getLeaderProtocol: deps.getLeaderProtocol,
2728
+ getCurrentIdentity: current.getRestorationIdentity,
2729
+ }),
2730
+ };
2731
+ }
2732
+
2636
2733
  export interface TelegramThreadStatusFollowerView {
2637
2734
  instanceId: string;
2638
2735
  cwd?: string;
2639
2736
  lastHeartbeatMs: number;
2640
2737
  target?: TelegramTarget;
2738
+ protocol?: {
2739
+ protocolVersion: number;
2740
+ runtimeBuild: string;
2741
+ capabilities: string[];
2742
+ };
2641
2743
  }
2642
2744
 
2643
2745
  function getTelegramThreadStatusName(
@@ -2660,6 +2762,11 @@ export function listTelegramThreadStatusFollowers(options: {
2660
2762
  cwd?: string;
2661
2763
  lastHeartbeatMs: number;
2662
2764
  target?: TelegramTarget;
2765
+ protocol?: {
2766
+ protocolVersion: number;
2767
+ runtimeBuild: string;
2768
+ capabilities: string[];
2769
+ };
2663
2770
  slot?: string;
2664
2771
  threadName?: string;
2665
2772
  status?: string;
@@ -2676,6 +2783,7 @@ export function listTelegramThreadStatusFollowers(options: {
2676
2783
  cwd: follower.cwd,
2677
2784
  lastHeartbeatMs: follower.lastHeartbeatMs,
2678
2785
  target: follower.target,
2786
+ ...(follower.protocol ? { protocol: follower.protocol } : {}),
2679
2787
  slot: record?.slot,
2680
2788
  threadName: getTelegramThreadStatusName(record),
2681
2789
  status: record?.status,