@llblab/pi-telegram 0.17.5 → 0.18.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -19
  3. package/CHANGELOG.md +42 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +410 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +9 -2
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +409 -41
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/docs/telegram-bot-api-rich-messages.md +0 -890
@@ -0,0 +1,915 @@
1
+ /**
2
+ * Telegram bus leader orchestration
3
+ * Zones: multi-instance bus, leader polling/server lifecycle, follower routing
4
+ * Owns leader-only runtime orchestration: follower registration envelopes, follower API proxying,
5
+ * leader activation hot-switching, local bus server startup, and stale follower pruning.
6
+ */
7
+
8
+ import * as Sync from "./sync.ts";
9
+ import * as ThreadReconciler from "./thread-reconciler.ts";
10
+ import type { TelegramApiCallOptions } from "./telegram-api.ts";
11
+ import type { TelegramTarget } from "./target.ts";
12
+ import * as Threads from "./threads.ts";
13
+ import {
14
+ createTelegramBusLocalServer,
15
+ createUnauthorizedBusAck,
16
+ isTelegramBusEnvelopeAuthorized,
17
+ getTelegramBusFollowerSocketPath,
18
+ sendTelegramBusLocalEnvelope,
19
+ type TelegramBusEnvelope,
20
+ type TelegramBusFollowerRegistry,
21
+ type TelegramBusFollowerView,
22
+ type TelegramBusInstanceRegistration,
23
+ } from "./bus.ts";
24
+
25
+ export interface TelegramBusLeaderRuntime<TContext> {
26
+ startPolling: (ctx: TContext) => Promise<void>;
27
+ stopPolling: () => Promise<void>;
28
+ }
29
+
30
+ export interface TelegramBusFollowerLifecycleAnnouncement {
31
+ target: TelegramTarget & { threadId: number };
32
+ text: string;
33
+ parseMode: "HTML";
34
+ }
35
+
36
+ function escapeHtml(text: string): string {
37
+ return text
38
+ .replace(/&/g, "&amp;")
39
+ .replace(/</g, "&lt;")
40
+ .replace(/>/g, "&gt;");
41
+ }
42
+
43
+ function formatTelegramBusInstanceLabel(input: {
44
+ threadName?: string;
45
+ slot?: string;
46
+ }): string {
47
+ const threadName = input.threadName?.trim();
48
+ if (threadName) return escapeHtml(threadName);
49
+ return input.slot && /^[A-Z]$/.test(input.slot) ? input.slot : "?";
50
+ }
51
+
52
+ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
53
+ getAllowedUserId: () => number | undefined;
54
+ instanceId: string;
55
+ getCwd?: (ctx: TContext) => string | undefined;
56
+ shouldForceFreshUnnamed?: () => boolean;
57
+ topicTargetStore: Threads.TelegramTopicTargetStore;
58
+ callApi: <TResponse>(
59
+ method: string,
60
+ body: Record<string, unknown>,
61
+ ) => Promise<TResponse>;
62
+ getCurrentLeaderEpoch?: () => number | string | undefined;
63
+ getThreadReconciliationMachineState?: () =>
64
+ | ThreadReconciler.ThreadReconciliationMachineState
65
+ | undefined;
66
+ recordThreadReconciliationPlan?: (
67
+ plan: ThreadReconciler.ThreadReconciliationPlan,
68
+ ) => void;
69
+ getSyncState: () => Sync.TelegramSyncState;
70
+ setSyncState: (state: Sync.TelegramSyncState) => void;
71
+ setLeaderTarget: (input: { target: TelegramTarget; slot?: string }) => void;
72
+ onProvisioningStart?: () => void;
73
+ onProvisioningEnd?: () => void;
74
+ recordRuntimeEvent: (
75
+ category: string,
76
+ error: unknown,
77
+ details?: Record<string, unknown>,
78
+ ) => void;
79
+ getNowMs?: () => number;
80
+ }
81
+
82
+ export interface TelegramBusFollowerTargetProvisionerDeps {
83
+ getAllowedUserId: () => number | undefined;
84
+ topicTargetStore: Threads.TelegramTopicTargetStore;
85
+ callApi: <TResponse>(
86
+ method: string,
87
+ body: Record<string, unknown>,
88
+ ) => Promise<TResponse>;
89
+ getCurrentLeaderEpoch?: () => number | string | undefined;
90
+ getSyncState: () => Sync.TelegramSyncState;
91
+ setSyncState: (state: Sync.TelegramSyncState) => void;
92
+ onProvisioningStart?: () => void;
93
+ onProvisioningEnd?: () => void;
94
+ getNowMs?: () => number;
95
+ recordRuntimeEvent: (
96
+ category: string,
97
+ error: unknown,
98
+ details?: Record<string, unknown>,
99
+ ) => void;
100
+ }
101
+
102
+ export interface TelegramBusFollowerPruneHandlerDeps {
103
+ topicTargetStore: Pick<
104
+ Threads.TelegramTopicTargetStore,
105
+ | "load"
106
+ | "getActiveByInstanceId"
107
+ | "list"
108
+ | "listPendingProvisions"
109
+ | "markStaleByTarget"
110
+ | "markOfflineByInstanceId"
111
+ | "persist"
112
+ | "removePendingProvision"
113
+ >;
114
+ callApi: <TResponse>(
115
+ method: string,
116
+ body: Record<string, unknown>,
117
+ ) => Promise<TResponse>;
118
+ getCurrentLeaderEpoch?: () => number | string | undefined;
119
+ getSyncState: () => Sync.TelegramSyncState;
120
+ setSyncState: (state: Sync.TelegramSyncState) => void;
121
+ getNowMs?: () => number;
122
+ recordRuntimeEvent: (
123
+ category: string,
124
+ error: unknown,
125
+ details?: Record<string, unknown>,
126
+ ) => void;
127
+ }
128
+
129
+ export interface TelegramBusLeaderApiProxyDeps {
130
+ call: (
131
+ method: string,
132
+ body: Record<string, unknown>,
133
+ options?: TelegramApiCallOptions,
134
+ ) => Promise<unknown>;
135
+ callMultipart: (
136
+ method: string,
137
+ fields: Record<string, string>,
138
+ fieldName: string,
139
+ filePath: string,
140
+ fileName: string,
141
+ options?: TelegramApiCallOptions,
142
+ ) => Promise<unknown>;
143
+ downloadFile: (fileId: string, destinationDir: string) => Promise<unknown>;
144
+ recoverStaleTargetError?: (
145
+ apiBody: unknown,
146
+ error: unknown,
147
+ ) => Promise<unknown> | unknown;
148
+ }
149
+
150
+ export interface TelegramBusFollowerRegistryRestoreDeps {
151
+ topicTargetStore: Pick<Threads.TelegramTopicTargetStore, "load" | "list">;
152
+ followerRegistry: TelegramBusFollowerRegistry;
153
+ recordRuntimeEvent: (
154
+ category: string,
155
+ error: unknown,
156
+ details?: Record<string, unknown>,
157
+ ) => void;
158
+ getNowMs?: () => number;
159
+ }
160
+
161
+ export interface TelegramBusLeaderRuntimeDeps<TContext> {
162
+ socketPath: string;
163
+ followerRegistry: TelegramBusFollowerRegistry;
164
+ authSecret?: string;
165
+ startPolling: (ctx: TContext) => void | Promise<void>;
166
+ stopPolling: () => void | Promise<void>;
167
+ callApi?: (method: string, args: unknown[]) => Promise<unknown> | unknown;
168
+ authorizeFollowerApiCall?: (input: {
169
+ follower: TelegramBusFollowerView;
170
+ method: string;
171
+ args: unknown[];
172
+ }) => boolean;
173
+ provisionFollowerTarget?: (
174
+ registration: TelegramBusInstanceRegistration,
175
+ ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
176
+ restoreFollowerRegistry?: () => Promise<void> | void;
177
+ provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
178
+ getNowMs?: () => number;
179
+ followerPruneIntervalMs?: number;
180
+ followerStaleAfterMs?: number;
181
+ onFollowerPruned?: (
182
+ follower: TelegramBusFollowerView,
183
+ ) => Promise<void> | void;
184
+ recordRuntimeEvent?: (
185
+ category: string,
186
+ error: unknown,
187
+ details?: Record<string, unknown>,
188
+ ) => void;
189
+ }
190
+
191
+ export function createTelegramBusFollowerRegistryRestoreHandler(
192
+ deps: TelegramBusFollowerRegistryRestoreDeps,
193
+ ): () => Promise<void> {
194
+ return async () => {
195
+ await deps.topicTargetStore.load();
196
+ const nowMs = (deps.getNowMs ?? Date.now)();
197
+ let restored = 0;
198
+ for (const record of deps.topicTargetStore.list()) {
199
+ if (record.owner?.kind !== "manual-follower") continue;
200
+ if (!record.instanceId) continue;
201
+ if (
202
+ record.status !== "active" &&
203
+ record.status !== "starting" &&
204
+ record.status !== "pending"
205
+ ) {
206
+ continue;
207
+ }
208
+ deps.followerRegistry.register({
209
+ instanceId: record.instanceId,
210
+ profileKey: record.profileKey,
211
+ threadName: record.threadName,
212
+ target: record.target,
213
+ busSocketPath: getTelegramBusFollowerSocketPath(record.instanceId),
214
+ connectedAtMs: nowMs,
215
+ });
216
+ restored += 1;
217
+ }
218
+ if (restored > 0) {
219
+ deps.recordRuntimeEvent("bus", "Telegram follower registry restored", {
220
+ phase: "follower-registry-restore",
221
+ followers: restored,
222
+ });
223
+ }
224
+ };
225
+ }
226
+
227
+ export function createTelegramBusInstanceLifecycleAnnouncement(input: {
228
+ target: TelegramTarget & { threadId: number };
229
+ threadName?: string;
230
+ slot?: string;
231
+ state: "connected" | "disconnected";
232
+ }): TelegramBusFollowerLifecycleAnnouncement {
233
+ return {
234
+ target: { ...input.target },
235
+ text: `📡 Instance <b>${formatTelegramBusInstanceLabel(input)}</b> ${input.state}.`,
236
+ parseMode: "HTML",
237
+ };
238
+ }
239
+
240
+ export function createTelegramBusFollowerDisconnectedAnnouncement(input: {
241
+ follower: TelegramBusFollowerView;
242
+ threadName?: string;
243
+ slot?: string;
244
+ }): TelegramBusFollowerLifecycleAnnouncement | undefined {
245
+ if (!input.follower.target?.threadId) return undefined;
246
+ return createTelegramBusInstanceLifecycleAnnouncement({
247
+ target: {
248
+ chatId: input.follower.target.chatId,
249
+ threadId: input.follower.target.threadId,
250
+ },
251
+ threadName: input.threadName,
252
+ slot: input.slot,
253
+ state: "disconnected",
254
+ });
255
+ }
256
+
257
+ const TELEGRAM_BUS_SLOW_FOLLOWER_REGISTRATION_MS = 1000;
258
+
259
+ function scheduleTelegramBusLeaderBackgroundTask(
260
+ task: () => Promise<void>,
261
+ ): void {
262
+ const timer = setTimeout(() => {
263
+ void task();
264
+ }, 0);
265
+ timer.unref?.();
266
+ }
267
+
268
+ function recordSlowTelegramBusFollowerRegistrationStep(
269
+ deps: Pick<TelegramBusFollowerTargetProvisionerDeps, "recordRuntimeEvent">,
270
+ input: {
271
+ phase: string;
272
+ elapsedMs: number;
273
+ instanceId: string;
274
+ target?: TelegramTarget;
275
+ reused?: boolean;
276
+ },
277
+ ): void {
278
+ if (input.elapsedMs < TELEGRAM_BUS_SLOW_FOLLOWER_REGISTRATION_MS) return;
279
+ deps.recordRuntimeEvent(
280
+ "bus",
281
+ `Telegram bus follower registration step was slow (${input.elapsedMs}ms).`,
282
+ {
283
+ phase: input.phase,
284
+ elapsedMs: input.elapsedMs,
285
+ instanceId: input.instanceId,
286
+ reused: input.reused,
287
+ chatId: input.target?.chatId,
288
+ threadId: input.target?.threadId,
289
+ },
290
+ );
291
+ }
292
+
293
+ export function createTelegramBusFollowerTargetProvisioner(
294
+ deps: TelegramBusFollowerTargetProvisionerDeps,
295
+ ): (
296
+ registration: TelegramBusInstanceRegistration,
297
+ ) => Promise<
298
+ (TelegramTarget & { slot?: string; threadName?: string }) | undefined
299
+ > {
300
+ const getNowMs = deps.getNowMs ?? Date.now;
301
+ return async (registration) => {
302
+ const registrationStartedAtMs = Date.now();
303
+ const chatId = deps.getAllowedUserId();
304
+ if (typeof chatId !== "number") return registration.target;
305
+ await deps.topicTargetStore.load();
306
+ const provision = Threads.createTelegramTopicTargetProvisioner({
307
+ topicChatId: chatId,
308
+ store: deps.topicTargetStore,
309
+ getNowMs,
310
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
311
+ callApi: deps.callApi,
312
+ // Manual follower registration should create a visible fresh topic unless
313
+ // the same profile key already has a known live/reusable binding. Do not
314
+ // silently claim old offline/failed tabs: they may be closed/deleted in
315
+ // Telegram and therefore invisible to the operator.
316
+ claimPendingTargets: false,
317
+ });
318
+ const recordsBeforeProvision = deps.topicTargetStore.list();
319
+ const followerProfileKey =
320
+ registration.profileKey ?? `manual:${registration.instanceId}`;
321
+ const followerOwner =
322
+ Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
323
+ deps.onProvisioningStart?.();
324
+ let result: Threads.TelegramTopicTargetProvisionResult;
325
+ try {
326
+ result = await provision({
327
+ instanceId: registration.instanceId,
328
+ owner:
329
+ followerOwner.kind === "manual-follower"
330
+ ? followerOwner
331
+ : {
332
+ kind: "manual-follower",
333
+ instanceId: registration.instanceId,
334
+ },
335
+ profileKey: followerProfileKey,
336
+ threadName: registration.threadName,
337
+ });
338
+ } finally {
339
+ deps.onProvisioningEnd?.();
340
+ }
341
+ deps.setSyncState(
342
+ Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
343
+ nowMs: getNowMs(),
344
+ action: "follower-register",
345
+ }),
346
+ );
347
+ recordSlowTelegramBusFollowerRegistrationStep(deps, {
348
+ phase: "follower-register-critical",
349
+ elapsedMs: Date.now() - registrationStartedAtMs,
350
+ instanceId: registration.instanceId,
351
+ target: result.target,
352
+ reused: result.reused,
353
+ });
354
+ const connectedAnnouncement =
355
+ createTelegramBusInstanceLifecycleAnnouncement({
356
+ target: result.target,
357
+ threadName: result.record.threadName,
358
+ slot: result.record.slot,
359
+ state: "connected",
360
+ });
361
+ scheduleTelegramBusLeaderBackgroundTask(async () => {
362
+ const backgroundStartedAtMs = Date.now();
363
+ if (connectedAnnouncement) {
364
+ try {
365
+ await deps.callApi("sendMessage", {
366
+ chat_id: connectedAnnouncement.target.chatId,
367
+ message_thread_id: connectedAnnouncement.target.threadId,
368
+ text: connectedAnnouncement.text,
369
+ parse_mode: connectedAnnouncement.parseMode,
370
+ });
371
+ } catch (error) {
372
+ deps.recordRuntimeEvent("telegram", error, {
373
+ phase: "follower-topic-announce",
374
+ instanceId: registration.instanceId,
375
+ chatId: result.target.chatId,
376
+ threadId: result.target.threadId,
377
+ });
378
+ }
379
+ }
380
+ try {
381
+ await ThreadReconciler.applyThreadReconciliationPlan(
382
+ ThreadReconciler.planThreadReconciliation({
383
+ nowMs: getNowMs(),
384
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
385
+ records: recordsBeforeProvision,
386
+ pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
387
+ replacedBindings: [
388
+ {
389
+ instanceId: registration.instanceId,
390
+ replacementTarget: result.target,
391
+ },
392
+ ],
393
+ }),
394
+ {
395
+ callApi: deps.callApi,
396
+ markStaleByTarget(target, syncStatus, lastSyncError) {
397
+ return deps.topicTargetStore.markStaleByTarget(
398
+ target,
399
+ syncStatus,
400
+ lastSyncError,
401
+ );
402
+ },
403
+ persist() {
404
+ return deps.topicTargetStore.persist();
405
+ },
406
+ removePendingProvisionById(id) {
407
+ return deps.topicTargetStore.removePendingProvision(id);
408
+ },
409
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
410
+ recordRuntimeEvent: deps.recordRuntimeEvent,
411
+ },
412
+ );
413
+ await deps.topicTargetStore.persist();
414
+ } catch (error) {
415
+ deps.recordRuntimeEvent("telegram", error, {
416
+ phase: "follower-register-background-reconcile",
417
+ instanceId: registration.instanceId,
418
+ chatId: result.target.chatId,
419
+ threadId: result.target.threadId,
420
+ });
421
+ }
422
+ recordSlowTelegramBusFollowerRegistrationStep(deps, {
423
+ phase: "follower-register-background",
424
+ elapsedMs: Date.now() - backgroundStartedAtMs,
425
+ instanceId: registration.instanceId,
426
+ target: result.target,
427
+ reused: result.reused,
428
+ });
429
+ });
430
+ return {
431
+ ...result.target,
432
+ slot: result.record.slot,
433
+ threadName: result.record.threadName,
434
+ };
435
+ };
436
+ }
437
+
438
+ export function createTelegramBusFollowerPruneHandler(
439
+ deps: TelegramBusFollowerPruneHandlerDeps,
440
+ ): (follower: TelegramBusFollowerView) => Promise<void> {
441
+ return async (follower) => {
442
+ deps.recordRuntimeEvent(
443
+ "bus",
444
+ "Telegram bus follower heartbeat stale; preserving thread binding",
445
+ {
446
+ phase: "follower-pruned",
447
+ instanceId: follower.instanceId,
448
+ },
449
+ );
450
+ };
451
+ }
452
+
453
+ export function createTelegramBusLeaderTargetProvisioner<TContext>(
454
+ deps: TelegramBusLeaderTargetProvisionerDeps<TContext>,
455
+ ): (ctx: TContext) => Promise<void> {
456
+ const getNowMs = deps.getNowMs ?? Date.now;
457
+ return async (ctx) => {
458
+ deps.onProvisioningStart?.();
459
+ let ownTarget: Threads.TelegramOwnTopicProvisionResult | undefined;
460
+ try {
461
+ ownTarget = await Sync.ensureTelegramLeaderThreadBinding({
462
+ getAllowedUserId: deps.getAllowedUserId,
463
+ instanceId: deps.instanceId,
464
+ cwd: deps.getCwd?.(ctx),
465
+ forceFreshUnnamed: deps.shouldForceFreshUnnamed?.(),
466
+ getNowMs,
467
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
468
+ getThreadReconciliationMachineState:
469
+ deps.getThreadReconciliationMachineState,
470
+ recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
471
+ topicTargetStore: deps.topicTargetStore,
472
+ callApi: deps.callApi,
473
+ recordEvent: deps.recordRuntimeEvent,
474
+ });
475
+ } finally {
476
+ deps.onProvisioningEnd?.();
477
+ }
478
+ if (!ownTarget) return;
479
+ deps.setLeaderTarget({ target: ownTarget.target, slot: ownTarget.slot });
480
+ const nowMs = getNowMs();
481
+ let syncState = deps.getSyncState();
482
+ syncState = Sync.markTelegramSyncSliceFresh(syncState, "target-bindings", {
483
+ nowMs,
484
+ action: "leader-startup",
485
+ });
486
+ syncState = Sync.markTelegramSyncSliceFresh(syncState, "reservations", {
487
+ nowMs,
488
+ action: "leader-startup",
489
+ });
490
+ syncState = Sync.markTelegramSyncSliceFresh(syncState, "topic-capability", {
491
+ nowMs,
492
+ action: "leader-startup",
493
+ });
494
+ deps.setSyncState(syncState);
495
+ if (ownTarget.reused) return;
496
+ const connectedAnnouncement =
497
+ createTelegramBusInstanceLifecycleAnnouncement({
498
+ target: ownTarget.target,
499
+ threadName: ownTarget.threadName,
500
+ slot: ownTarget.slot,
501
+ state: "connected",
502
+ });
503
+ try {
504
+ await deps.callApi("sendMessage", {
505
+ chat_id: connectedAnnouncement.target.chatId,
506
+ message_thread_id: connectedAnnouncement.target.threadId,
507
+ text: connectedAnnouncement.text,
508
+ parse_mode: connectedAnnouncement.parseMode,
509
+ });
510
+ } catch (error) {
511
+ deps.recordRuntimeEvent("telegram", error, {
512
+ phase: "leader-topic-announce",
513
+ instanceId: deps.instanceId,
514
+ chatId: ownTarget.target.chatId,
515
+ threadId: ownTarget.target.threadId,
516
+ slot: ownTarget.slot,
517
+ });
518
+ }
519
+ };
520
+ }
521
+
522
+ export function createTelegramBusLeaderApiProxy(
523
+ deps: TelegramBusLeaderApiProxyDeps,
524
+ ): (method: string, args: unknown[]) => Promise<unknown> {
525
+ return async (method, args) => {
526
+ if (method === "call") {
527
+ const body = args[1] as Record<string, unknown>;
528
+ try {
529
+ return await deps.call(
530
+ args[0] as string,
531
+ body,
532
+ args[2] as TelegramApiCallOptions | undefined,
533
+ );
534
+ } catch (error) {
535
+ await deps.recoverStaleTargetError?.(body, error);
536
+ throw error;
537
+ }
538
+ }
539
+ if (method === "callMultipart") {
540
+ const fields = args[1] as Record<string, string>;
541
+ try {
542
+ return await deps.callMultipart(
543
+ args[0] as string,
544
+ fields,
545
+ args[2] as string,
546
+ args[3] as string,
547
+ args[4] as string,
548
+ args[5] as TelegramApiCallOptions | undefined,
549
+ );
550
+ } catch (error) {
551
+ await deps.recoverStaleTargetError?.(fields, error);
552
+ throw error;
553
+ }
554
+ }
555
+ if (method === "downloadFile") {
556
+ return deps.downloadFile(args[0] as string, args[1] as string);
557
+ }
558
+ throw new Error(`Unsupported Telegram bus API method: ${method}`);
559
+ };
560
+ }
561
+
562
+ export function createTelegramBusLeaderEnvelopeHandler(deps: {
563
+ followerRegistry: TelegramBusFollowerRegistry;
564
+ authSecret?: string;
565
+ getNowMs?: () => number;
566
+ timeoutMs?: number;
567
+ callApi?: (method: string, args: unknown[]) => Promise<unknown> | unknown;
568
+ authorizeFollowerApiCall?: (input: {
569
+ follower: TelegramBusFollowerView;
570
+ method: string;
571
+ args: unknown[];
572
+ }) => boolean;
573
+ provisionFollowerTarget?: (
574
+ registration: TelegramBusInstanceRegistration,
575
+ ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
576
+ }): (
577
+ envelope: TelegramBusEnvelope,
578
+ ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
579
+ const getNowMs = deps.getNowMs ?? Date.now;
580
+ const forwardToFollower = async (
581
+ envelope: Extract<
582
+ TelegramBusEnvelope,
583
+ {
584
+ kind:
585
+ | "leader.forwardCallback"
586
+ | "leader.forwardReaction"
587
+ | "leader.forwardMessage"
588
+ | "leader.forwardEditedMessage";
589
+ }
590
+ >,
591
+ ): Promise<TelegramBusEnvelope> => {
592
+ const follower = deps.followerRegistry.get(envelope.recipientInstanceId);
593
+ if (!follower) {
594
+ return {
595
+ kind: "bus.ack",
596
+ requestId: envelope.requestId,
597
+ ok: false,
598
+ message: "Unknown Telegram bus follower instance.",
599
+ };
600
+ }
601
+ if (!follower.busSocketPath) {
602
+ return {
603
+ kind: "bus.ack",
604
+ requestId: envelope.requestId,
605
+ ok: false,
606
+ message: "Telegram bus follower does not expose a receiver socket.",
607
+ };
608
+ }
609
+ deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
610
+ try {
611
+ const response = await sendTelegramBusLocalEnvelope({
612
+ socketPath: follower.busSocketPath,
613
+ envelope,
614
+ timeoutMs: deps.timeoutMs,
615
+ });
616
+ if (response?.kind === "bus.ack" && response.ok) {
617
+ deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
618
+ return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
619
+ }
620
+ const message =
621
+ response?.kind === "bus.ack" ? response.message : undefined;
622
+ return {
623
+ kind: "bus.ack",
624
+ requestId: envelope.requestId,
625
+ ok: false,
626
+ message: message ?? "Telegram bus follower rejected forwarded update.",
627
+ };
628
+ } catch (error) {
629
+ return {
630
+ kind: "bus.ack",
631
+ requestId: envelope.requestId,
632
+ ok: false,
633
+ message:
634
+ error instanceof Error
635
+ ? error.message
636
+ : "Telegram bus follower forwarding failed.",
637
+ };
638
+ }
639
+ };
640
+ return async (envelope) => {
641
+ if (
642
+ envelope.kind !== "bus.ack" &&
643
+ !isTelegramBusEnvelopeAuthorized(envelope, deps.authSecret)
644
+ ) {
645
+ return createUnauthorizedBusAck(envelope.requestId);
646
+ }
647
+ switch (envelope.kind) {
648
+ case "follower.register": {
649
+ try {
650
+ const target = await deps.provisionFollowerTarget?.(
651
+ envelope.registration,
652
+ );
653
+ const registeredTarget = target ?? envelope.registration.target;
654
+ deps.followerRegistry.register({
655
+ ...envelope.registration,
656
+ connectedAtMs: getNowMs(),
657
+ target: registeredTarget,
658
+ });
659
+ return {
660
+ kind: "bus.ack",
661
+ requestId: envelope.requestId,
662
+ ok: true,
663
+ ...(registeredTarget ? { result: registeredTarget } : {}),
664
+ };
665
+ } catch (error) {
666
+ return {
667
+ kind: "bus.ack",
668
+ requestId: envelope.requestId,
669
+ ok: false,
670
+ message:
671
+ error instanceof Error
672
+ ? error.message
673
+ : "Telegram bus follower target provisioning failed.",
674
+ };
675
+ }
676
+ }
677
+ case "follower.heartbeat": {
678
+ const follower = deps.followerRegistry.heartbeat(
679
+ envelope.instanceId,
680
+ getNowMs(),
681
+ );
682
+ return follower
683
+ ? { kind: "bus.ack", requestId: envelope.requestId, ok: true }
684
+ : {
685
+ kind: "bus.ack",
686
+ requestId: envelope.requestId,
687
+ ok: false,
688
+ message: "Unknown Telegram bus follower instance.",
689
+ };
690
+ }
691
+ case "leader.forwardCallback":
692
+ case "leader.forwardReaction":
693
+ case "leader.forwardMessage":
694
+ case "leader.forwardEditedMessage":
695
+ return forwardToFollower(envelope);
696
+ case "follower.callApi":
697
+ return handleFollowerApiCall(envelope, { ...deps, getNowMs });
698
+ default:
699
+ return {
700
+ kind: "bus.ack",
701
+ requestId: envelope.requestId,
702
+ ok: false,
703
+ message: "Telegram bus envelope is not handled by this leader.",
704
+ };
705
+ }
706
+ };
707
+ }
708
+
709
+ async function handleFollowerApiCall(
710
+ envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>,
711
+ deps: {
712
+ followerRegistry: TelegramBusFollowerRegistry;
713
+ getNowMs: () => number;
714
+ callApi?: (method: string, args: unknown[]) => Promise<unknown> | unknown;
715
+ authorizeFollowerApiCall?: (input: {
716
+ follower: TelegramBusFollowerView;
717
+ method: string;
718
+ args: unknown[];
719
+ }) => boolean;
720
+ },
721
+ ): Promise<TelegramBusEnvelope> {
722
+ const follower = deps.followerRegistry.get(envelope.instanceId);
723
+ if (!follower) {
724
+ return {
725
+ kind: "bus.ack",
726
+ requestId: envelope.requestId,
727
+ ok: false,
728
+ message: "Unknown Telegram bus follower instance.",
729
+ };
730
+ }
731
+ deps.followerRegistry.heartbeat(envelope.instanceId, deps.getNowMs());
732
+ if (
733
+ deps.authorizeFollowerApiCall &&
734
+ !deps.authorizeFollowerApiCall({
735
+ follower,
736
+ method: envelope.method,
737
+ args: envelope.args,
738
+ })
739
+ ) {
740
+ return {
741
+ kind: "bus.ack",
742
+ requestId: envelope.requestId,
743
+ ok: false,
744
+ message: "Telegram bus API call is not allowed for this follower.",
745
+ };
746
+ }
747
+ if (!deps.callApi) {
748
+ return {
749
+ kind: "bus.ack",
750
+ requestId: envelope.requestId,
751
+ ok: false,
752
+ message: "Telegram bus leader does not expose API calling.",
753
+ };
754
+ }
755
+ try {
756
+ return {
757
+ kind: "bus.ack",
758
+ requestId: envelope.requestId,
759
+ ok: true,
760
+ result: await deps.callApi(envelope.method, envelope.args),
761
+ };
762
+ } catch (error) {
763
+ return {
764
+ kind: "bus.ack",
765
+ requestId: envelope.requestId,
766
+ ok: false,
767
+ message:
768
+ error instanceof Error
769
+ ? error.message
770
+ : "Telegram bus API call failed.",
771
+ };
772
+ }
773
+ }
774
+
775
+ export interface TelegramBusLeaderActivationSchedulerDeps<TContext> {
776
+ isBusEnabled: () => boolean;
777
+ ownsPolling: (ctx: TContext) => boolean;
778
+ isBusPollingStarted: () => boolean;
779
+ setBusPollingStarted: (started: boolean) => void;
780
+ stopClassicPolling: () => Promise<void>;
781
+ startClassicPolling: (ctx: TContext) => void | Promise<void>;
782
+ startBusLeaderPolling: (ctx: TContext) => Promise<void>;
783
+ updateStatus: (ctx: TContext) => void;
784
+ recordRuntimeEvent?: (
785
+ category: string,
786
+ error: unknown,
787
+ details?: Record<string, unknown>,
788
+ ) => void;
789
+ }
790
+
791
+ export function createTelegramBusLeaderActivationScheduler<TContext>(
792
+ deps: TelegramBusLeaderActivationSchedulerDeps<TContext>,
793
+ ): (ctx: TContext) => void {
794
+ let pending = false;
795
+ return (ctx) => {
796
+ if (deps.isBusPollingStarted() || pending) return;
797
+ if (!deps.isBusEnabled()) return;
798
+ if (!deps.ownsPolling(ctx)) return;
799
+ pending = true;
800
+ const timer = setTimeout(() => {
801
+ void (async () => {
802
+ try {
803
+ if (deps.isBusPollingStarted()) return;
804
+ if (!deps.isBusEnabled()) return;
805
+ if (!deps.ownsPolling(ctx)) return;
806
+ await deps.stopClassicPolling();
807
+ try {
808
+ await deps.startBusLeaderPolling(ctx);
809
+ deps.setBusPollingStarted(true);
810
+ deps.updateStatus(ctx);
811
+ deps.recordRuntimeEvent?.(
812
+ "bus",
813
+ "Telegram bus leader mode activated",
814
+ { phase: "leader-hot-switch" },
815
+ );
816
+ } catch (error) {
817
+ deps.recordRuntimeEvent?.("bus", error, {
818
+ phase: "leader-hot-switch",
819
+ });
820
+ deps.setBusPollingStarted(false);
821
+ await deps.startClassicPolling(ctx);
822
+ }
823
+ } finally {
824
+ pending = false;
825
+ }
826
+ })();
827
+ }, 0);
828
+ timer.unref?.();
829
+ };
830
+ }
831
+
832
+ export function createTelegramBusLeaderRuntime<TContext>(
833
+ deps: TelegramBusLeaderRuntimeDeps<TContext>,
834
+ ): TelegramBusLeaderRuntime<TContext> {
835
+ const getNowMs = deps.getNowMs ?? Date.now;
836
+ const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
837
+ const followerStaleAfterMs = deps.followerStaleAfterMs ?? 2000;
838
+ let pruneInterval: ReturnType<typeof setInterval> | undefined;
839
+ const stopPruning = () => {
840
+ if (!pruneInterval) return;
841
+ clearInterval(pruneInterval);
842
+ pruneInterval = undefined;
843
+ };
844
+ const pruneFollowers = async () => {
845
+ const removed = deps.followerRegistry.pruneStale(
846
+ getNowMs(),
847
+ followerStaleAfterMs,
848
+ );
849
+ for (const follower of removed) {
850
+ try {
851
+ await deps.onFollowerPruned?.(follower);
852
+ } catch (error) {
853
+ deps.recordRuntimeEvent?.("bus", error, {
854
+ phase: "follower-prune-offline",
855
+ instanceId: follower.instanceId,
856
+ });
857
+ }
858
+ deps.recordRuntimeEvent?.("bus", "Telegram bus follower timed out", {
859
+ phase: "follower-prune",
860
+ instanceId: follower.instanceId,
861
+ });
862
+ }
863
+ };
864
+ const startPruning = () => {
865
+ stopPruning();
866
+ pruneInterval = setInterval(() => {
867
+ void pruneFollowers();
868
+ }, followerPruneIntervalMs);
869
+ pruneInterval.unref?.();
870
+ };
871
+ const localServer = createTelegramBusLocalServer({
872
+ socketPath: deps.socketPath,
873
+ handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
874
+ followerRegistry: deps.followerRegistry,
875
+ authSecret: deps.authSecret,
876
+ getNowMs,
877
+ callApi: deps.callApi,
878
+ authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
879
+ provisionFollowerTarget: deps.provisionFollowerTarget,
880
+ }),
881
+ });
882
+ return {
883
+ startPolling: async (ctx) => {
884
+ await localServer.start();
885
+ try {
886
+ await deps.restoreFollowerRegistry?.();
887
+ } catch (error) {
888
+ deps.recordRuntimeEvent?.("bus", error, {
889
+ phase: "follower-registry-restore",
890
+ });
891
+ }
892
+ startPruning();
893
+ try {
894
+ await deps.provisionLeaderTarget?.(ctx);
895
+ await deps.startPolling(ctx);
896
+ } catch (error) {
897
+ stopPruning();
898
+ await localServer.stop();
899
+ throw error;
900
+ }
901
+ },
902
+ stopPolling: async () => {
903
+ stopPruning();
904
+ try {
905
+ await deps.stopPolling();
906
+ } finally {
907
+ await localServer
908
+ .stop()
909
+ .catch((error) =>
910
+ deps.recordRuntimeEvent?.("bus", error, { phase: "stop" }),
911
+ );
912
+ }
913
+ },
914
+ };
915
+ }