@llblab/pi-telegram 0.18.4 → 0.18.6

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Telegram bus local transport boundary
3
+ * Zones: multi-instance bus, IPC transport, Windows named pipes, Unix sockets
4
+ * Owns endpoint derivation, transport-kind detection, retry/error classification, and small timing policy.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { createConnection } from "node:net";
9
+ import { join, resolve } from "node:path";
10
+
11
+ export type TelegramBusTransportKind = "pipe" | "socket";
12
+
13
+ export type TelegramBusTransportEventRecorder = (
14
+ phase: string,
15
+ details: Record<string, unknown>,
16
+ ) => void;
17
+
18
+ export type TelegramBusTransportEndpointDiagnostics = Record<
19
+ string,
20
+ unknown
21
+ > & {
22
+ endpoint: string;
23
+ transport: TelegramBusTransportKind;
24
+ };
25
+
26
+ export interface TelegramBusTransportRetryPolicy {
27
+ attempts: number;
28
+ delayMs: number;
29
+ }
30
+
31
+ export interface TelegramBusTransportRetryPolicyOverrides {
32
+ attempts?: number;
33
+ delayMs?: number;
34
+ }
35
+
36
+ export type TelegramBusTransportOperation = "registration" | "operation";
37
+
38
+ export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy = {
39
+ attempts: 10,
40
+ delayMs: 150,
41
+ };
42
+
43
+ export const TELEGRAM_BUS_OPERATION_RETRY: TelegramBusTransportRetryPolicy = {
44
+ attempts: 3,
45
+ delayMs: 100,
46
+ };
47
+
48
+ export const TELEGRAM_BUS_PIPE_REGISTRATION_RETRY =
49
+ TELEGRAM_BUS_REGISTRATION_RETRY;
50
+
51
+ export const TELEGRAM_BUS_PIPE_OPERATION_RETRY = TELEGRAM_BUS_OPERATION_RETRY;
52
+
53
+ export function getTelegramBusPipePath(input: {
54
+ agentDir: string;
55
+ scope: string;
56
+ }): string {
57
+ const digest = createHash("sha256")
58
+ .update(resolve(input.agentDir))
59
+ .digest("base64url")
60
+ .slice(0, 16);
61
+ const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
62
+ return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
63
+ }
64
+
65
+ export function isTelegramBusPipePath(endpoint: string): boolean {
66
+ return /^\\\\[.?]\\pipe\\/i.test(endpoint);
67
+ }
68
+
69
+ export function getTelegramBusTransportKind(
70
+ endpoint: string,
71
+ ): TelegramBusTransportKind {
72
+ return isTelegramBusPipePath(endpoint) ? "pipe" : "socket";
73
+ }
74
+
75
+ export function getTelegramBusEndpointDiagnostics(
76
+ endpoint: string,
77
+ ): TelegramBusTransportEndpointDiagnostics {
78
+ return {
79
+ endpoint,
80
+ transport: getTelegramBusTransportKind(endpoint),
81
+ };
82
+ }
83
+
84
+ export function getTelegramBusTransportRetryPolicy(input: {
85
+ endpoint: string;
86
+ operation: TelegramBusTransportOperation;
87
+ overrides?: TelegramBusTransportRetryPolicyOverrides;
88
+ }): TelegramBusTransportRetryPolicy | undefined {
89
+ const base =
90
+ input.operation === "registration"
91
+ ? TELEGRAM_BUS_REGISTRATION_RETRY
92
+ : isTelegramBusPipePath(input.endpoint)
93
+ ? TELEGRAM_BUS_OPERATION_RETRY
94
+ : undefined;
95
+ if (!base && !input.overrides) return undefined;
96
+ return {
97
+ attempts: Math.max(1, input.overrides?.attempts ?? base?.attempts ?? 1),
98
+ delayMs: Math.max(0, input.overrides?.delayMs ?? base?.delayMs ?? 0),
99
+ };
100
+ }
101
+
102
+ export function getTelegramBusLeaderEndpoint(input: {
103
+ agentDir: string;
104
+ platform: NodeJS.Platform | string;
105
+ }): string {
106
+ return input.platform === "win32"
107
+ ? getTelegramBusPipePath({ agentDir: input.agentDir, scope: "bus" })
108
+ : join(input.agentDir, "tmp", "telegram", "bus.sock");
109
+ }
110
+
111
+ export function getTelegramBusFollowerEndpoint(input: {
112
+ agentDir: string;
113
+ platform: NodeJS.Platform | string;
114
+ instanceId: string;
115
+ }): string {
116
+ return input.platform === "win32"
117
+ ? getTelegramBusPipePath({
118
+ agentDir: input.agentDir,
119
+ scope: `follower-${input.instanceId}`,
120
+ })
121
+ : join(
122
+ input.agentDir,
123
+ "tmp",
124
+ "telegram",
125
+ "followers",
126
+ `${input.instanceId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.sock`,
127
+ );
128
+ }
129
+
130
+ export interface TelegramBusTransportErrorInfo {
131
+ message: string;
132
+ code?: string;
133
+ syscall?: string;
134
+ kind: "connect" | "timeout" | "auth" | "protocol" | "unknown";
135
+ retryable: boolean;
136
+ }
137
+
138
+ export function classifyTelegramBusTransportError(
139
+ error: unknown,
140
+ ): TelegramBusTransportErrorInfo {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ const maybeNodeError = error as NodeJS.ErrnoException;
143
+ const code =
144
+ typeof maybeNodeError?.code === "string" ? maybeNodeError.code : undefined;
145
+ const syscall =
146
+ typeof maybeNodeError?.syscall === "string"
147
+ ? maybeNodeError.syscall
148
+ : undefined;
149
+ const timeout = code === "ETIMEDOUT" || /timed out/i.test(message);
150
+ const retryable =
151
+ code === "ENOENT" ||
152
+ code === "ECONNREFUSED" ||
153
+ code === "EPIPE" ||
154
+ code === "ECONNRESET" ||
155
+ code === "EBUSY" ||
156
+ timeout;
157
+ const kind = timeout
158
+ ? "timeout"
159
+ : code === "ENOENT" || code === "ECONNREFUSED"
160
+ ? "connect"
161
+ : "unknown";
162
+ return { message, code, syscall, kind, retryable };
163
+ }
164
+
165
+ export function isRetryableTelegramBusTransportError(error: unknown): boolean {
166
+ return classifyTelegramBusTransportError(error).retryable;
167
+ }
168
+
169
+ export function createTelegramBusTransportTimeoutError(
170
+ message: string,
171
+ ): NodeJS.ErrnoException {
172
+ const error = new Error(message) as NodeJS.ErrnoException;
173
+ error.code = "ETIMEDOUT";
174
+ return error;
175
+ }
176
+
177
+ export function delayTelegramBusTransportRetry(ms: number): Promise<void> {
178
+ return new Promise((resolve) => {
179
+ const timer = setTimeout(resolve, ms);
180
+ timer.unref?.();
181
+ });
182
+ }
183
+
184
+ export interface TelegramBusTransportProbeResult
185
+ extends TelegramBusTransportEndpointDiagnostics {
186
+ reachable: boolean;
187
+ error?: TelegramBusTransportErrorInfo;
188
+ }
189
+
190
+ export function probeTelegramBusEndpoint(input: {
191
+ endpoint: string;
192
+ timeoutMs?: number;
193
+ }): Promise<TelegramBusTransportProbeResult> {
194
+ const timeoutMs = input.timeoutMs ?? 250;
195
+ const diagnostics = getTelegramBusEndpointDiagnostics(input.endpoint);
196
+ return new Promise((resolve) => {
197
+ const socket = createConnection(input.endpoint);
198
+ let settled = false;
199
+ const settle = (result: TelegramBusTransportProbeResult): void => {
200
+ if (settled) return;
201
+ settled = true;
202
+ clearTimeout(timeout);
203
+ socket.destroy();
204
+ resolve(result);
205
+ };
206
+ const timeout = setTimeout(() => {
207
+ settle({
208
+ ...diagnostics,
209
+ reachable: false,
210
+ error: classifyTelegramBusTransportError(
211
+ createTelegramBusTransportTimeoutError(
212
+ "Timed out probing Telegram bus endpoint",
213
+ ),
214
+ ),
215
+ });
216
+ }, timeoutMs);
217
+ timeout.unref?.();
218
+ socket.once("connect", () => settle({ ...diagnostics, reachable: true }));
219
+ socket.once("error", (error) =>
220
+ settle({
221
+ ...diagnostics,
222
+ reachable: false,
223
+ error: classifyTelegramBusTransportError(error),
224
+ }),
225
+ );
226
+ });
227
+ }
package/lib/bus.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * cross-instance forwarding helpers, and the live follower registry model.
6
6
  */
7
7
 
8
- import { createHash, randomBytes } from "node:crypto";
8
+ import { randomBytes } from "node:crypto";
9
9
  import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
10
10
  import {
11
11
  createConnection,
@@ -16,6 +16,19 @@ import {
16
16
  import { homedir, platform as getPlatform } from "node:os";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
 
19
+ import {
20
+ classifyTelegramBusTransportError,
21
+ createTelegramBusTransportTimeoutError,
22
+ delayTelegramBusTransportRetry,
23
+ getTelegramBusEndpointDiagnostics,
24
+ getTelegramBusFollowerEndpoint,
25
+ getTelegramBusLeaderEndpoint,
26
+ getTelegramBusTransportRetryPolicy,
27
+ isTelegramBusPipePath,
28
+ isRetryableTelegramBusTransportError,
29
+ type TelegramBusTransportEventRecorder,
30
+ type TelegramBusTransportRetryPolicy,
31
+ } from "./bus-transport.ts";
19
32
  import type { TelegramTarget } from "./target.ts";
20
33
 
21
34
  export type TelegramBusRole = "leader" | "follower";
@@ -30,30 +43,11 @@ export function createTelegramBusAuthSecret(): string {
30
43
  return randomBytes(32).toString("base64url");
31
44
  }
32
45
 
33
- function getTelegramBusPipePath(input: {
34
- agentDir: string;
35
- scope: string;
36
- }): string {
37
- const digest = createHash("sha256")
38
- .update(resolve(input.agentDir))
39
- .digest("base64url")
40
- .slice(0, 16);
41
- const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
42
- return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
43
- }
44
-
45
- function isWindowsPipePath(socketPath: string): boolean {
46
- return /^\\\\[.?]\\pipe\\/i.test(socketPath);
47
- }
48
-
49
46
  export function getTelegramBusSocketPath(
50
47
  agentDir = getAgentDir(),
51
48
  platform = getPlatform(),
52
49
  ): string {
53
- if (platform === "win32") {
54
- return getTelegramBusPipePath({ agentDir, scope: "bus" });
55
- }
56
- return join(agentDir, "tmp", "telegram", "bus.sock");
50
+ return getTelegramBusLeaderEndpoint({ agentDir, platform });
57
51
  }
58
52
 
59
53
  export function getTelegramBusFollowerSocketPath(
@@ -61,19 +55,7 @@ export function getTelegramBusFollowerSocketPath(
61
55
  agentDir = getAgentDir(),
62
56
  platform = getPlatform(),
63
57
  ): string {
64
- 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
- );
58
+ return getTelegramBusFollowerEndpoint({ agentDir, platform, instanceId });
77
59
  }
78
60
 
79
61
  export interface TelegramBusInstanceRegistration {
@@ -91,6 +73,41 @@ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration
91
73
  lastHeartbeatMs: number;
92
74
  }
93
75
 
76
+ export function getTelegramFollowerTargetOwnership(input: {
77
+ target: TelegramTarget;
78
+ followers: TelegramBusFollowerView[];
79
+ activeThreadRecords?: Array<{
80
+ status?: string;
81
+ instanceId?: string;
82
+ profileKey?: string;
83
+ owner?: { kind?: string };
84
+ target: TelegramTarget;
85
+ }>;
86
+ currentInstanceId?: string;
87
+ }): { instanceId: string } | undefined {
88
+ const liveFollower = input.followers.find((follower) => {
89
+ return (
90
+ follower.target?.chatId === input.target.chatId &&
91
+ follower.target.threadId === input.target.threadId
92
+ );
93
+ });
94
+ if (liveFollower) return { instanceId: liveFollower.instanceId };
95
+ const record = input.activeThreadRecords?.find((candidate) => {
96
+ const isFollowerRecord = candidate.owner?.kind
97
+ ? candidate.owner.kind === "manual-follower"
98
+ : candidate.profileKey?.startsWith("manual:") === true;
99
+ return (
100
+ isFollowerRecord &&
101
+ candidate.status === "active" &&
102
+ candidate.instanceId &&
103
+ candidate.instanceId !== input.currentInstanceId &&
104
+ candidate.target.chatId === input.target.chatId &&
105
+ candidate.target.threadId === input.target.threadId
106
+ );
107
+ });
108
+ return record?.instanceId ? { instanceId: record.instanceId } : undefined;
109
+ }
110
+
94
111
  export function isTelegramFollowerApiCallAllowed(input: {
95
112
  follower: TelegramBusFollowerView;
96
113
  method: string;
@@ -133,6 +150,28 @@ export function isTelegramFollowerApiCallAllowed(input: {
133
150
  const record = body as Record<string, unknown>;
134
151
  return matchesId(record.chat_id, target.chatId);
135
152
  };
153
+ const isTargetMessageScoped = (body: unknown): boolean => {
154
+ if (!isTargetChatScoped(body)) return false;
155
+ const messageId = (body as Record<string, unknown>).message_id;
156
+ const parsedMessageId =
157
+ typeof messageId === "number" ? messageId : Number(messageId);
158
+ return Number.isInteger(parsedMessageId) && matchesId(messageId, parsedMessageId);
159
+ };
160
+ const isBotCommandRegistration = (body: unknown): boolean => {
161
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
162
+ const commands = (body as Record<string, unknown>).commands;
163
+ return (
164
+ Array.isArray(commands) &&
165
+ commands.every(
166
+ (command) =>
167
+ command &&
168
+ typeof command === "object" &&
169
+ !Array.isArray(command) &&
170
+ typeof (command as Record<string, unknown>).command === "string" &&
171
+ typeof (command as Record<string, unknown>).description === "string",
172
+ )
173
+ );
174
+ };
136
175
  if (input.method === "downloadFile") return true;
137
176
  if (input.method === "call") {
138
177
  const apiMethod = input.args[0];
@@ -144,7 +183,12 @@ export function isTelegramFollowerApiCallAllowed(input: {
144
183
  return true;
145
184
  }
146
185
  if (apiMethod === "getMe") return true;
186
+ if (apiMethod === "setMyCommands")
187
+ return isBotCommandRegistration(input.args[1]);
147
188
  if (apiMethod === "sendChatAction") return isTargetChatScoped(input.args[1]);
189
+ if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
190
+ return isTargetMessageScoped(input.args[1]);
191
+ }
148
192
  return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
149
193
  }
150
194
  if (input.method === "callMultipart") {
@@ -310,12 +354,15 @@ export interface TelegramBusLocalServerDeps {
310
354
  | Promise<TelegramBusEnvelope | undefined>
311
355
  | TelegramBusEnvelope
312
356
  | undefined;
357
+ recordTransportEvent?: TelegramBusTransportEventRecorder;
313
358
  }
314
359
 
315
360
  export interface TelegramBusLocalClientOptions {
316
361
  socketPath: string;
317
362
  envelope: TelegramBusEnvelope;
318
363
  timeoutMs?: number;
364
+ retry?: TelegramBusTransportRetryPolicy;
365
+ recordTransportEvent?: TelegramBusTransportEventRecorder;
319
366
  }
320
367
 
321
368
  export interface TelegramBusForeignOwnedForwarderDeps {
@@ -362,6 +409,10 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
362
409
  socketPath: deps.socketPath,
363
410
  envelope,
364
411
  timeoutMs: deps.timeoutMs,
412
+ retry: getTelegramBusTransportRetryPolicy({
413
+ endpoint: deps.socketPath,
414
+ operation: "operation",
415
+ }),
365
416
  });
366
417
  return response?.kind === "bus.ack" && response.ok;
367
418
  };
@@ -449,6 +500,10 @@ export function createTelegramBusFollowerTargetController(
449
500
  socketPath: follower.busSocketPath,
450
501
  envelope,
451
502
  timeoutMs: deps.timeoutMs,
503
+ retry: getTelegramBusTransportRetryPolicy({
504
+ endpoint: follower.busSocketPath,
505
+ operation: "operation",
506
+ }),
452
507
  });
453
508
  return response?.kind === "bus.ack" && response.ok;
454
509
  },
@@ -517,7 +572,11 @@ export function createTelegramBusLocalServer(
517
572
  return {
518
573
  start: async () => {
519
574
  if (server) return;
520
- const usesWindowsPipe = isWindowsPipePath(deps.socketPath);
575
+ const usesWindowsPipe = isTelegramBusPipePath(deps.socketPath);
576
+ deps.recordTransportEvent?.(
577
+ "server-start",
578
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
579
+ );
521
580
  if (!usesWindowsPipe) {
522
581
  const socketDir = dirname(deps.socketPath);
523
582
  mkdirSync(socketDir, { recursive: true, mode: 0o700 });
@@ -533,16 +592,40 @@ export function createTelegramBusLocalServer(
533
592
  const lines = buffer.split("\n");
534
593
  buffer = lines.pop() ?? "";
535
594
  for (const line of lines) {
536
- void handleTelegramBusSocketLine(line, socket, deps.handleEnvelope);
595
+ void handleTelegramBusSocketLine(
596
+ line,
597
+ socket,
598
+ deps.handleEnvelope,
599
+ deps.recordTransportEvent,
600
+ deps.socketPath,
601
+ );
537
602
  }
538
603
  });
539
604
  socket.on("close", () => sockets.delete(socket));
540
- socket.on("error", () => closeSocket(socket));
541
- });
542
- await new Promise<void>((resolve, reject) => {
543
- server?.once("error", reject);
544
- server?.listen(deps.socketPath, resolve);
605
+ socket.on("error", (error) => {
606
+ deps.recordTransportEvent?.("server-socket-error", {
607
+ ...getTelegramBusEndpointDiagnostics(deps.socketPath),
608
+ ...classifyTelegramBusTransportError(error),
609
+ });
610
+ closeSocket(socket);
611
+ });
545
612
  });
613
+ try {
614
+ await new Promise<void>((resolve, reject) => {
615
+ server?.once("error", reject);
616
+ server?.listen(deps.socketPath, resolve);
617
+ });
618
+ deps.recordTransportEvent?.(
619
+ "server-started",
620
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
621
+ );
622
+ } catch (error) {
623
+ deps.recordTransportEvent?.("server-start-failed", {
624
+ ...getTelegramBusEndpointDiagnostics(deps.socketPath),
625
+ ...classifyTelegramBusTransportError(error),
626
+ });
627
+ throw error;
628
+ }
546
629
  if (!usesWindowsPipe) chmodSync(deps.socketPath, 0o600);
547
630
  },
548
631
  stop: async () => {
@@ -554,14 +637,27 @@ export function createTelegramBusLocalServer(
554
637
  activeServer.close(() => resolve()),
555
638
  );
556
639
  }
557
- if (!isWindowsPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
640
+ if (!isTelegramBusPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
558
641
  unlinkSync(deps.socketPath);
559
642
  }
643
+ deps.recordTransportEvent?.(
644
+ "server-stopped",
645
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
646
+ );
560
647
  },
561
648
  };
562
649
  }
563
650
 
564
- export function sendTelegramBusLocalEnvelope(
651
+ function getTelegramBusEnvelopeDiagnostics(
652
+ envelope: TelegramBusEnvelope,
653
+ ): Record<string, unknown> {
654
+ return {
655
+ envelopeKind: envelope.kind,
656
+ requestId: envelope.requestId,
657
+ };
658
+ }
659
+
660
+ function sendTelegramBusLocalEnvelopeOnce(
565
661
  options: TelegramBusLocalClientOptions,
566
662
  ): Promise<TelegramBusEnvelope | undefined> {
567
663
  const timeoutMs = options.timeoutMs ?? 1000;
@@ -578,7 +674,11 @@ export function sendTelegramBusLocalEnvelope(
578
674
  };
579
675
  const timeout = setTimeout(() => {
580
676
  settle(() =>
581
- reject(new Error("Timed out waiting for Telegram bus response")),
677
+ reject(
678
+ createTelegramBusTransportTimeoutError(
679
+ "Timed out waiting for Telegram bus response",
680
+ ),
681
+ ),
582
682
  );
583
683
  }, timeoutMs);
584
684
  timeout.unref?.();
@@ -598,6 +698,39 @@ export function sendTelegramBusLocalEnvelope(
598
698
  });
599
699
  }
600
700
 
701
+ export async function sendTelegramBusLocalEnvelope(
702
+ options: TelegramBusLocalClientOptions,
703
+ ): Promise<TelegramBusEnvelope | undefined> {
704
+ const attempts = Math.max(1, options.retry?.attempts ?? 1);
705
+ const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
706
+ for (let attempt = 1; ; attempt += 1) {
707
+ try {
708
+ return await sendTelegramBusLocalEnvelopeOnce(options);
709
+ } catch (error) {
710
+ const info = classifyTelegramBusTransportError(error);
711
+ options.recordTransportEvent?.("client-failed", {
712
+ ...getTelegramBusEndpointDiagnostics(options.socketPath),
713
+ ...getTelegramBusEnvelopeDiagnostics(options.envelope),
714
+ attempt,
715
+ attempts,
716
+ ...info,
717
+ });
718
+ if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
719
+ throw error;
720
+ }
721
+ options.recordTransportEvent?.("client-retry", {
722
+ ...getTelegramBusEndpointDiagnostics(options.socketPath),
723
+ ...getTelegramBusEnvelopeDiagnostics(options.envelope),
724
+ attempt,
725
+ attempts,
726
+ delayMs,
727
+ ...info,
728
+ });
729
+ await delayTelegramBusTransportRetry(delayMs);
730
+ }
731
+ }
732
+ }
733
+
601
734
  export interface TelegramBusFollowerRegistry {
602
735
  register: (
603
736
  registration: TelegramBusInstanceRegistration,
@@ -610,6 +743,7 @@ export interface TelegramBusFollowerRegistry {
610
743
  getByTarget: (target: TelegramTarget) => TelegramBusFollowerView | undefined;
611
744
  list: () => TelegramBusFollowerView[];
612
745
  remove: (instanceId: string) => boolean;
746
+ clear: () => void;
613
747
  pruneStale: (
614
748
  nowMs: number,
615
749
  staleAfterMs: number,
@@ -627,6 +761,17 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
627
761
  return {
628
762
  register: (registration) => {
629
763
  const existing = followers.get(registration.instanceId);
764
+ for (const [instanceId, follower] of followers.entries()) {
765
+ if (instanceId === registration.instanceId) continue;
766
+ const sameProfile =
767
+ registration.profileKey !== undefined &&
768
+ registration.profileKey === follower.profileKey;
769
+ const sameTarget =
770
+ registration.target !== undefined &&
771
+ follower.target?.chatId === registration.target.chatId &&
772
+ follower.target.threadId === registration.target.threadId;
773
+ if (sameProfile || sameTarget) followers.delete(instanceId);
774
+ }
630
775
  const next: TelegramBusFollowerView = {
631
776
  ...registration,
632
777
  target: registration.target ? { ...registration.target } : undefined,
@@ -660,6 +805,7 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
660
805
  },
661
806
  list: () => [...followers.values()].map(clone),
662
807
  remove: (instanceId) => followers.delete(instanceId),
808
+ clear: () => followers.clear(),
663
809
  pruneStale: (nowMs, staleAfterMs) => {
664
810
  const removed: TelegramBusFollowerView[] = [];
665
811
  for (const [instanceId, follower] of followers.entries()) {
@@ -676,9 +822,15 @@ async function handleTelegramBusSocketLine(
676
822
  line: string,
677
823
  socket: Socket,
678
824
  handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
825
+ recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
826
+ socketPath: string,
679
827
  ): Promise<void> {
680
828
  const envelope = parseTelegramBusEnvelope(line);
681
829
  if (!envelope) {
830
+ recordTransportEvent?.("server-invalid-envelope", {
831
+ ...getTelegramBusEndpointDiagnostics(socketPath),
832
+ byteLength: Buffer.byteLength(line),
833
+ });
682
834
  socket.write(
683
835
  encodeTelegramBusEnvelope({
684
836
  kind: "bus.ack",
@@ -689,8 +841,24 @@ async function handleTelegramBusSocketLine(
689
841
  );
690
842
  return;
691
843
  }
692
- const response = await handleEnvelope(envelope);
693
- if (response) socket.write(encodeTelegramBusEnvelope(response));
844
+ try {
845
+ const response = await handleEnvelope(envelope);
846
+ if (response) socket.write(encodeTelegramBusEnvelope(response));
847
+ } catch (error) {
848
+ recordTransportEvent?.("server-handler-failed", {
849
+ ...getTelegramBusEndpointDiagnostics(socketPath),
850
+ ...getTelegramBusEnvelopeDiagnostics(envelope),
851
+ ...classifyTelegramBusTransportError(error),
852
+ });
853
+ socket.write(
854
+ encodeTelegramBusEnvelope({
855
+ kind: "bus.ack",
856
+ requestId: envelope.requestId,
857
+ ok: false,
858
+ message: "Telegram bus handler failed.",
859
+ }),
860
+ );
861
+ }
694
862
  }
695
863
 
696
864
  function parseRegisterEnvelope(