@llblab/pi-telegram 0.18.3 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/BACKLOG.md +6 -19
- package/CHANGELOG.md +14 -2
- package/README.md +1 -1
- package/docs/multi-instance-bus.md +3 -3
- package/index.ts +30 -1
- package/lib/bus-follower.ts +64 -16
- package/lib/bus-leader.ts +19 -20
- package/lib/bus-transport.ts +227 -0
- package/lib/bus.ts +170 -46
- package/lib/polling.ts +58 -5
- package/lib/preview.ts +20 -39
- package/lib/queue.ts +70 -0
- package/lib/runtime-log.ts +19 -1
- package/lib/status.ts +50 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,35 @@ 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
|
+
target: TelegramTarget;
|
|
83
|
+
}>;
|
|
84
|
+
currentInstanceId?: string;
|
|
85
|
+
}): { instanceId: string } | undefined {
|
|
86
|
+
const liveFollower = input.followers.find((follower) => {
|
|
87
|
+
return (
|
|
88
|
+
follower.target?.chatId === input.target.chatId &&
|
|
89
|
+
follower.target.threadId === input.target.threadId
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
if (liveFollower) return { instanceId: liveFollower.instanceId };
|
|
93
|
+
const record = input.activeThreadRecords?.find((candidate) => {
|
|
94
|
+
return (
|
|
95
|
+
candidate.status === "active" &&
|
|
96
|
+
candidate.instanceId &&
|
|
97
|
+
candidate.instanceId !== input.currentInstanceId &&
|
|
98
|
+
candidate.target.chatId === input.target.chatId &&
|
|
99
|
+
candidate.target.threadId === input.target.threadId
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
return record?.instanceId ? { instanceId: record.instanceId } : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export function isTelegramFollowerApiCallAllowed(input: {
|
|
95
106
|
follower: TelegramBusFollowerView;
|
|
96
107
|
method: string;
|
|
@@ -310,12 +321,15 @@ export interface TelegramBusLocalServerDeps {
|
|
|
310
321
|
| Promise<TelegramBusEnvelope | undefined>
|
|
311
322
|
| TelegramBusEnvelope
|
|
312
323
|
| undefined;
|
|
324
|
+
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
313
325
|
}
|
|
314
326
|
|
|
315
327
|
export interface TelegramBusLocalClientOptions {
|
|
316
328
|
socketPath: string;
|
|
317
329
|
envelope: TelegramBusEnvelope;
|
|
318
330
|
timeoutMs?: number;
|
|
331
|
+
retry?: TelegramBusTransportRetryPolicy;
|
|
332
|
+
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
export interface TelegramBusForeignOwnedForwarderDeps {
|
|
@@ -362,6 +376,10 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
362
376
|
socketPath: deps.socketPath,
|
|
363
377
|
envelope,
|
|
364
378
|
timeoutMs: deps.timeoutMs,
|
|
379
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
380
|
+
endpoint: deps.socketPath,
|
|
381
|
+
operation: "operation",
|
|
382
|
+
}),
|
|
365
383
|
});
|
|
366
384
|
return response?.kind === "bus.ack" && response.ok;
|
|
367
385
|
};
|
|
@@ -449,6 +467,10 @@ export function createTelegramBusFollowerTargetController(
|
|
|
449
467
|
socketPath: follower.busSocketPath,
|
|
450
468
|
envelope,
|
|
451
469
|
timeoutMs: deps.timeoutMs,
|
|
470
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
471
|
+
endpoint: follower.busSocketPath,
|
|
472
|
+
operation: "operation",
|
|
473
|
+
}),
|
|
452
474
|
});
|
|
453
475
|
return response?.kind === "bus.ack" && response.ok;
|
|
454
476
|
},
|
|
@@ -517,7 +539,11 @@ export function createTelegramBusLocalServer(
|
|
|
517
539
|
return {
|
|
518
540
|
start: async () => {
|
|
519
541
|
if (server) return;
|
|
520
|
-
const usesWindowsPipe =
|
|
542
|
+
const usesWindowsPipe = isTelegramBusPipePath(deps.socketPath);
|
|
543
|
+
deps.recordTransportEvent?.(
|
|
544
|
+
"server-start",
|
|
545
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
546
|
+
);
|
|
521
547
|
if (!usesWindowsPipe) {
|
|
522
548
|
const socketDir = dirname(deps.socketPath);
|
|
523
549
|
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
@@ -533,16 +559,40 @@ export function createTelegramBusLocalServer(
|
|
|
533
559
|
const lines = buffer.split("\n");
|
|
534
560
|
buffer = lines.pop() ?? "";
|
|
535
561
|
for (const line of lines) {
|
|
536
|
-
void handleTelegramBusSocketLine(
|
|
562
|
+
void handleTelegramBusSocketLine(
|
|
563
|
+
line,
|
|
564
|
+
socket,
|
|
565
|
+
deps.handleEnvelope,
|
|
566
|
+
deps.recordTransportEvent,
|
|
567
|
+
deps.socketPath,
|
|
568
|
+
);
|
|
537
569
|
}
|
|
538
570
|
});
|
|
539
571
|
socket.on("close", () => sockets.delete(socket));
|
|
540
|
-
socket.on("error", () =>
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
572
|
+
socket.on("error", (error) => {
|
|
573
|
+
deps.recordTransportEvent?.("server-socket-error", {
|
|
574
|
+
...getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
575
|
+
...classifyTelegramBusTransportError(error),
|
|
576
|
+
});
|
|
577
|
+
closeSocket(socket);
|
|
578
|
+
});
|
|
545
579
|
});
|
|
580
|
+
try {
|
|
581
|
+
await new Promise<void>((resolve, reject) => {
|
|
582
|
+
server?.once("error", reject);
|
|
583
|
+
server?.listen(deps.socketPath, resolve);
|
|
584
|
+
});
|
|
585
|
+
deps.recordTransportEvent?.(
|
|
586
|
+
"server-started",
|
|
587
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
588
|
+
);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
deps.recordTransportEvent?.("server-start-failed", {
|
|
591
|
+
...getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
592
|
+
...classifyTelegramBusTransportError(error),
|
|
593
|
+
});
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
546
596
|
if (!usesWindowsPipe) chmodSync(deps.socketPath, 0o600);
|
|
547
597
|
},
|
|
548
598
|
stop: async () => {
|
|
@@ -554,14 +604,27 @@ export function createTelegramBusLocalServer(
|
|
|
554
604
|
activeServer.close(() => resolve()),
|
|
555
605
|
);
|
|
556
606
|
}
|
|
557
|
-
if (!
|
|
607
|
+
if (!isTelegramBusPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
|
|
558
608
|
unlinkSync(deps.socketPath);
|
|
559
609
|
}
|
|
610
|
+
deps.recordTransportEvent?.(
|
|
611
|
+
"server-stopped",
|
|
612
|
+
getTelegramBusEndpointDiagnostics(deps.socketPath),
|
|
613
|
+
);
|
|
560
614
|
},
|
|
561
615
|
};
|
|
562
616
|
}
|
|
563
617
|
|
|
564
|
-
|
|
618
|
+
function getTelegramBusEnvelopeDiagnostics(
|
|
619
|
+
envelope: TelegramBusEnvelope,
|
|
620
|
+
): Record<string, unknown> {
|
|
621
|
+
return {
|
|
622
|
+
envelopeKind: envelope.kind,
|
|
623
|
+
requestId: envelope.requestId,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function sendTelegramBusLocalEnvelopeOnce(
|
|
565
628
|
options: TelegramBusLocalClientOptions,
|
|
566
629
|
): Promise<TelegramBusEnvelope | undefined> {
|
|
567
630
|
const timeoutMs = options.timeoutMs ?? 1000;
|
|
@@ -578,7 +641,11 @@ export function sendTelegramBusLocalEnvelope(
|
|
|
578
641
|
};
|
|
579
642
|
const timeout = setTimeout(() => {
|
|
580
643
|
settle(() =>
|
|
581
|
-
reject(
|
|
644
|
+
reject(
|
|
645
|
+
createTelegramBusTransportTimeoutError(
|
|
646
|
+
"Timed out waiting for Telegram bus response",
|
|
647
|
+
),
|
|
648
|
+
),
|
|
582
649
|
);
|
|
583
650
|
}, timeoutMs);
|
|
584
651
|
timeout.unref?.();
|
|
@@ -598,6 +665,39 @@ export function sendTelegramBusLocalEnvelope(
|
|
|
598
665
|
});
|
|
599
666
|
}
|
|
600
667
|
|
|
668
|
+
export async function sendTelegramBusLocalEnvelope(
|
|
669
|
+
options: TelegramBusLocalClientOptions,
|
|
670
|
+
): Promise<TelegramBusEnvelope | undefined> {
|
|
671
|
+
const attempts = Math.max(1, options.retry?.attempts ?? 1);
|
|
672
|
+
const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
|
|
673
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
674
|
+
try {
|
|
675
|
+
return await sendTelegramBusLocalEnvelopeOnce(options);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const info = classifyTelegramBusTransportError(error);
|
|
678
|
+
options.recordTransportEvent?.("client-failed", {
|
|
679
|
+
...getTelegramBusEndpointDiagnostics(options.socketPath),
|
|
680
|
+
...getTelegramBusEnvelopeDiagnostics(options.envelope),
|
|
681
|
+
attempt,
|
|
682
|
+
attempts,
|
|
683
|
+
...info,
|
|
684
|
+
});
|
|
685
|
+
if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
options.recordTransportEvent?.("client-retry", {
|
|
689
|
+
...getTelegramBusEndpointDiagnostics(options.socketPath),
|
|
690
|
+
...getTelegramBusEnvelopeDiagnostics(options.envelope),
|
|
691
|
+
attempt,
|
|
692
|
+
attempts,
|
|
693
|
+
delayMs,
|
|
694
|
+
...info,
|
|
695
|
+
});
|
|
696
|
+
await delayTelegramBusTransportRetry(delayMs);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
601
701
|
export interface TelegramBusFollowerRegistry {
|
|
602
702
|
register: (
|
|
603
703
|
registration: TelegramBusInstanceRegistration,
|
|
@@ -610,6 +710,7 @@ export interface TelegramBusFollowerRegistry {
|
|
|
610
710
|
getByTarget: (target: TelegramTarget) => TelegramBusFollowerView | undefined;
|
|
611
711
|
list: () => TelegramBusFollowerView[];
|
|
612
712
|
remove: (instanceId: string) => boolean;
|
|
713
|
+
clear: () => void;
|
|
613
714
|
pruneStale: (
|
|
614
715
|
nowMs: number,
|
|
615
716
|
staleAfterMs: number,
|
|
@@ -660,6 +761,7 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
|
|
|
660
761
|
},
|
|
661
762
|
list: () => [...followers.values()].map(clone),
|
|
662
763
|
remove: (instanceId) => followers.delete(instanceId),
|
|
764
|
+
clear: () => followers.clear(),
|
|
663
765
|
pruneStale: (nowMs, staleAfterMs) => {
|
|
664
766
|
const removed: TelegramBusFollowerView[] = [];
|
|
665
767
|
for (const [instanceId, follower] of followers.entries()) {
|
|
@@ -676,9 +778,15 @@ async function handleTelegramBusSocketLine(
|
|
|
676
778
|
line: string,
|
|
677
779
|
socket: Socket,
|
|
678
780
|
handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
|
|
781
|
+
recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
|
|
782
|
+
socketPath: string,
|
|
679
783
|
): Promise<void> {
|
|
680
784
|
const envelope = parseTelegramBusEnvelope(line);
|
|
681
785
|
if (!envelope) {
|
|
786
|
+
recordTransportEvent?.("server-invalid-envelope", {
|
|
787
|
+
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
788
|
+
byteLength: Buffer.byteLength(line),
|
|
789
|
+
});
|
|
682
790
|
socket.write(
|
|
683
791
|
encodeTelegramBusEnvelope({
|
|
684
792
|
kind: "bus.ack",
|
|
@@ -689,8 +797,24 @@ async function handleTelegramBusSocketLine(
|
|
|
689
797
|
);
|
|
690
798
|
return;
|
|
691
799
|
}
|
|
692
|
-
|
|
693
|
-
|
|
800
|
+
try {
|
|
801
|
+
const response = await handleEnvelope(envelope);
|
|
802
|
+
if (response) socket.write(encodeTelegramBusEnvelope(response));
|
|
803
|
+
} catch (error) {
|
|
804
|
+
recordTransportEvent?.("server-handler-failed", {
|
|
805
|
+
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
806
|
+
...getTelegramBusEnvelopeDiagnostics(envelope),
|
|
807
|
+
...classifyTelegramBusTransportError(error),
|
|
808
|
+
});
|
|
809
|
+
socket.write(
|
|
810
|
+
encodeTelegramBusEnvelope({
|
|
811
|
+
kind: "bus.ack",
|
|
812
|
+
requestId: envelope.requestId,
|
|
813
|
+
ok: false,
|
|
814
|
+
message: "Telegram bus handler failed.",
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
694
818
|
}
|
|
695
819
|
|
|
696
820
|
function parseRegisterEnvelope(
|
package/lib/polling.ts
CHANGED
|
@@ -20,7 +20,7 @@ const TELEGRAM_INITIAL_SYNC_LIMIT = 1;
|
|
|
20
20
|
const TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS = 0;
|
|
21
21
|
const TELEGRAM_LONG_POLL_LIMIT = 10;
|
|
22
22
|
const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
|
|
23
|
-
const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS =
|
|
23
|
+
const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
|
|
24
24
|
const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
|
|
25
25
|
const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
|
|
26
26
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
@@ -426,6 +426,12 @@ export async function probeTelegramStartupThreadCapability(
|
|
|
426
426
|
return threadModeEnabled;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
function hasTelegramClassicRestoreFailure(
|
|
430
|
+
state: TelegramThreadCapabilityState,
|
|
431
|
+
): boolean {
|
|
432
|
+
return state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false;
|
|
433
|
+
}
|
|
434
|
+
|
|
429
435
|
function hasTelegramThreadCapabilityBindings(
|
|
430
436
|
store: TelegramThreadCapabilityStore,
|
|
431
437
|
): boolean {
|
|
@@ -451,6 +457,7 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
451
457
|
await deps.topicTargetStore.load();
|
|
452
458
|
if (!deps.isBusConfigured()) return;
|
|
453
459
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
460
|
+
const previousBotState = deps.topicTargetStore.getBotState();
|
|
454
461
|
if (!threadModeEnabled) {
|
|
455
462
|
if (
|
|
456
463
|
hasTelegramThreadCapabilityBindings(deps.topicTargetStore) &&
|
|
@@ -470,11 +477,26 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
470
477
|
await deps.topicTargetStore.persist();
|
|
471
478
|
deps.setTopicModeUnavailable(true);
|
|
472
479
|
deps.stopFollowerRegistration();
|
|
473
|
-
if (
|
|
480
|
+
if (
|
|
481
|
+
deps.getPollingStartedWithTelegramBus() ||
|
|
482
|
+
hasTelegramClassicRestoreFailure(previousBotState)
|
|
483
|
+
) {
|
|
474
484
|
deps.stopLeaderHealth();
|
|
475
485
|
await deps.stopBusPolling();
|
|
476
486
|
deps.setPollingStartedWithTelegramBus(false);
|
|
477
|
-
|
|
487
|
+
try {
|
|
488
|
+
await deps.startClassicPolling(ctx);
|
|
489
|
+
} catch (classicError) {
|
|
490
|
+
deps.topicTargetStore.setBotState({
|
|
491
|
+
threadMode: "disabled",
|
|
492
|
+
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
493
|
+
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
494
|
+
});
|
|
495
|
+
await deps.topicTargetStore.persist();
|
|
496
|
+
deps.recordEvent("bus", classicError, {
|
|
497
|
+
phase: `${phase}-classic-restore`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
478
500
|
}
|
|
479
501
|
deps.updateStatus(ctx);
|
|
480
502
|
return;
|
|
@@ -509,6 +531,12 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
509
531
|
try {
|
|
510
532
|
await deps.startClassicPolling(ctx);
|
|
511
533
|
} catch (classicError) {
|
|
534
|
+
deps.topicTargetStore.setBotState({
|
|
535
|
+
threadMode: "disabled",
|
|
536
|
+
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
537
|
+
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
538
|
+
});
|
|
539
|
+
await deps.topicTargetStore.persist();
|
|
512
540
|
deps.recordEvent("bus", classicError, {
|
|
513
541
|
phase: `${phase}-classic-restore`,
|
|
514
542
|
});
|
|
@@ -583,6 +611,19 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
|
|
|
583
611
|
): Promise<boolean | undefined> => {
|
|
584
612
|
await deps.topicTargetStore.load();
|
|
585
613
|
if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
|
|
614
|
+
if (hasTelegramThreadCapabilityBindings(deps.topicTargetStore)) {
|
|
615
|
+
deps.recordEvent(
|
|
616
|
+
"bus",
|
|
617
|
+
"Telegram Threaded Mode disabled; follower takeover blocked",
|
|
618
|
+
{
|
|
619
|
+
phase: "follower-register-thread-mode-disabled",
|
|
620
|
+
reason: "active-thread-bindings-present",
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
throw new Error(
|
|
624
|
+
"Telegram Threaded Mode is disabled; the current leader remains the classic polling owner.",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
586
627
|
return undefined;
|
|
587
628
|
}
|
|
588
629
|
if (!deps.isBusRuntimeEnabled()) return undefined;
|
|
@@ -653,9 +694,21 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
653
694
|
return;
|
|
654
695
|
}
|
|
655
696
|
if (threadModeEnabled) consecutiveDisabledProbes = 0;
|
|
656
|
-
const
|
|
697
|
+
const botState = deps.topicTargetStore.getBotState();
|
|
698
|
+
const current = botState.threadMode;
|
|
657
699
|
if (threadModeEnabled && current === "enabled") return;
|
|
658
|
-
if (!threadModeEnabled && current === "disabled")
|
|
700
|
+
if (!threadModeEnabled && current === "disabled") {
|
|
701
|
+
if (!deps.ownsLock(ctx) || !hasTelegramClassicRestoreFailure(botState)) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
await applyTelegramThreadCapability(
|
|
705
|
+
ctx,
|
|
706
|
+
false,
|
|
707
|
+
"capability-monitor-disabled-confirmed",
|
|
708
|
+
deps,
|
|
709
|
+
);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
659
712
|
if (
|
|
660
713
|
!threadModeEnabled &&
|
|
661
714
|
hasTelegramThreadCapabilityBindings(deps.topicTargetStore)
|