@llblab/pi-telegram 0.22.1 → 0.23.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 +24 -13
- package/BACKLOG.md +1 -51
- package/CHANGELOG.md +41 -19
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +16 -15
- package/docs/locks.md +21 -15
- package/docs/multi-instance-bus.md +17 -16
- package/docs/outbound.md +16 -0
- package/docs/public-api.md +9 -4
- package/index.ts +76 -65
- package/lib/activity.ts +100 -3
- package/lib/bindings.ts +86 -15
- package/lib/bus-follower.ts +205 -17
- package/lib/bus-leader.ts +333 -244
- package/lib/bus.ts +82 -19
- package/lib/commands.ts +28 -4
- package/lib/config.ts +44 -33
- package/lib/media.ts +102 -1
- package/lib/menu-settings.ts +4 -1
- package/lib/outbound-attachments.ts +150 -1
- package/lib/outbound.ts +106 -1
- package/lib/polling.ts +5 -0
- package/lib/queue.ts +41 -48
- package/lib/routing.ts +88 -1
- package/lib/sync.ts +40 -3
- package/lib/telegram-api.ts +77 -13
- package/lib/text-groups.ts +183 -16
- package/lib/thread-reconciler.ts +69 -93
- package/lib/threads.ts +131 -66
- package/lib/turns.ts +102 -13
- package/lib/updates.ts +2 -0
- package/package.json +3 -2
package/lib/bus-follower.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
|
98
98
|
options?: { target?: TelegramTarget },
|
|
99
99
|
) => Promise<boolean>;
|
|
100
100
|
setContext: (ctx: TContext) => void;
|
|
101
|
+
disconnectFromLeader?: () => Promise<boolean>;
|
|
101
102
|
stop: () => void;
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -108,6 +109,10 @@ export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
|
|
|
108
109
|
>;
|
|
109
110
|
instanceId: string;
|
|
110
111
|
suspendPolling: () => Promise<void>;
|
|
112
|
+
isLeader?: () => boolean;
|
|
113
|
+
getLeaderBinding?: () => TelegramBusFollowerPromotedBinding | undefined;
|
|
114
|
+
getActiveContext?: () => { cwd?: string } | undefined;
|
|
115
|
+
getActiveProfileName?: () => string | undefined;
|
|
111
116
|
recordRuntimeEvent: (
|
|
112
117
|
category: string,
|
|
113
118
|
error: unknown,
|
|
@@ -139,6 +144,8 @@ export interface TelegramBusFollowerRegistrationState {
|
|
|
139
144
|
getSlot: () => string | undefined;
|
|
140
145
|
getThreadName: () => string | undefined;
|
|
141
146
|
getGeneration: () => string | undefined;
|
|
147
|
+
getEligibleElectionSlots: () => readonly string[];
|
|
148
|
+
setEligibleElectionSlots: (slots: readonly string[]) => void;
|
|
142
149
|
setRegistered: (
|
|
143
150
|
registered: boolean,
|
|
144
151
|
target?: TelegramTarget,
|
|
@@ -151,12 +158,20 @@ export interface TelegramBusForwardedUpdateReceiverRuntime {
|
|
|
151
158
|
stop: () => Promise<void>;
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
export interface TelegramBusFollowerClientRuntimeDeps {
|
|
161
|
+
export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
|
|
155
162
|
socketPath: TelegramBusSocketPathSource;
|
|
156
163
|
instanceId: string;
|
|
157
164
|
getApiAuthSecret?: () => string | undefined;
|
|
158
165
|
getForwardingAuthSecret?: () => string | undefined;
|
|
159
166
|
getRegistrationGeneration?: () => string | undefined;
|
|
167
|
+
getForwardCommentBatchPosition?: (
|
|
168
|
+
message: TMessage,
|
|
169
|
+
) => "comment" | "forward" | undefined;
|
|
170
|
+
recordRuntimeEvent?: (
|
|
171
|
+
category: string,
|
|
172
|
+
error: unknown,
|
|
173
|
+
details?: Record<string, unknown>,
|
|
174
|
+
) => void;
|
|
160
175
|
timeoutMs?: number;
|
|
161
176
|
}
|
|
162
177
|
|
|
@@ -239,9 +254,11 @@ export function createTelegramBusFollowerPromotionHandler<
|
|
|
239
254
|
error: unknown,
|
|
240
255
|
details?: Record<string, unknown>,
|
|
241
256
|
) => void;
|
|
257
|
+
getNowMs?: () => number;
|
|
258
|
+
getPid?: () => number;
|
|
242
259
|
}): TelegramBusFollowerPromotionHandler<TContext> {
|
|
243
|
-
return async (ctx, binding, election) =>
|
|
244
|
-
input.startLeader(ctx, election, async () => {
|
|
260
|
+
return async (ctx, binding, election) => {
|
|
261
|
+
const promoted = await input.startLeader(ctx, election, async () => {
|
|
245
262
|
const promotedRecord =
|
|
246
263
|
await Threads.promoteTelegramFollowerBindingToLeader({
|
|
247
264
|
store: input.topicTargetStore,
|
|
@@ -266,6 +283,39 @@ export function createTelegramBusFollowerPromotionHandler<
|
|
|
266
283
|
);
|
|
267
284
|
}
|
|
268
285
|
});
|
|
286
|
+
if (promoted && typeof binding.target?.threadId === "number") {
|
|
287
|
+
const profileKey = Threads.getTelegramThreadOwnerKey({
|
|
288
|
+
kind: "leader",
|
|
289
|
+
cwd: ctx.cwd,
|
|
290
|
+
instanceId: input.instanceId,
|
|
291
|
+
telegramProfile: input.getActiveProfileName(),
|
|
292
|
+
});
|
|
293
|
+
Threads.setTelegramLeaderSessionHandoff({
|
|
294
|
+
pid: input.getPid?.() ?? process.pid,
|
|
295
|
+
instanceId: input.instanceId,
|
|
296
|
+
createdAtMs: input.getNowMs?.() ?? Date.now(),
|
|
297
|
+
profileKey,
|
|
298
|
+
target: {
|
|
299
|
+
chatId: binding.target.chatId,
|
|
300
|
+
threadId: binding.target.threadId,
|
|
301
|
+
},
|
|
302
|
+
slot: binding.slot,
|
|
303
|
+
threadName: binding.threadName,
|
|
304
|
+
});
|
|
305
|
+
input.recordRuntimeEvent(
|
|
306
|
+
"bus",
|
|
307
|
+
"Promoted leader binding retained for session replacement",
|
|
308
|
+
{
|
|
309
|
+
phase: "follower-promoted-session-handoff",
|
|
310
|
+
chatId: binding.target.chatId,
|
|
311
|
+
threadId: binding.target.threadId,
|
|
312
|
+
slot: binding.slot,
|
|
313
|
+
threadName: binding.threadName,
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return promoted;
|
|
318
|
+
};
|
|
269
319
|
}
|
|
270
320
|
|
|
271
321
|
export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
|
|
@@ -309,7 +359,11 @@ export interface TelegramBusFollowerPromotedBinding {
|
|
|
309
359
|
export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
310
360
|
registrationState: Pick<
|
|
311
361
|
TelegramBusFollowerRegistrationState,
|
|
312
|
-
|
|
362
|
+
| "getTarget"
|
|
363
|
+
| "getSlot"
|
|
364
|
+
| "getThreadName"
|
|
365
|
+
| "getEligibleElectionSlots"
|
|
366
|
+
| "setRegistered"
|
|
313
367
|
>;
|
|
314
368
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
315
369
|
getLeaderState: () => TelegramBusFollowerLeaderState;
|
|
@@ -350,6 +404,10 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
|
|
|
350
404
|
reactionUpdate: TReactionUpdate,
|
|
351
405
|
ctx: TContext,
|
|
352
406
|
) => Promise<void> | void;
|
|
407
|
+
prepareForwardedMessage?: (
|
|
408
|
+
message: TMessage,
|
|
409
|
+
position: "comment" | "forward",
|
|
410
|
+
) => void;
|
|
353
411
|
handleForwardedMessage?: (
|
|
354
412
|
message: TMessage,
|
|
355
413
|
ctx: TContext,
|
|
@@ -564,7 +622,7 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
564
622
|
TReactionUpdate,
|
|
565
623
|
TCallbackQuery,
|
|
566
624
|
TMessage = unknown,
|
|
567
|
-
>(deps: TelegramBusFollowerClientRuntimeDeps) {
|
|
625
|
+
>(deps: TelegramBusFollowerClientRuntimeDeps<TMessage>) {
|
|
568
626
|
const createRequestId = createTelegramBusRequestIdFactory(deps.instanceId);
|
|
569
627
|
const sharedClientDeps = {
|
|
570
628
|
socketPath: deps.socketPath,
|
|
@@ -587,6 +645,9 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
587
645
|
>({
|
|
588
646
|
...sharedClientDeps,
|
|
589
647
|
getAuthSecret: deps.getForwardingAuthSecret,
|
|
648
|
+
getForwardCommentBatchPosition:
|
|
649
|
+
deps.getForwardCommentBatchPosition,
|
|
650
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
590
651
|
}),
|
|
591
652
|
targetController: createTelegramBusFollowerTargetController({
|
|
592
653
|
...sharedClientDeps,
|
|
@@ -689,6 +750,41 @@ export function createTelegramBusFollowerSessionReplacementSuspender(
|
|
|
689
750
|
threadId: target.threadId,
|
|
690
751
|
},
|
|
691
752
|
);
|
|
753
|
+
} else if (deps.isLeader?.()) {
|
|
754
|
+
const leaderBinding = deps.getLeaderBinding?.();
|
|
755
|
+
if (typeof leaderBinding?.target?.threadId === "number") {
|
|
756
|
+
const activeContext = deps.getActiveContext?.();
|
|
757
|
+
const profileKey = Threads.getTelegramThreadOwnerKey({
|
|
758
|
+
kind: "leader",
|
|
759
|
+
cwd: activeContext?.cwd,
|
|
760
|
+
instanceId: deps.instanceId,
|
|
761
|
+
telegramProfile: deps.getActiveProfileName?.(),
|
|
762
|
+
});
|
|
763
|
+
Threads.setTelegramLeaderSessionHandoff({
|
|
764
|
+
pid: getPid(),
|
|
765
|
+
instanceId: deps.instanceId,
|
|
766
|
+
createdAtMs: getNowMs(),
|
|
767
|
+
profileKey,
|
|
768
|
+
target: {
|
|
769
|
+
chatId: leaderBinding.target.chatId,
|
|
770
|
+
threadId: leaderBinding.target.threadId,
|
|
771
|
+
},
|
|
772
|
+
slot: leaderBinding.slot,
|
|
773
|
+
threadName: leaderBinding.threadName,
|
|
774
|
+
});
|
|
775
|
+
deps.recordRuntimeEvent(
|
|
776
|
+
"bus",
|
|
777
|
+
"Telegram leader binding suspended for session replacement",
|
|
778
|
+
{
|
|
779
|
+
phase: "leader-session-handoff",
|
|
780
|
+
instanceId: deps.instanceId,
|
|
781
|
+
chatId: leaderBinding.target.chatId,
|
|
782
|
+
threadId: leaderBinding.target.threadId,
|
|
783
|
+
slot: leaderBinding.slot,
|
|
784
|
+
threadName: leaderBinding.threadName,
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
}
|
|
692
788
|
}
|
|
693
789
|
await deps.suspendPolling();
|
|
694
790
|
};
|
|
@@ -751,12 +847,19 @@ export function createTelegramBusFollowerRegistrationState(): TelegramBusFollowe
|
|
|
751
847
|
let slot: string | undefined;
|
|
752
848
|
let threadName: string | undefined;
|
|
753
849
|
let generation: string | undefined;
|
|
850
|
+
let eligibleElectionSlots: string[] = [];
|
|
754
851
|
return {
|
|
755
852
|
isRegistered: () => registered,
|
|
756
853
|
getTarget: () => (target ? { ...target } : undefined),
|
|
757
854
|
getSlot: () => slot,
|
|
758
855
|
getThreadName: () => threadName,
|
|
759
856
|
getGeneration: () => generation,
|
|
857
|
+
getEligibleElectionSlots: () => [...eligibleElectionSlots],
|
|
858
|
+
setEligibleElectionSlots: (slots) => {
|
|
859
|
+
eligibleElectionSlots = Array.from(
|
|
860
|
+
new Set(slots.filter((slot) => /^[A-Z]$/.test(slot))),
|
|
861
|
+
).sort();
|
|
862
|
+
},
|
|
760
863
|
setRegistered: (next, nextTarget, metadata) => {
|
|
761
864
|
registered = next;
|
|
762
865
|
target = next ? (nextTarget ? { ...nextTarget } : undefined) : undefined;
|
|
@@ -868,7 +971,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
868
971
|
deps.getRegistrationRuntime().stop();
|
|
869
972
|
deps.setLifecyclePhase("electing");
|
|
870
973
|
safeUpdateStatus(ctx);
|
|
871
|
-
deps.recordRuntimeEvent("bus", "Telegram follower
|
|
974
|
+
deps.recordRuntimeEvent("bus", "Telegram follower attempting promotion", {
|
|
872
975
|
phase: "follower-promotion-electing",
|
|
873
976
|
});
|
|
874
977
|
const promoted = await deps.promoteToLeader(ctx, binding, election);
|
|
@@ -887,6 +990,51 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
887
990
|
);
|
|
888
991
|
if (!promoted) scheduleRecovery(reason, ctx, binding);
|
|
889
992
|
};
|
|
993
|
+
const attemptPreferredPromotion = async (
|
|
994
|
+
reason: unknown,
|
|
995
|
+
ctx: TContext,
|
|
996
|
+
binding: TelegramBusFollowerPromotedBinding,
|
|
997
|
+
candidateState: TelegramBusFollowerLeaderState,
|
|
998
|
+
): Promise<void> => {
|
|
999
|
+
const slot = binding.slot;
|
|
1000
|
+
const lowerEligibleSlot = slot
|
|
1001
|
+
? deps.registrationState
|
|
1002
|
+
.getEligibleElectionSlots()
|
|
1003
|
+
.find((candidate) => candidate < slot)
|
|
1004
|
+
: undefined;
|
|
1005
|
+
if (lowerEligibleSlot) {
|
|
1006
|
+
deps.recordRuntimeEvent(
|
|
1007
|
+
"bus",
|
|
1008
|
+
"Telegram follower deferring to a lower-slot election candidate",
|
|
1009
|
+
{
|
|
1010
|
+
phase: "follower-promotion-slot-priority",
|
|
1011
|
+
slot,
|
|
1012
|
+
lowerEligibleSlot,
|
|
1013
|
+
},
|
|
1014
|
+
);
|
|
1015
|
+
await sleep(promotionGraceMs);
|
|
1016
|
+
candidateState = deps.getLeaderState();
|
|
1017
|
+
if (candidateState.kind === "active-elsewhere") {
|
|
1018
|
+
if (
|
|
1019
|
+
!(await tryRegisterWithLeader(
|
|
1020
|
+
ctx,
|
|
1021
|
+
candidateState.lock,
|
|
1022
|
+
"follower-register-preferred-successor",
|
|
1023
|
+
binding,
|
|
1024
|
+
))
|
|
1025
|
+
) {
|
|
1026
|
+
scheduleRecovery(reason, ctx, binding);
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (candidateState.kind !== "stale" && candidateState.kind !== "inactive")
|
|
1032
|
+
return;
|
|
1033
|
+
await promoteToLeader(reason, ctx, binding, {
|
|
1034
|
+
expectedOwner:
|
|
1035
|
+
candidateState.kind === "stale" ? candidateState.lock : undefined,
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
890
1038
|
const recover = async (
|
|
891
1039
|
error: unknown,
|
|
892
1040
|
ctx: TContext,
|
|
@@ -943,19 +1091,15 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
943
1091
|
scheduleRecovery(error, ctx, initialBinding);
|
|
944
1092
|
return;
|
|
945
1093
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1094
|
+
await attemptPreferredPromotion(
|
|
1095
|
+
error,
|
|
1096
|
+
ctx,
|
|
1097
|
+
initialBinding,
|
|
1098
|
+
graceState,
|
|
1099
|
+
);
|
|
952
1100
|
return;
|
|
953
1101
|
}
|
|
954
|
-
|
|
955
|
-
await promoteToLeader(error, ctx, initialBinding, {
|
|
956
|
-
expectedOwner: state.kind === "stale" ? state.lock : undefined,
|
|
957
|
-
});
|
|
958
|
-
}
|
|
1102
|
+
await attemptPreferredPromotion(error, ctx, initialBinding, state);
|
|
959
1103
|
} catch (promotionError) {
|
|
960
1104
|
deps.setLifecyclePhase(undefined);
|
|
961
1105
|
safeUpdateStatus(ctx);
|
|
@@ -1035,6 +1179,17 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1035
1179
|
sentAtMs: getNowMs(),
|
|
1036
1180
|
},
|
|
1037
1181
|
});
|
|
1182
|
+
if (response?.kind === "bus.ack" && response.ok) {
|
|
1183
|
+
const heartbeatResult = isRecord(response.result)
|
|
1184
|
+
? response.result
|
|
1185
|
+
: undefined;
|
|
1186
|
+
const slots = Array.isArray(heartbeatResult?.eligibleElectionSlots)
|
|
1187
|
+
? heartbeatResult.eligibleElectionSlots.filter(
|
|
1188
|
+
(slot): slot is string => typeof slot === "string",
|
|
1189
|
+
)
|
|
1190
|
+
: [];
|
|
1191
|
+
deps.registrationState?.setEligibleElectionSlots(slots);
|
|
1192
|
+
}
|
|
1038
1193
|
if (response?.kind === "bus.ack" && !response.ok) {
|
|
1039
1194
|
throw new Error(
|
|
1040
1195
|
response.message ?? "Telegram bus follower heartbeat was rejected.",
|
|
@@ -1172,6 +1327,33 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1172
1327
|
setContext(ctx) {
|
|
1173
1328
|
activeContext = ctx;
|
|
1174
1329
|
},
|
|
1330
|
+
async disconnectFromLeader() {
|
|
1331
|
+
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
1335
|
+
socketPath: activeLeaderSocketPath,
|
|
1336
|
+
timeoutMs: registrationTimeoutMs,
|
|
1337
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
1338
|
+
endpoint: activeLeaderSocketPath,
|
|
1339
|
+
operation: "operation",
|
|
1340
|
+
}),
|
|
1341
|
+
envelope: {
|
|
1342
|
+
kind: "follower.disconnect",
|
|
1343
|
+
requestId: deps.createRequestId(),
|
|
1344
|
+
auth: activeAuthSecret,
|
|
1345
|
+
instanceId: deps.instanceId,
|
|
1346
|
+
registrationGeneration: activeRegistrationGeneration,
|
|
1347
|
+
sentAtMs: getNowMs(),
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
if (response?.kind === "bus.ack" && response.ok) return true;
|
|
1351
|
+
throw new Error(
|
|
1352
|
+
response?.kind === "bus.ack"
|
|
1353
|
+
? (response.message ?? "Telegram follower disconnect was rejected.")
|
|
1354
|
+
: "Telegram follower disconnect was not acknowledged.",
|
|
1355
|
+
);
|
|
1356
|
+
},
|
|
1175
1357
|
stop,
|
|
1176
1358
|
};
|
|
1177
1359
|
}
|
|
@@ -1250,6 +1432,12 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<
|
|
|
1250
1432
|
ctx,
|
|
1251
1433
|
);
|
|
1252
1434
|
} else if (envelope.kind === "leader.forwardMessage") {
|
|
1435
|
+
if (envelope.forwardCommentBatchPosition) {
|
|
1436
|
+
deps.prepareForwardedMessage?.(
|
|
1437
|
+
envelope.message as TMessage,
|
|
1438
|
+
envelope.forwardCommentBatchPosition,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1253
1441
|
if (!deps.handleForwardedMessage) {
|
|
1254
1442
|
throw new Error(
|
|
1255
1443
|
"Telegram bus receiver cannot handle this envelope.",
|