@llblab/pi-telegram 0.21.1 → 0.22.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.
- package/AGENTS.md +7 -4
- package/BACKLOG.md +29 -1
- package/CHANGELOG.md +479 -452
- package/docs/architecture.md +12 -10
- package/docs/delivery.md +2 -1
- package/docs/locks.md +13 -5
- package/docs/multi-instance-bus.md +5 -5
- package/index.ts +311 -633
- package/lib/bindings.ts +59 -21
- package/lib/bus-api.ts +12 -2
- package/lib/bus-follower.ts +327 -83
- package/lib/bus-leader.ts +201 -121
- package/lib/bus.ts +345 -37
- package/lib/config.ts +168 -28
- package/lib/delivery.ts +148 -61
- package/lib/lifecycle.ts +199 -8
- package/lib/locks.ts +854 -57
- package/lib/logs.ts +185 -22
- package/lib/media.ts +79 -24
- package/lib/model.ts +5 -10
- package/lib/ownership.ts +150 -6
- package/lib/polling.ts +156 -7
- package/lib/preview.ts +15 -3
- package/lib/queue.ts +167 -20
- package/lib/routing.ts +68 -12
- package/lib/sync.ts +105 -32
- package/lib/telegram-api.ts +86 -10
- package/lib/text-groups.ts +71 -15
- package/lib/thread-reconciler.ts +49 -36
- package/lib/threads.ts +505 -118
- package/package.json +1 -1
package/lib/bus.ts
CHANGED
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readlinkSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
symlinkSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
} from "node:fs";
|
|
10
20
|
import {
|
|
11
21
|
createConnection,
|
|
12
22
|
createServer,
|
|
@@ -14,7 +24,7 @@ import {
|
|
|
14
24
|
type Socket,
|
|
15
25
|
} from "node:net";
|
|
16
26
|
import { platform as getPlatform } from "node:os";
|
|
17
|
-
import { dirname } from "node:path";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
18
28
|
|
|
19
29
|
import {
|
|
20
30
|
classifyTelegramBusTransportError,
|
|
@@ -26,6 +36,7 @@ import {
|
|
|
26
36
|
getTelegramBusTransportRetryPolicy,
|
|
27
37
|
isTelegramBusPipePath,
|
|
28
38
|
isRetryableTelegramBusTransportError,
|
|
39
|
+
probeTelegramBusEndpoint,
|
|
29
40
|
type TelegramBusTransportEventRecorder,
|
|
30
41
|
type TelegramBusTransportRetryPolicy,
|
|
31
42
|
} from "./bus-transport.ts";
|
|
@@ -41,14 +52,39 @@ export interface TelegramBusProcessRuntime {
|
|
|
41
52
|
getFollowerSocketPath: () => string;
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
export function getTelegramProcessBirthIdentity(
|
|
56
|
+
pid: number,
|
|
57
|
+
fallbackGeneration: number | string,
|
|
58
|
+
): string {
|
|
59
|
+
if (pid > 0) {
|
|
60
|
+
try {
|
|
61
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
62
|
+
const closeParen = stat.lastIndexOf(")");
|
|
63
|
+
const fields = stat
|
|
64
|
+
.slice(closeParen + 2)
|
|
65
|
+
.trim()
|
|
66
|
+
.split(/\s+/u);
|
|
67
|
+
const startTicks = fields[19];
|
|
68
|
+
if (startTicks) return `${pid}:start:${startTicks}`;
|
|
69
|
+
} catch {
|
|
70
|
+
/* non-Linux or inaccessible process metadata */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return `${pid}:generation:${fallbackGeneration}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
export function createTelegramBusProcessRuntime(input: {
|
|
45
77
|
getActiveProfileName: () => string | undefined;
|
|
46
78
|
pid: number;
|
|
47
79
|
parentPid: number;
|
|
80
|
+
parentProcessIdentity?: string;
|
|
48
81
|
createdAtMs: number;
|
|
49
82
|
}): TelegramBusProcessRuntime {
|
|
50
83
|
const instanceId = `${input.pid}:${input.createdAtMs}`;
|
|
51
|
-
const
|
|
84
|
+
const ownerPid = input.parentPid || input.pid;
|
|
85
|
+
const manualFollowerOwnerId =
|
|
86
|
+
input.parentProcessIdentity ??
|
|
87
|
+
getTelegramProcessBirthIdentity(ownerPid, input.createdAtMs);
|
|
52
88
|
return {
|
|
53
89
|
instanceId,
|
|
54
90
|
manualFollowerOwnerId,
|
|
@@ -103,6 +139,7 @@ export interface TelegramBusInstanceRegistration {
|
|
|
103
139
|
pid?: number;
|
|
104
140
|
target?: TelegramTarget;
|
|
105
141
|
busSocketPath?: string;
|
|
142
|
+
registrationGeneration?: string;
|
|
106
143
|
connectedAtMs: number;
|
|
107
144
|
}
|
|
108
145
|
|
|
@@ -112,14 +149,14 @@ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration
|
|
|
112
149
|
|
|
113
150
|
export function getTelegramFollowerTargetOwnership(input: {
|
|
114
151
|
target: TelegramTarget;
|
|
115
|
-
followers: TelegramBusFollowerView[];
|
|
116
|
-
activeThreadRecords?:
|
|
152
|
+
followers: readonly TelegramBusFollowerView[];
|
|
153
|
+
activeThreadRecords?: readonly {
|
|
117
154
|
status?: string;
|
|
118
155
|
instanceId?: string;
|
|
119
156
|
profileKey?: string;
|
|
120
157
|
owner?: { kind?: string };
|
|
121
158
|
target: TelegramTarget;
|
|
122
|
-
}
|
|
159
|
+
}[];
|
|
123
160
|
currentInstanceId?: string;
|
|
124
161
|
}): { instanceId: string } | undefined {
|
|
125
162
|
const liveFollower = input.followers.find((follower) => {
|
|
@@ -145,12 +182,11 @@ export function getTelegramFollowerTargetOwnership(input: {
|
|
|
145
182
|
return record?.instanceId ? { instanceId: record.instanceId } : undefined;
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD =
|
|
149
|
-
"__piTelegramAggregateDelivery";
|
|
185
|
+
const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
|
|
150
186
|
|
|
151
|
-
export function markTelegramBusAggregateDelivery<
|
|
152
|
-
|
|
153
|
-
): T {
|
|
187
|
+
export function markTelegramBusAggregateDelivery<
|
|
188
|
+
T extends Record<string, unknown>,
|
|
189
|
+
>(body: T): T {
|
|
154
190
|
return {
|
|
155
191
|
...body,
|
|
156
192
|
[TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD]: true,
|
|
@@ -160,11 +196,10 @@ export function markTelegramBusAggregateDelivery<T extends Record<string, unknow
|
|
|
160
196
|
export function isTelegramBusAggregateDelivery(body: unknown): boolean {
|
|
161
197
|
return Boolean(
|
|
162
198
|
body &&
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
] === true,
|
|
199
|
+
typeof body === "object" &&
|
|
200
|
+
!Array.isArray(body) &&
|
|
201
|
+
(body as Record<string, unknown>)[TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD] ===
|
|
202
|
+
true,
|
|
168
203
|
);
|
|
169
204
|
}
|
|
170
205
|
|
|
@@ -181,6 +216,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
181
216
|
follower: TelegramBusFollowerView;
|
|
182
217
|
method: string;
|
|
183
218
|
args: unknown[];
|
|
219
|
+
isMessageOwned?: (chatId: number, messageId: number) => boolean;
|
|
184
220
|
}): boolean {
|
|
185
221
|
const allowedCallMethods = new Set([
|
|
186
222
|
"answerCallbackQuery",
|
|
@@ -259,14 +295,21 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
259
295
|
return isBotCommandRegistration(input.args[1]);
|
|
260
296
|
if (apiMethod === "sendChatAction")
|
|
261
297
|
return isTargetChatScoped(input.args[1]);
|
|
262
|
-
if (
|
|
298
|
+
if (
|
|
299
|
+
apiMethod === "sendMessage" &&
|
|
300
|
+
isTelegramBusAggregateDelivery(input.args[1])
|
|
301
|
+
) {
|
|
263
302
|
const body = input.args[1] as Record<string, unknown>;
|
|
264
|
-
return (
|
|
265
|
-
body.message_thread_id === undefined && isTargetChatScoped(body)
|
|
266
|
-
);
|
|
303
|
+
return body.message_thread_id === undefined && isTargetChatScoped(body);
|
|
267
304
|
}
|
|
268
305
|
if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
|
|
269
|
-
|
|
306
|
+
if (!isTargetMessageScoped(input.args[1])) return false;
|
|
307
|
+
const body = input.args[1] as Record<string, unknown>;
|
|
308
|
+
const messageId =
|
|
309
|
+
typeof body.message_id === "number"
|
|
310
|
+
? body.message_id
|
|
311
|
+
: Number(body.message_id);
|
|
312
|
+
return input.isMessageOwned?.(target!.chatId, messageId) === true;
|
|
270
313
|
}
|
|
271
314
|
return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
|
|
272
315
|
}
|
|
@@ -281,6 +324,32 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
281
324
|
return false;
|
|
282
325
|
}
|
|
283
326
|
|
|
327
|
+
export interface TelegramFollowerApiCallAuthorizationInput {
|
|
328
|
+
follower: TelegramBusFollowerView;
|
|
329
|
+
method: string;
|
|
330
|
+
args: unknown[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createTelegramFollowerApiCallAuthorizer(deps: {
|
|
334
|
+
isMessageOwned(input: {
|
|
335
|
+
chatId: number;
|
|
336
|
+
messageId: number;
|
|
337
|
+
follower: TelegramBusFollowerView;
|
|
338
|
+
}): boolean;
|
|
339
|
+
}): (input: TelegramFollowerApiCallAuthorizationInput) => boolean {
|
|
340
|
+
return (input) =>
|
|
341
|
+
isTelegramFollowerApiCallAllowed({
|
|
342
|
+
...input,
|
|
343
|
+
isMessageOwned(chatId, messageId) {
|
|
344
|
+
return deps.isMessageOwned({
|
|
345
|
+
chatId,
|
|
346
|
+
messageId,
|
|
347
|
+
follower: input.follower,
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
284
353
|
export type TelegramBusEnvelope = (
|
|
285
354
|
| {
|
|
286
355
|
kind: "follower.register";
|
|
@@ -291,12 +360,14 @@ export type TelegramBusEnvelope = (
|
|
|
291
360
|
kind: "follower.heartbeat";
|
|
292
361
|
requestId: string;
|
|
293
362
|
instanceId: string;
|
|
363
|
+
registrationGeneration?: string;
|
|
294
364
|
sentAtMs: number;
|
|
295
365
|
}
|
|
296
366
|
| {
|
|
297
367
|
kind: "leader.forwardCallback";
|
|
298
368
|
requestId: string;
|
|
299
369
|
recipientInstanceId: string;
|
|
370
|
+
recipientRegistrationGeneration?: string;
|
|
300
371
|
query: unknown;
|
|
301
372
|
sentAtMs: number;
|
|
302
373
|
}
|
|
@@ -304,6 +375,7 @@ export type TelegramBusEnvelope = (
|
|
|
304
375
|
kind: "leader.forwardReaction";
|
|
305
376
|
requestId: string;
|
|
306
377
|
recipientInstanceId: string;
|
|
378
|
+
recipientRegistrationGeneration?: string;
|
|
307
379
|
reactionUpdate: unknown;
|
|
308
380
|
sentAtMs: number;
|
|
309
381
|
}
|
|
@@ -311,6 +383,7 @@ export type TelegramBusEnvelope = (
|
|
|
311
383
|
kind: "leader.forwardMessage";
|
|
312
384
|
requestId: string;
|
|
313
385
|
recipientInstanceId: string;
|
|
386
|
+
recipientRegistrationGeneration?: string;
|
|
314
387
|
message: unknown;
|
|
315
388
|
sentAtMs: number;
|
|
316
389
|
}
|
|
@@ -318,6 +391,7 @@ export type TelegramBusEnvelope = (
|
|
|
318
391
|
kind: "leader.forwardEditedMessage";
|
|
319
392
|
requestId: string;
|
|
320
393
|
recipientInstanceId: string;
|
|
394
|
+
recipientRegistrationGeneration?: string;
|
|
321
395
|
message: unknown;
|
|
322
396
|
sentAtMs: number;
|
|
323
397
|
}
|
|
@@ -325,6 +399,7 @@ export type TelegramBusEnvelope = (
|
|
|
325
399
|
kind: "leader.replaceFollowerTarget";
|
|
326
400
|
requestId: string;
|
|
327
401
|
recipientInstanceId: string;
|
|
402
|
+
recipientRegistrationGeneration?: string;
|
|
328
403
|
target: TelegramTarget & { threadId: number };
|
|
329
404
|
oldTarget?: TelegramTarget & { threadId: number };
|
|
330
405
|
reason: "thread-restore";
|
|
@@ -334,6 +409,7 @@ export type TelegramBusEnvelope = (
|
|
|
334
409
|
kind: "follower.callApi";
|
|
335
410
|
requestId: string;
|
|
336
411
|
instanceId: string;
|
|
412
|
+
registrationGeneration?: string;
|
|
337
413
|
method: string;
|
|
338
414
|
args: unknown[];
|
|
339
415
|
sentAtMs: number;
|
|
@@ -344,6 +420,10 @@ export type TelegramBusEnvelope = (
|
|
|
344
420
|
ok: boolean;
|
|
345
421
|
message?: string;
|
|
346
422
|
result?: unknown;
|
|
423
|
+
error?: {
|
|
424
|
+
code: "commit-unknown" | "request-id-collision" | "ledger-overloaded";
|
|
425
|
+
method?: string;
|
|
426
|
+
};
|
|
347
427
|
}
|
|
348
428
|
) & { auth?: string };
|
|
349
429
|
|
|
@@ -354,6 +434,16 @@ export function createTelegramBusRequestId(input: {
|
|
|
354
434
|
return `${input.instanceId}:${input.sequence}`;
|
|
355
435
|
}
|
|
356
436
|
|
|
437
|
+
export function createTelegramBusRequestIdFactory(
|
|
438
|
+
instanceId: string,
|
|
439
|
+
): () => string {
|
|
440
|
+
let sequence = 0;
|
|
441
|
+
return () => {
|
|
442
|
+
sequence += 1;
|
|
443
|
+
return createTelegramBusRequestId({ instanceId, sequence });
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
357
447
|
export function encodeTelegramBusEnvelope(
|
|
358
448
|
envelope: TelegramBusEnvelope,
|
|
359
449
|
): string {
|
|
@@ -441,6 +531,13 @@ export interface TelegramBusLocalServerDeps {
|
|
|
441
531
|
) =>
|
|
442
532
|
Promise<TelegramBusEnvelope | undefined> | TelegramBusEnvelope | undefined;
|
|
443
533
|
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
534
|
+
beforeEndpointPublication?: () => Promise<void> | void;
|
|
535
|
+
commitEndpointPublication?: (commit: () => void) => boolean;
|
|
536
|
+
requestLedgerMaxEntries?: number;
|
|
537
|
+
shouldDropResponse?: (
|
|
538
|
+
request: TelegramBusEnvelope,
|
|
539
|
+
response: TelegramBusEnvelope,
|
|
540
|
+
) => boolean;
|
|
444
541
|
}
|
|
445
542
|
|
|
446
543
|
export interface TelegramBusLocalClientOptions {
|
|
@@ -469,22 +566,22 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
469
566
|
): {
|
|
470
567
|
forwardCallback: (input: {
|
|
471
568
|
query: TCallbackQuery;
|
|
472
|
-
ownership: { instanceId: string };
|
|
569
|
+
ownership: { instanceId: string; ownerGeneration?: string };
|
|
473
570
|
ctx: TContext;
|
|
474
571
|
}) => Promise<boolean>;
|
|
475
572
|
forwardReaction: (input: {
|
|
476
573
|
reactionUpdate: TReactionUpdate;
|
|
477
|
-
ownership: { instanceId: string };
|
|
574
|
+
ownership: { instanceId: string; ownerGeneration?: string };
|
|
478
575
|
ctx: TContext;
|
|
479
576
|
}) => Promise<boolean>;
|
|
480
577
|
forwardMessage: (input: {
|
|
481
578
|
message: TMessage;
|
|
482
|
-
ownership: { instanceId: string };
|
|
579
|
+
ownership: { instanceId: string; ownerGeneration?: string };
|
|
483
580
|
ctx: TContext;
|
|
484
581
|
}) => Promise<boolean>;
|
|
485
582
|
forwardEditedMessage: (input: {
|
|
486
583
|
message: TMessage;
|
|
487
|
-
ownership: { instanceId: string };
|
|
584
|
+
ownership: { instanceId: string; ownerGeneration?: string };
|
|
488
585
|
ctx: TContext;
|
|
489
586
|
}) => Promise<boolean>;
|
|
490
587
|
} {
|
|
@@ -509,6 +606,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
509
606
|
kind: "leader.forwardCallback",
|
|
510
607
|
requestId: deps.createRequestId(),
|
|
511
608
|
recipientInstanceId: ownership.instanceId,
|
|
609
|
+
...(ownership.ownerGeneration
|
|
610
|
+
? { recipientRegistrationGeneration: ownership.ownerGeneration }
|
|
611
|
+
: {}),
|
|
512
612
|
query,
|
|
513
613
|
sentAtMs: getNowMs(),
|
|
514
614
|
}),
|
|
@@ -517,6 +617,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
517
617
|
kind: "leader.forwardReaction",
|
|
518
618
|
requestId: deps.createRequestId(),
|
|
519
619
|
recipientInstanceId: ownership.instanceId,
|
|
620
|
+
...(ownership.ownerGeneration
|
|
621
|
+
? { recipientRegistrationGeneration: ownership.ownerGeneration }
|
|
622
|
+
: {}),
|
|
520
623
|
reactionUpdate,
|
|
521
624
|
sentAtMs: getNowMs(),
|
|
522
625
|
}),
|
|
@@ -525,6 +628,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
525
628
|
kind: "leader.forwardMessage",
|
|
526
629
|
requestId: deps.createRequestId(),
|
|
527
630
|
recipientInstanceId: ownership.instanceId,
|
|
631
|
+
...(ownership.ownerGeneration
|
|
632
|
+
? { recipientRegistrationGeneration: ownership.ownerGeneration }
|
|
633
|
+
: {}),
|
|
528
634
|
message,
|
|
529
635
|
sentAtMs: getNowMs(),
|
|
530
636
|
}),
|
|
@@ -533,6 +639,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
533
639
|
kind: "leader.forwardEditedMessage",
|
|
534
640
|
requestId: deps.createRequestId(),
|
|
535
641
|
recipientInstanceId: ownership.instanceId,
|
|
642
|
+
...(ownership.ownerGeneration
|
|
643
|
+
? { recipientRegistrationGeneration: ownership.ownerGeneration }
|
|
644
|
+
: {}),
|
|
536
645
|
message,
|
|
537
646
|
sentAtMs: getNowMs(),
|
|
538
647
|
}),
|
|
@@ -579,6 +688,11 @@ export function createTelegramBusFollowerTargetController(
|
|
|
579
688
|
kind: "leader.replaceFollowerTarget",
|
|
580
689
|
requestId: deps.createRequestId(),
|
|
581
690
|
recipientInstanceId: follower.instanceId,
|
|
691
|
+
...(follower.registrationGeneration
|
|
692
|
+
? {
|
|
693
|
+
recipientRegistrationGeneration: follower.registrationGeneration,
|
|
694
|
+
}
|
|
695
|
+
: {}),
|
|
582
696
|
target,
|
|
583
697
|
...(oldTarget ? { oldTarget } : {}),
|
|
584
698
|
reason,
|
|
@@ -648,8 +762,77 @@ export function createUnauthorizedBusAck(
|
|
|
648
762
|
export function createTelegramBusLocalServer(
|
|
649
763
|
deps: TelegramBusLocalServerDeps,
|
|
650
764
|
): TelegramBusLocalServer {
|
|
765
|
+
type LedgerEntry = {
|
|
766
|
+
fingerprint: string;
|
|
767
|
+
settled: boolean;
|
|
768
|
+
result: Promise<TelegramBusEnvelope | undefined>;
|
|
769
|
+
};
|
|
770
|
+
const requestLedger = new Map<string, LedgerEntry>();
|
|
771
|
+
const requestLedgerMaxEntries = Math.max(
|
|
772
|
+
1,
|
|
773
|
+
deps.requestLedgerMaxEntries ?? 4096,
|
|
774
|
+
);
|
|
775
|
+
const getRequestLedgerKey = (envelope: TelegramBusEnvelope): string => {
|
|
776
|
+
const identity =
|
|
777
|
+
envelope.kind === "follower.register"
|
|
778
|
+
? envelope.registration.instanceId
|
|
779
|
+
: "instanceId" in envelope
|
|
780
|
+
? envelope.instanceId
|
|
781
|
+
: "recipientInstanceId" in envelope
|
|
782
|
+
? envelope.recipientInstanceId
|
|
783
|
+
: "ack";
|
|
784
|
+
return `${envelope.auth ?? ""}:${identity}:${envelope.requestId}`;
|
|
785
|
+
};
|
|
786
|
+
const handleEnvelopeOnce = (
|
|
787
|
+
envelope: TelegramBusEnvelope,
|
|
788
|
+
): Promise<TelegramBusEnvelope | undefined> => {
|
|
789
|
+
const key = getRequestLedgerKey(envelope);
|
|
790
|
+
const fingerprint = JSON.stringify(envelope);
|
|
791
|
+
const existing = requestLedger.get(key);
|
|
792
|
+
if (existing) {
|
|
793
|
+
if (existing.fingerprint === fingerprint) return existing.result;
|
|
794
|
+
return Promise.resolve({
|
|
795
|
+
kind: "bus.ack",
|
|
796
|
+
requestId: envelope.requestId,
|
|
797
|
+
ok: false,
|
|
798
|
+
message: "Telegram bus request id was reused with a different payload.",
|
|
799
|
+
error: { code: "request-id-collision" },
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
if (requestLedger.size >= requestLedgerMaxEntries) {
|
|
803
|
+
const settledKey = Array.from(requestLedger.entries()).find(
|
|
804
|
+
([, entry]) => entry.settled,
|
|
805
|
+
)?.[0];
|
|
806
|
+
if (settledKey) requestLedger.delete(settledKey);
|
|
807
|
+
}
|
|
808
|
+
if (requestLedger.size >= requestLedgerMaxEntries) {
|
|
809
|
+
return Promise.resolve({
|
|
810
|
+
kind: "bus.ack",
|
|
811
|
+
requestId: envelope.requestId,
|
|
812
|
+
ok: false,
|
|
813
|
+
message: "Telegram bus request ledger is full.",
|
|
814
|
+
error: { code: "ledger-overloaded" },
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const entry: LedgerEntry = {
|
|
818
|
+
fingerprint,
|
|
819
|
+
settled: false,
|
|
820
|
+
result: Promise.resolve().then(() => deps.handleEnvelope(envelope)),
|
|
821
|
+
};
|
|
822
|
+
requestLedger.set(key, entry);
|
|
823
|
+
void entry.result.then(
|
|
824
|
+
() => {
|
|
825
|
+
entry.settled = true;
|
|
826
|
+
},
|
|
827
|
+
() => {
|
|
828
|
+
entry.settled = true;
|
|
829
|
+
},
|
|
830
|
+
);
|
|
831
|
+
return entry.result;
|
|
832
|
+
};
|
|
651
833
|
let server: Server | undefined;
|
|
652
834
|
let activeSocketPath: string | undefined;
|
|
835
|
+
let activeListenPath: string | undefined;
|
|
653
836
|
let endpointRecovery: Promise<boolean> | undefined;
|
|
654
837
|
let stopGeneration = 0;
|
|
655
838
|
const sockets = new Set<Socket>();
|
|
@@ -661,8 +844,13 @@ export function createTelegramBusLocalServer(
|
|
|
661
844
|
start: async () => {
|
|
662
845
|
if (server) return;
|
|
663
846
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
664
|
-
activeSocketPath = socketPath;
|
|
665
847
|
const usesWindowsPipe = isTelegramBusPipePath(socketPath);
|
|
848
|
+
const endpointGeneration = randomBytes(8).toString("hex");
|
|
849
|
+
const listenPath = usesWindowsPipe
|
|
850
|
+
? socketPath
|
|
851
|
+
: join(dirname(socketPath), `.pi-telegram-${endpointGeneration}.sock`);
|
|
852
|
+
activeSocketPath = socketPath;
|
|
853
|
+
activeListenPath = listenPath;
|
|
666
854
|
deps.recordTransportEvent?.(
|
|
667
855
|
"server-start",
|
|
668
856
|
getTelegramBusEndpointDiagnostics(socketPath),
|
|
@@ -671,7 +859,28 @@ export function createTelegramBusLocalServer(
|
|
|
671
859
|
const socketDir = dirname(socketPath);
|
|
672
860
|
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
673
861
|
chmodSync(socketDir, 0o700);
|
|
674
|
-
if (existsSync(
|
|
862
|
+
if (existsSync(listenPath)) unlinkSync(listenPath);
|
|
863
|
+
const legacyDeadlineMs = Date.now() + 2000;
|
|
864
|
+
while (true) {
|
|
865
|
+
let isLegacySocket = false;
|
|
866
|
+
try {
|
|
867
|
+
isLegacySocket = lstatSync(socketPath).isSocket();
|
|
868
|
+
} catch {
|
|
869
|
+
/* endpoint does not exist */
|
|
870
|
+
}
|
|
871
|
+
if (!isLegacySocket) break;
|
|
872
|
+
const probe = await probeTelegramBusEndpoint({
|
|
873
|
+
endpoint: socketPath,
|
|
874
|
+
timeoutMs: 50,
|
|
875
|
+
});
|
|
876
|
+
if (!probe.reachable) break;
|
|
877
|
+
if (Date.now() >= legacyDeadlineMs) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
`Timed out waiting for legacy Telegram bus endpoint: ${socketPath}`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
await delayTelegramBusTransportRetry(25);
|
|
883
|
+
}
|
|
675
884
|
}
|
|
676
885
|
server = createServer((socket) => {
|
|
677
886
|
sockets.add(socket);
|
|
@@ -685,9 +894,10 @@ export function createTelegramBusLocalServer(
|
|
|
685
894
|
void handleTelegramBusSocketLine(
|
|
686
895
|
line,
|
|
687
896
|
socket,
|
|
688
|
-
|
|
897
|
+
handleEnvelopeOnce,
|
|
689
898
|
deps.recordTransportEvent,
|
|
690
899
|
socketPath,
|
|
900
|
+
deps.shouldDropResponse,
|
|
691
901
|
);
|
|
692
902
|
}
|
|
693
903
|
});
|
|
@@ -703,7 +913,7 @@ export function createTelegramBusLocalServer(
|
|
|
703
913
|
try {
|
|
704
914
|
await new Promise<void>((resolve, reject) => {
|
|
705
915
|
server?.once("error", reject);
|
|
706
|
-
server?.listen(
|
|
916
|
+
server?.listen(listenPath, resolve);
|
|
707
917
|
});
|
|
708
918
|
deps.recordTransportEvent?.(
|
|
709
919
|
"server-started",
|
|
@@ -712,32 +922,76 @@ export function createTelegramBusLocalServer(
|
|
|
712
922
|
} catch (error) {
|
|
713
923
|
server = undefined;
|
|
714
924
|
activeSocketPath = undefined;
|
|
925
|
+
activeListenPath = undefined;
|
|
715
926
|
deps.recordTransportEvent?.("server-start-failed", {
|
|
716
927
|
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
717
928
|
...classifyTelegramBusTransportError(error),
|
|
718
929
|
});
|
|
719
930
|
throw error;
|
|
720
931
|
}
|
|
721
|
-
if (!usesWindowsPipe)
|
|
932
|
+
if (!usesWindowsPipe) {
|
|
933
|
+
chmodSync(listenPath, 0o600);
|
|
934
|
+
const linkPath = `${socketPath}.link.${endpointGeneration}`;
|
|
935
|
+
try {
|
|
936
|
+
symlinkSync(basename(listenPath), linkPath);
|
|
937
|
+
await deps.beforeEndpointPublication?.();
|
|
938
|
+
const committed = deps.commitEndpointPublication
|
|
939
|
+
? deps.commitEndpointPublication(() =>
|
|
940
|
+
renameSync(linkPath, socketPath),
|
|
941
|
+
)
|
|
942
|
+
: (renameSync(linkPath, socketPath), true);
|
|
943
|
+
if (!committed) {
|
|
944
|
+
throw new Error(
|
|
945
|
+
"Telegram bus endpoint publication lost transport ownership.",
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
} catch (error) {
|
|
949
|
+
try {
|
|
950
|
+
if (lstatSync(linkPath).isSymbolicLink()) unlinkSync(linkPath);
|
|
951
|
+
} catch {
|
|
952
|
+
/* no unpublished link to remove */
|
|
953
|
+
}
|
|
954
|
+
const failedServer = server;
|
|
955
|
+
server = undefined;
|
|
956
|
+
activeSocketPath = undefined;
|
|
957
|
+
activeListenPath = undefined;
|
|
958
|
+
if (failedServer) {
|
|
959
|
+
await new Promise<void>((resolve) =>
|
|
960
|
+
failedServer.close(() => resolve()),
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
722
966
|
},
|
|
723
967
|
stop: async () => {
|
|
724
968
|
stopGeneration += 1;
|
|
969
|
+
requestLedger.clear();
|
|
725
970
|
const activeServer = server;
|
|
726
971
|
const socketPath = activeSocketPath;
|
|
972
|
+
const listenPath = activeListenPath;
|
|
727
973
|
server = undefined;
|
|
728
974
|
activeSocketPath = undefined;
|
|
975
|
+
activeListenPath = undefined;
|
|
729
976
|
for (const socket of sockets) closeSocket(socket);
|
|
730
977
|
if (activeServer) {
|
|
731
978
|
await new Promise<void>((resolve) =>
|
|
732
979
|
activeServer.close(() => resolve()),
|
|
733
980
|
);
|
|
734
981
|
}
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
982
|
+
if (socketPath && listenPath && !isTelegramBusPipePath(socketPath)) {
|
|
983
|
+
try {
|
|
984
|
+
if (
|
|
985
|
+
lstatSync(socketPath).isSymbolicLink() &&
|
|
986
|
+
readlinkSync(socketPath) === basename(listenPath)
|
|
987
|
+
) {
|
|
988
|
+
// Leave the generation link in place. Node removes only the unique
|
|
989
|
+
// listen path on close; a later generation atomically replaces this
|
|
990
|
+
// link, and existsSync() treats the stopped dangling link as missing.
|
|
991
|
+
}
|
|
992
|
+
} catch {
|
|
993
|
+
/* endpoint already moved or removed */
|
|
994
|
+
}
|
|
741
995
|
}
|
|
742
996
|
if (socketPath) {
|
|
743
997
|
deps.recordTransportEvent?.(
|
|
@@ -962,6 +1216,10 @@ async function handleTelegramBusSocketLine(
|
|
|
962
1216
|
handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
|
|
963
1217
|
recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
|
|
964
1218
|
socketPath: string,
|
|
1219
|
+
shouldDropResponse?: (
|
|
1220
|
+
request: TelegramBusEnvelope,
|
|
1221
|
+
response: TelegramBusEnvelope,
|
|
1222
|
+
) => boolean,
|
|
965
1223
|
): Promise<void> {
|
|
966
1224
|
const envelope = parseTelegramBusEnvelope(line);
|
|
967
1225
|
if (!envelope) {
|
|
@@ -981,7 +1239,9 @@ async function handleTelegramBusSocketLine(
|
|
|
981
1239
|
}
|
|
982
1240
|
try {
|
|
983
1241
|
const response = await handleEnvelope(envelope);
|
|
984
|
-
if (response
|
|
1242
|
+
if (response && !shouldDropResponse?.(envelope, response)) {
|
|
1243
|
+
socket.write(encodeTelegramBusEnvelope(response));
|
|
1244
|
+
}
|
|
985
1245
|
} catch (error) {
|
|
986
1246
|
recordTransportEvent?.("server-handler-failed", {
|
|
987
1247
|
...getTelegramBusEndpointDiagnostics(socketPath),
|
|
@@ -1019,6 +1279,9 @@ function parseHeartbeatEnvelope(
|
|
|
1019
1279
|
kind: "follower.heartbeat",
|
|
1020
1280
|
requestId,
|
|
1021
1281
|
instanceId: value.instanceId,
|
|
1282
|
+
...(typeof value.registrationGeneration === "string"
|
|
1283
|
+
? { registrationGeneration: value.registrationGeneration }
|
|
1284
|
+
: {}),
|
|
1022
1285
|
sentAtMs: value.sentAtMs,
|
|
1023
1286
|
}
|
|
1024
1287
|
: undefined;
|
|
@@ -1034,6 +1297,12 @@ function parseForwardCallbackEnvelope(
|
|
|
1034
1297
|
kind: "leader.forwardCallback",
|
|
1035
1298
|
requestId,
|
|
1036
1299
|
recipientInstanceId: value.recipientInstanceId,
|
|
1300
|
+
...(typeof value.recipientRegistrationGeneration === "string"
|
|
1301
|
+
? {
|
|
1302
|
+
recipientRegistrationGeneration:
|
|
1303
|
+
value.recipientRegistrationGeneration,
|
|
1304
|
+
}
|
|
1305
|
+
: {}),
|
|
1037
1306
|
query: value.query,
|
|
1038
1307
|
sentAtMs: value.sentAtMs,
|
|
1039
1308
|
}
|
|
@@ -1050,6 +1319,12 @@ function parseForwardReactionEnvelope(
|
|
|
1050
1319
|
kind: "leader.forwardReaction",
|
|
1051
1320
|
requestId,
|
|
1052
1321
|
recipientInstanceId: value.recipientInstanceId,
|
|
1322
|
+
...(typeof value.recipientRegistrationGeneration === "string"
|
|
1323
|
+
? {
|
|
1324
|
+
recipientRegistrationGeneration:
|
|
1325
|
+
value.recipientRegistrationGeneration,
|
|
1326
|
+
}
|
|
1327
|
+
: {}),
|
|
1053
1328
|
reactionUpdate: value.reactionUpdate,
|
|
1054
1329
|
sentAtMs: value.sentAtMs,
|
|
1055
1330
|
}
|
|
@@ -1067,6 +1342,12 @@ function parseForwardMessageEnvelope(
|
|
|
1067
1342
|
kind,
|
|
1068
1343
|
requestId,
|
|
1069
1344
|
recipientInstanceId: value.recipientInstanceId,
|
|
1345
|
+
...(typeof value.recipientRegistrationGeneration === "string"
|
|
1346
|
+
? {
|
|
1347
|
+
recipientRegistrationGeneration:
|
|
1348
|
+
value.recipientRegistrationGeneration,
|
|
1349
|
+
}
|
|
1350
|
+
: {}),
|
|
1070
1351
|
message: value.message,
|
|
1071
1352
|
sentAtMs: value.sentAtMs,
|
|
1072
1353
|
}
|
|
@@ -1092,6 +1373,12 @@ function parseReplaceFollowerTargetEnvelope(
|
|
|
1092
1373
|
kind: "leader.replaceFollowerTarget",
|
|
1093
1374
|
requestId,
|
|
1094
1375
|
recipientInstanceId: value.recipientInstanceId,
|
|
1376
|
+
...(typeof value.recipientRegistrationGeneration === "string"
|
|
1377
|
+
? {
|
|
1378
|
+
recipientRegistrationGeneration:
|
|
1379
|
+
value.recipientRegistrationGeneration,
|
|
1380
|
+
}
|
|
1381
|
+
: {}),
|
|
1095
1382
|
target,
|
|
1096
1383
|
...(oldTarget ? { oldTarget } : {}),
|
|
1097
1384
|
reason: value.reason,
|
|
@@ -1111,6 +1398,9 @@ function parseCallApiEnvelope(
|
|
|
1111
1398
|
kind: "follower.callApi",
|
|
1112
1399
|
requestId,
|
|
1113
1400
|
instanceId: value.instanceId,
|
|
1401
|
+
...(typeof value.registrationGeneration === "string"
|
|
1402
|
+
? { registrationGeneration: value.registrationGeneration }
|
|
1403
|
+
: {}),
|
|
1114
1404
|
method: value.method,
|
|
1115
1405
|
args: value.args,
|
|
1116
1406
|
sentAtMs: value.sentAtMs,
|
|
@@ -1130,6 +1420,21 @@ function parseAckEnvelope(
|
|
|
1130
1420
|
message: typeof value.message === "string" ? value.message : undefined,
|
|
1131
1421
|
};
|
|
1132
1422
|
if (Object.hasOwn(value, "result")) envelope.result = value.result;
|
|
1423
|
+
if (isRecord(value.error)) {
|
|
1424
|
+
const code = value.error.code;
|
|
1425
|
+
if (
|
|
1426
|
+
code === "commit-unknown" ||
|
|
1427
|
+
code === "request-id-collision" ||
|
|
1428
|
+
code === "ledger-overloaded"
|
|
1429
|
+
) {
|
|
1430
|
+
envelope.error = {
|
|
1431
|
+
code,
|
|
1432
|
+
...(typeof value.error.method === "string"
|
|
1433
|
+
? { method: value.error.method }
|
|
1434
|
+
: {}),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1133
1438
|
return envelope;
|
|
1134
1439
|
}
|
|
1135
1440
|
|
|
@@ -1157,6 +1462,9 @@ function parseRegistration(
|
|
|
1157
1462
|
if (typeof value.busSocketPath === "string") {
|
|
1158
1463
|
registration.busSocketPath = value.busSocketPath;
|
|
1159
1464
|
}
|
|
1465
|
+
if (typeof value.registrationGeneration === "string") {
|
|
1466
|
+
registration.registrationGeneration = value.registrationGeneration;
|
|
1467
|
+
}
|
|
1160
1468
|
if (target) registration.target = target;
|
|
1161
1469
|
return registration;
|
|
1162
1470
|
}
|