@llblab/pi-telegram 0.17.4 → 0.18.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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -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 +483 -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 +1 -1
  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 +405 -40
  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/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/bus.ts ADDED
@@ -0,0 +1,866 @@
1
+ /**
2
+ * Telegram multi-instance bus protocol and IPC helpers
3
+ * Zones: multi-instance bus, local IPC contract, live instance routing
4
+ * Owns serializable bus envelopes, socket/auth helpers, local IPC client/server primitives,
5
+ * cross-instance forwarding helpers, and the live follower registry model.
6
+ */
7
+
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
10
+ import {
11
+ createConnection,
12
+ createServer,
13
+ type Server,
14
+ type Socket,
15
+ } from "node:net";
16
+ import { homedir, platform as getPlatform } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+
19
+ import type { TelegramTarget } from "./target.ts";
20
+
21
+ export type TelegramBusRole = "leader" | "follower";
22
+
23
+ function getAgentDir(): string {
24
+ return process.env.PI_CODING_AGENT_DIR
25
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
26
+ : join(homedir(), ".pi", "agent");
27
+ }
28
+
29
+ export function createTelegramBusAuthSecret(): string {
30
+ return randomBytes(32).toString("base64url");
31
+ }
32
+
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
+ export function getTelegramBusSocketPath(
50
+ agentDir = getAgentDir(),
51
+ platform = getPlatform(),
52
+ ): string {
53
+ if (platform === "win32") {
54
+ return getTelegramBusPipePath({ agentDir, scope: "bus" });
55
+ }
56
+ return join(agentDir, "tmp", "telegram", "bus.sock");
57
+ }
58
+
59
+ export function getTelegramBusFollowerSocketPath(
60
+ instanceId: string,
61
+ agentDir = getAgentDir(),
62
+ platform = getPlatform(),
63
+ ): string {
64
+ if (platform === "win32") {
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
+ );
77
+ }
78
+
79
+ export interface TelegramBusInstanceRegistration {
80
+ instanceId: string;
81
+ profileKey?: string;
82
+ threadName?: string;
83
+ cwd?: string;
84
+ pid?: number;
85
+ target?: TelegramTarget;
86
+ busSocketPath?: string;
87
+ connectedAtMs: number;
88
+ }
89
+
90
+ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration {
91
+ lastHeartbeatMs: number;
92
+ }
93
+
94
+ export function isTelegramFollowerApiCallAllowed(input: {
95
+ follower: TelegramBusFollowerView;
96
+ method: string;
97
+ args: unknown[];
98
+ }): boolean {
99
+ const allowedCallMethods = new Set([
100
+ "answerCallbackQuery",
101
+ "answerGuestQuery",
102
+ "closeForumTopic",
103
+ "deleteForumTopic",
104
+ "deleteMessage",
105
+ "editForumTopic",
106
+ "editMessageText",
107
+ "sendChatAction",
108
+ "sendMessage",
109
+ "sendMessageDraft",
110
+ "sendRichMessage",
111
+ "sendRichMessageDraft",
112
+ ]);
113
+ const allowedMultipartMethods = new Set([
114
+ "sendDocument",
115
+ "sendMediaGroup",
116
+ "sendPhoto",
117
+ "sendVoice",
118
+ ]);
119
+ const target = input.follower.target;
120
+ const matchesId = (value: unknown, expected: number): boolean =>
121
+ value === expected || value === String(expected);
122
+ const isTargetScoped = (body: unknown): boolean => {
123
+ if (!target) return false;
124
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
125
+ const record = body as Record<string, unknown>;
126
+ if (!matchesId(record.chat_id, target.chatId)) return false;
127
+ if (target.threadId === undefined) return true;
128
+ return matchesId(record.message_thread_id, target.threadId);
129
+ };
130
+ if (input.method === "downloadFile") return true;
131
+ if (input.method === "call") {
132
+ const apiMethod = input.args[0];
133
+ if (typeof apiMethod !== "string") return false;
134
+ if (
135
+ apiMethod === "answerCallbackQuery" ||
136
+ apiMethod === "answerGuestQuery"
137
+ ) {
138
+ return true;
139
+ }
140
+ return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
141
+ }
142
+ if (input.method === "callMultipart") {
143
+ const apiMethod = input.args[0];
144
+ return (
145
+ typeof apiMethod === "string" &&
146
+ allowedMultipartMethods.has(apiMethod) &&
147
+ isTargetScoped(input.args[1])
148
+ );
149
+ }
150
+ return false;
151
+ }
152
+
153
+ export type TelegramBusEnvelope = (
154
+ | {
155
+ kind: "follower.register";
156
+ requestId: string;
157
+ registration: TelegramBusInstanceRegistration;
158
+ }
159
+ | {
160
+ kind: "follower.heartbeat";
161
+ requestId: string;
162
+ instanceId: string;
163
+ sentAtMs: number;
164
+ }
165
+ | {
166
+ kind: "leader.forwardCallback";
167
+ requestId: string;
168
+ recipientInstanceId: string;
169
+ query: unknown;
170
+ sentAtMs: number;
171
+ }
172
+ | {
173
+ kind: "leader.forwardReaction";
174
+ requestId: string;
175
+ recipientInstanceId: string;
176
+ reactionUpdate: unknown;
177
+ sentAtMs: number;
178
+ }
179
+ | {
180
+ kind: "leader.forwardMessage";
181
+ requestId: string;
182
+ recipientInstanceId: string;
183
+ message: unknown;
184
+ sentAtMs: number;
185
+ }
186
+ | {
187
+ kind: "leader.forwardEditedMessage";
188
+ requestId: string;
189
+ recipientInstanceId: string;
190
+ message: unknown;
191
+ sentAtMs: number;
192
+ }
193
+ | {
194
+ kind: "leader.replaceFollowerTarget";
195
+ requestId: string;
196
+ recipientInstanceId: string;
197
+ target: TelegramTarget & { threadId: number };
198
+ oldTarget?: TelegramTarget & { threadId: number };
199
+ reason: "thread-restore";
200
+ sentAtMs: number;
201
+ }
202
+ | {
203
+ kind: "follower.callApi";
204
+ requestId: string;
205
+ instanceId: string;
206
+ method: string;
207
+ args: unknown[];
208
+ sentAtMs: number;
209
+ }
210
+ | {
211
+ kind: "bus.ack";
212
+ requestId: string;
213
+ ok: boolean;
214
+ message?: string;
215
+ result?: unknown;
216
+ }
217
+ ) & { auth?: string };
218
+
219
+ export function createTelegramBusRequestId(input: {
220
+ instanceId: string;
221
+ sequence: number;
222
+ }): string {
223
+ return `${input.instanceId}:${input.sequence}`;
224
+ }
225
+
226
+ export function encodeTelegramBusEnvelope(
227
+ envelope: TelegramBusEnvelope,
228
+ ): string {
229
+ return `${JSON.stringify(envelope)}\n`;
230
+ }
231
+
232
+ export function parseTelegramBusEnvelope(
233
+ line: string,
234
+ ): TelegramBusEnvelope | undefined {
235
+ let value: unknown;
236
+ try {
237
+ value = JSON.parse(line);
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ if (!isRecord(value)) return undefined;
242
+ const kind = value.kind;
243
+ const requestId = value.requestId;
244
+ if (typeof kind !== "string" || typeof requestId !== "string") {
245
+ return undefined;
246
+ }
247
+ let envelope: TelegramBusEnvelope | undefined;
248
+ switch (kind) {
249
+ case "follower.register":
250
+ envelope = parseRegisterEnvelope(value, requestId);
251
+ break;
252
+ case "follower.heartbeat":
253
+ envelope = parseHeartbeatEnvelope(value, requestId);
254
+ break;
255
+ case "leader.forwardCallback":
256
+ envelope = parseForwardCallbackEnvelope(value, requestId);
257
+ break;
258
+ case "leader.forwardReaction":
259
+ envelope = parseForwardReactionEnvelope(value, requestId);
260
+ break;
261
+ case "leader.forwardMessage":
262
+ envelope = parseForwardMessageEnvelope(
263
+ value,
264
+ requestId,
265
+ "leader.forwardMessage",
266
+ );
267
+ break;
268
+ case "leader.forwardEditedMessage":
269
+ envelope = parseForwardMessageEnvelope(
270
+ value,
271
+ requestId,
272
+ "leader.forwardEditedMessage",
273
+ );
274
+ break;
275
+ case "leader.replaceFollowerTarget":
276
+ envelope = parseReplaceFollowerTargetEnvelope(value, requestId);
277
+ break;
278
+ case "follower.callApi":
279
+ envelope = parseCallApiEnvelope(value, requestId);
280
+ break;
281
+ case "bus.ack":
282
+ envelope = parseAckEnvelope(value, requestId);
283
+ break;
284
+ default:
285
+ return undefined;
286
+ }
287
+ const auth = value.auth;
288
+ if (envelope && typeof auth === "string") envelope.auth = auth;
289
+ return envelope;
290
+ }
291
+
292
+ export interface TelegramBusLocalServer {
293
+ start: () => Promise<void>;
294
+ stop: () => Promise<void>;
295
+ }
296
+
297
+ export interface TelegramBusLocalServerDeps {
298
+ socketPath: string;
299
+ handleEnvelope: (
300
+ envelope: TelegramBusEnvelope,
301
+ ) =>
302
+ | Promise<TelegramBusEnvelope | undefined>
303
+ | TelegramBusEnvelope
304
+ | undefined;
305
+ }
306
+
307
+ export interface TelegramBusLocalClientOptions {
308
+ socketPath: string;
309
+ envelope: TelegramBusEnvelope;
310
+ timeoutMs?: number;
311
+ }
312
+
313
+ export interface TelegramBusForeignOwnedForwarderDeps {
314
+ socketPath: string;
315
+ createRequestId: () => string;
316
+ getNowMs?: () => number;
317
+ timeoutMs?: number;
318
+ getAuthSecret?: () => string | undefined;
319
+ }
320
+
321
+ export function createTelegramBusForeignOwnedUpdateForwarder<
322
+ TContext,
323
+ TReactionUpdate,
324
+ TCallbackQuery,
325
+ TMessage = unknown,
326
+ >(
327
+ deps: TelegramBusForeignOwnedForwarderDeps,
328
+ ): {
329
+ forwardCallback: (input: {
330
+ query: TCallbackQuery;
331
+ ownership: { instanceId: string };
332
+ ctx: TContext;
333
+ }) => Promise<boolean>;
334
+ forwardReaction: (input: {
335
+ reactionUpdate: TReactionUpdate;
336
+ ownership: { instanceId: string };
337
+ ctx: TContext;
338
+ }) => Promise<boolean>;
339
+ forwardMessage: (input: {
340
+ message: TMessage;
341
+ ownership: { instanceId: string };
342
+ ctx: TContext;
343
+ }) => Promise<boolean>;
344
+ forwardEditedMessage: (input: {
345
+ message: TMessage;
346
+ ownership: { instanceId: string };
347
+ ctx: TContext;
348
+ }) => Promise<boolean>;
349
+ } {
350
+ const getNowMs = deps.getNowMs ?? Date.now;
351
+ const send = async (envelope: TelegramBusEnvelope): Promise<boolean> => {
352
+ if (deps.getAuthSecret) envelope.auth = deps.getAuthSecret();
353
+ const response = await sendTelegramBusLocalEnvelope({
354
+ socketPath: deps.socketPath,
355
+ envelope,
356
+ timeoutMs: deps.timeoutMs,
357
+ });
358
+ return response?.kind === "bus.ack" && response.ok;
359
+ };
360
+ return {
361
+ forwardCallback: ({ query, ownership }) =>
362
+ send({
363
+ kind: "leader.forwardCallback",
364
+ requestId: deps.createRequestId(),
365
+ recipientInstanceId: ownership.instanceId,
366
+ query,
367
+ sentAtMs: getNowMs(),
368
+ }),
369
+ forwardReaction: ({ reactionUpdate, ownership }) =>
370
+ send({
371
+ kind: "leader.forwardReaction",
372
+ requestId: deps.createRequestId(),
373
+ recipientInstanceId: ownership.instanceId,
374
+ reactionUpdate,
375
+ sentAtMs: getNowMs(),
376
+ }),
377
+ forwardMessage: ({ message, ownership }) =>
378
+ send({
379
+ kind: "leader.forwardMessage",
380
+ requestId: deps.createRequestId(),
381
+ recipientInstanceId: ownership.instanceId,
382
+ message,
383
+ sentAtMs: getNowMs(),
384
+ }),
385
+ forwardEditedMessage: ({ message, ownership }) =>
386
+ send({
387
+ kind: "leader.forwardEditedMessage",
388
+ requestId: deps.createRequestId(),
389
+ recipientInstanceId: ownership.instanceId,
390
+ message,
391
+ sentAtMs: getNowMs(),
392
+ }),
393
+ };
394
+ }
395
+
396
+ export interface TelegramBusFollowerThreadRestoreHandlerDeps {
397
+ followerRegistry: Pick<TelegramBusFollowerRegistry, "get" | "register">;
398
+ followerTargetController: ReturnType<typeof createTelegramBusFollowerTargetController>;
399
+ onRestored?: () => void;
400
+ }
401
+
402
+ export function listTelegramBusLiveThreadTargets(input: {
403
+ leaderTarget?: TelegramTarget;
404
+ followers: readonly TelegramBusFollowerView[];
405
+ }): TelegramTarget[] {
406
+ const targets: TelegramTarget[] = [];
407
+ if (input.leaderTarget?.threadId !== undefined) {
408
+ targets.push(input.leaderTarget);
409
+ }
410
+ for (const follower of input.followers) {
411
+ if (follower.target?.threadId !== undefined) targets.push(follower.target);
412
+ }
413
+ return targets;
414
+ }
415
+
416
+ export function createTelegramBusFollowerTargetController(
417
+ deps: TelegramBusForeignOwnedForwarderDeps,
418
+ ): {
419
+ replaceTarget: (input: {
420
+ follower: TelegramBusFollowerView;
421
+ target: TelegramTarget & { threadId: number };
422
+ oldTarget?: TelegramTarget & { threadId: number };
423
+ reason: "thread-restore";
424
+ }) => Promise<boolean>;
425
+ } {
426
+ const getNowMs = deps.getNowMs ?? Date.now;
427
+ return {
428
+ async replaceTarget({ follower, target, oldTarget, reason }) {
429
+ if (!follower.busSocketPath) return false;
430
+ const envelope: TelegramBusEnvelope = {
431
+ kind: "leader.replaceFollowerTarget",
432
+ requestId: deps.createRequestId(),
433
+ recipientInstanceId: follower.instanceId,
434
+ target,
435
+ ...(oldTarget ? { oldTarget } : {}),
436
+ reason,
437
+ sentAtMs: getNowMs(),
438
+ };
439
+ if (deps.getAuthSecret) envelope.auth = deps.getAuthSecret();
440
+ const response = await sendTelegramBusLocalEnvelope({
441
+ socketPath: follower.busSocketPath,
442
+ envelope,
443
+ timeoutMs: deps.timeoutMs,
444
+ });
445
+ return response?.kind === "bus.ack" && response.ok;
446
+ },
447
+ };
448
+ }
449
+
450
+ export function createTelegramBusFollowerThreadRestoreHandler(
451
+ deps: TelegramBusFollowerThreadRestoreHandlerDeps,
452
+ ): (input: {
453
+ record: { instanceId?: string };
454
+ target: TelegramTarget & { threadId: number };
455
+ oldTarget?: TelegramTarget & { threadId: number };
456
+ }) => Promise<boolean> {
457
+ return async ({
458
+ record,
459
+ target,
460
+ oldTarget,
461
+ }) => {
462
+ if (!record.instanceId) return false;
463
+ const follower = deps.followerRegistry.get(record.instanceId);
464
+ if (!follower) return false;
465
+ const replaced = await deps.followerTargetController.replaceTarget({
466
+ follower,
467
+ target,
468
+ oldTarget,
469
+ reason: "thread-restore",
470
+ });
471
+ if (!replaced) return false;
472
+ deps.followerRegistry.register({
473
+ ...follower,
474
+ target,
475
+ connectedAtMs: follower.connectedAtMs,
476
+ });
477
+ deps.onRestored?.();
478
+ return true;
479
+ };
480
+ }
481
+
482
+ export function isTelegramBusEnvelopeAuthorized(
483
+ envelope: TelegramBusEnvelope,
484
+ secret: string | undefined,
485
+ ): boolean {
486
+ return !secret || envelope.auth === secret;
487
+ }
488
+
489
+ export function createUnauthorizedBusAck(
490
+ requestId: string,
491
+ ): TelegramBusEnvelope {
492
+ return {
493
+ kind: "bus.ack",
494
+ requestId,
495
+ ok: false,
496
+ message: "Unauthorized Telegram bus envelope.",
497
+ };
498
+ }
499
+
500
+ export function createTelegramBusLocalServer(
501
+ deps: TelegramBusLocalServerDeps,
502
+ ): TelegramBusLocalServer {
503
+ let server: Server | undefined;
504
+ const sockets = new Set<Socket>();
505
+ const closeSocket = (socket: Socket) => {
506
+ sockets.delete(socket);
507
+ socket.destroy();
508
+ };
509
+ return {
510
+ start: async () => {
511
+ if (server) return;
512
+ const usesWindowsPipe = isWindowsPipePath(deps.socketPath);
513
+ if (!usesWindowsPipe) {
514
+ const socketDir = dirname(deps.socketPath);
515
+ mkdirSync(socketDir, { recursive: true, mode: 0o700 });
516
+ chmodSync(socketDir, 0o700);
517
+ if (existsSync(deps.socketPath)) unlinkSync(deps.socketPath);
518
+ }
519
+ server = createServer((socket) => {
520
+ sockets.add(socket);
521
+ let buffer = "";
522
+ socket.setEncoding("utf8");
523
+ socket.on("data", (chunk) => {
524
+ buffer += chunk;
525
+ const lines = buffer.split("\n");
526
+ buffer = lines.pop() ?? "";
527
+ for (const line of lines) {
528
+ void handleTelegramBusSocketLine(line, socket, deps.handleEnvelope);
529
+ }
530
+ });
531
+ socket.on("close", () => sockets.delete(socket));
532
+ socket.on("error", () => closeSocket(socket));
533
+ });
534
+ await new Promise<void>((resolve, reject) => {
535
+ server?.once("error", reject);
536
+ server?.listen(deps.socketPath, resolve);
537
+ });
538
+ if (!usesWindowsPipe) chmodSync(deps.socketPath, 0o600);
539
+ },
540
+ stop: async () => {
541
+ const activeServer = server;
542
+ server = undefined;
543
+ for (const socket of sockets) closeSocket(socket);
544
+ if (activeServer) {
545
+ await new Promise<void>((resolve) =>
546
+ activeServer.close(() => resolve()),
547
+ );
548
+ }
549
+ if (!isWindowsPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
550
+ unlinkSync(deps.socketPath);
551
+ }
552
+ },
553
+ };
554
+ }
555
+
556
+ export function sendTelegramBusLocalEnvelope(
557
+ options: TelegramBusLocalClientOptions,
558
+ ): Promise<TelegramBusEnvelope | undefined> {
559
+ const timeoutMs = options.timeoutMs ?? 1000;
560
+ return new Promise((resolve, reject) => {
561
+ const socket = createConnection(options.socketPath);
562
+ let settled = false;
563
+ let buffer = "";
564
+ const settle = (callback: () => void) => {
565
+ if (settled) return;
566
+ settled = true;
567
+ clearTimeout(timeout);
568
+ socket.destroy();
569
+ callback();
570
+ };
571
+ const timeout = setTimeout(() => {
572
+ settle(() =>
573
+ reject(new Error("Timed out waiting for Telegram bus response")),
574
+ );
575
+ }, timeoutMs);
576
+ timeout.unref?.();
577
+ socket.setEncoding("utf8");
578
+ socket.once("connect", () => {
579
+ socket.write(encodeTelegramBusEnvelope(options.envelope));
580
+ });
581
+ socket.on("data", (chunk) => {
582
+ buffer += chunk;
583
+ const newlineIndex = buffer.indexOf("\n");
584
+ if (newlineIndex < 0) return;
585
+ const line = buffer.slice(0, newlineIndex);
586
+ settle(() => resolve(parseTelegramBusEnvelope(line)));
587
+ });
588
+ socket.once("error", (error) => settle(() => reject(error)));
589
+ socket.once("end", () => settle(() => resolve(undefined)));
590
+ });
591
+ }
592
+
593
+ export interface TelegramBusFollowerRegistry {
594
+ register: (
595
+ registration: TelegramBusInstanceRegistration,
596
+ ) => TelegramBusFollowerView;
597
+ heartbeat: (
598
+ instanceId: string,
599
+ nowMs: number,
600
+ ) => TelegramBusFollowerView | undefined;
601
+ get: (instanceId: string) => TelegramBusFollowerView | undefined;
602
+ getByTarget: (target: TelegramTarget) => TelegramBusFollowerView | undefined;
603
+ list: () => TelegramBusFollowerView[];
604
+ remove: (instanceId: string) => boolean;
605
+ pruneStale: (
606
+ nowMs: number,
607
+ staleAfterMs: number,
608
+ ) => TelegramBusFollowerView[];
609
+ }
610
+
611
+ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry {
612
+ const followers = new Map<string, TelegramBusFollowerView>();
613
+ const clone = (
614
+ follower: TelegramBusFollowerView,
615
+ ): TelegramBusFollowerView => ({
616
+ ...follower,
617
+ target: follower.target ? { ...follower.target } : undefined,
618
+ });
619
+ return {
620
+ register: (registration) => {
621
+ const existing = followers.get(registration.instanceId);
622
+ const next: TelegramBusFollowerView = {
623
+ ...registration,
624
+ target: registration.target ? { ...registration.target } : undefined,
625
+ lastHeartbeatMs:
626
+ existing?.lastHeartbeatMs ?? registration.connectedAtMs,
627
+ };
628
+ followers.set(registration.instanceId, next);
629
+ return clone(next);
630
+ },
631
+ heartbeat: (instanceId, nowMs) => {
632
+ const existing = followers.get(instanceId);
633
+ if (!existing) return undefined;
634
+ const next = { ...existing, lastHeartbeatMs: nowMs };
635
+ followers.set(instanceId, next);
636
+ return clone(next);
637
+ },
638
+ get: (instanceId) => {
639
+ const existing = followers.get(instanceId);
640
+ return existing ? clone(existing) : undefined;
641
+ },
642
+ getByTarget: (target) => {
643
+ for (const follower of followers.values()) {
644
+ if (
645
+ follower.target?.chatId === target.chatId &&
646
+ follower.target.threadId === target.threadId
647
+ ) {
648
+ return clone(follower);
649
+ }
650
+ }
651
+ return undefined;
652
+ },
653
+ list: () => [...followers.values()].map(clone),
654
+ remove: (instanceId) => followers.delete(instanceId),
655
+ pruneStale: (nowMs, staleAfterMs) => {
656
+ const removed: TelegramBusFollowerView[] = [];
657
+ for (const [instanceId, follower] of followers.entries()) {
658
+ if (nowMs - follower.lastHeartbeatMs <= staleAfterMs) continue;
659
+ followers.delete(instanceId);
660
+ removed.push(clone(follower));
661
+ }
662
+ return removed;
663
+ },
664
+ };
665
+ }
666
+
667
+ async function handleTelegramBusSocketLine(
668
+ line: string,
669
+ socket: Socket,
670
+ handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
671
+ ): Promise<void> {
672
+ const envelope = parseTelegramBusEnvelope(line);
673
+ if (!envelope) {
674
+ socket.write(
675
+ encodeTelegramBusEnvelope({
676
+ kind: "bus.ack",
677
+ requestId: "invalid",
678
+ ok: false,
679
+ message: "Invalid Telegram bus envelope.",
680
+ }),
681
+ );
682
+ return;
683
+ }
684
+ const response = await handleEnvelope(envelope);
685
+ if (response) socket.write(encodeTelegramBusEnvelope(response));
686
+ }
687
+
688
+ function parseRegisterEnvelope(
689
+ value: Record<string, unknown>,
690
+ requestId: string,
691
+ ): TelegramBusEnvelope | undefined {
692
+ const registration = parseRegistration(value.registration);
693
+ return registration
694
+ ? { kind: "follower.register", requestId, registration }
695
+ : undefined;
696
+ }
697
+
698
+ function parseHeartbeatEnvelope(
699
+ value: Record<string, unknown>,
700
+ requestId: string,
701
+ ): TelegramBusEnvelope | undefined {
702
+ return typeof value.instanceId === "string" &&
703
+ typeof value.sentAtMs === "number"
704
+ ? {
705
+ kind: "follower.heartbeat",
706
+ requestId,
707
+ instanceId: value.instanceId,
708
+ sentAtMs: value.sentAtMs,
709
+ }
710
+ : undefined;
711
+ }
712
+
713
+ function parseForwardCallbackEnvelope(
714
+ value: Record<string, unknown>,
715
+ requestId: string,
716
+ ): TelegramBusEnvelope | undefined {
717
+ return typeof value.recipientInstanceId === "string" &&
718
+ typeof value.sentAtMs === "number"
719
+ ? {
720
+ kind: "leader.forwardCallback",
721
+ requestId,
722
+ recipientInstanceId: value.recipientInstanceId,
723
+ query: value.query,
724
+ sentAtMs: value.sentAtMs,
725
+ }
726
+ : undefined;
727
+ }
728
+
729
+ function parseForwardReactionEnvelope(
730
+ value: Record<string, unknown>,
731
+ requestId: string,
732
+ ): TelegramBusEnvelope | undefined {
733
+ return typeof value.recipientInstanceId === "string" &&
734
+ typeof value.sentAtMs === "number"
735
+ ? {
736
+ kind: "leader.forwardReaction",
737
+ requestId,
738
+ recipientInstanceId: value.recipientInstanceId,
739
+ reactionUpdate: value.reactionUpdate,
740
+ sentAtMs: value.sentAtMs,
741
+ }
742
+ : undefined;
743
+ }
744
+
745
+ function parseForwardMessageEnvelope(
746
+ value: Record<string, unknown>,
747
+ requestId: string,
748
+ kind: "leader.forwardMessage" | "leader.forwardEditedMessage",
749
+ ): TelegramBusEnvelope | undefined {
750
+ return typeof value.recipientInstanceId === "string" &&
751
+ typeof value.sentAtMs === "number"
752
+ ? {
753
+ kind,
754
+ requestId,
755
+ recipientInstanceId: value.recipientInstanceId,
756
+ message: value.message,
757
+ sentAtMs: value.sentAtMs,
758
+ }
759
+ : undefined;
760
+ }
761
+
762
+ function parseReplaceFollowerTargetEnvelope(
763
+ value: Record<string, unknown>,
764
+ requestId: string,
765
+ ): TelegramBusEnvelope | undefined {
766
+ const target = parseThreadTarget(value.target);
767
+ const oldTarget = parseThreadTarget(value.oldTarget);
768
+ if (
769
+ typeof value.recipientInstanceId !== "string" ||
770
+ !target ||
771
+ (value.oldTarget !== undefined && !oldTarget) ||
772
+ value.reason !== "thread-restore" ||
773
+ typeof value.sentAtMs !== "number"
774
+ ) {
775
+ return undefined;
776
+ }
777
+ return {
778
+ kind: "leader.replaceFollowerTarget",
779
+ requestId,
780
+ recipientInstanceId: value.recipientInstanceId,
781
+ target,
782
+ ...(oldTarget ? { oldTarget } : {}),
783
+ reason: value.reason,
784
+ sentAtMs: value.sentAtMs,
785
+ };
786
+ }
787
+
788
+ function parseCallApiEnvelope(
789
+ value: Record<string, unknown>,
790
+ requestId: string,
791
+ ): TelegramBusEnvelope | undefined {
792
+ return typeof value.instanceId === "string" &&
793
+ typeof value.method === "string" &&
794
+ Array.isArray(value.args) &&
795
+ typeof value.sentAtMs === "number"
796
+ ? {
797
+ kind: "follower.callApi",
798
+ requestId,
799
+ instanceId: value.instanceId,
800
+ method: value.method,
801
+ args: value.args,
802
+ sentAtMs: value.sentAtMs,
803
+ }
804
+ : undefined;
805
+ }
806
+
807
+ function parseAckEnvelope(
808
+ value: Record<string, unknown>,
809
+ requestId: string,
810
+ ): TelegramBusEnvelope | undefined {
811
+ if (typeof value.ok !== "boolean") return undefined;
812
+ const envelope: TelegramBusEnvelope = {
813
+ kind: "bus.ack",
814
+ requestId,
815
+ ok: value.ok,
816
+ message: typeof value.message === "string" ? value.message : undefined,
817
+ };
818
+ if (Object.hasOwn(value, "result")) envelope.result = value.result;
819
+ return envelope;
820
+ }
821
+
822
+ function parseRegistration(
823
+ value: unknown,
824
+ ): TelegramBusInstanceRegistration | undefined {
825
+ if (!isRecord(value)) return undefined;
826
+ if (typeof value.instanceId !== "string") return undefined;
827
+ if (typeof value.connectedAtMs !== "number") return undefined;
828
+ const target = parseTarget(value.target);
829
+ if (value.target !== undefined && !target) return undefined;
830
+ const registration: TelegramBusInstanceRegistration = {
831
+ instanceId: value.instanceId,
832
+ connectedAtMs: value.connectedAtMs,
833
+ };
834
+ if (typeof value.profileKey === "string")
835
+ registration.profileKey = value.profileKey;
836
+ if (typeof value.threadName === "string")
837
+ registration.threadName = value.threadName;
838
+ if (typeof value.cwd === "string") registration.cwd = value.cwd;
839
+ if (typeof value.pid === "number") registration.pid = value.pid;
840
+ if (typeof value.busSocketPath === "string") {
841
+ registration.busSocketPath = value.busSocketPath;
842
+ }
843
+ if (target) registration.target = target;
844
+ return registration;
845
+ }
846
+
847
+ function parseTarget(value: unknown): TelegramTarget | undefined {
848
+ if (value === undefined) return undefined;
849
+ if (!isRecord(value) || typeof value.chatId !== "number") return undefined;
850
+ return typeof value.threadId === "number"
851
+ ? { chatId: value.chatId, threadId: value.threadId }
852
+ : { chatId: value.chatId };
853
+ }
854
+
855
+ function parseThreadTarget(
856
+ value: unknown,
857
+ ): (TelegramTarget & { threadId: number }) | undefined {
858
+ const target = parseTarget(value);
859
+ return target && typeof target.threadId === "number"
860
+ ? { chatId: target.chatId, threadId: target.threadId }
861
+ : undefined;
862
+ }
863
+
864
+ function isRecord(value: unknown): value is Record<string, unknown> {
865
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
866
+ }