@llblab/pi-telegram 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -4
- package/CHANGELOG.md +474 -452
- package/docs/architecture.md +12 -10
- package/docs/delivery.md +2 -1
- package/docs/locks.md +9 -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 +569 -56
- package/lib/logs.ts +178 -19
- 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/threads.ts
CHANGED
|
@@ -5,12 +5,30 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
chmod,
|
|
17
|
+
mkdir,
|
|
18
|
+
readFile,
|
|
19
|
+
rename,
|
|
20
|
+
unlink,
|
|
21
|
+
writeFile,
|
|
22
|
+
} from "node:fs/promises";
|
|
10
23
|
import { dirname } from "node:path";
|
|
11
24
|
|
|
12
|
-
import
|
|
25
|
+
import {
|
|
26
|
+
isTelegramApiCommitUnknownError,
|
|
27
|
+
TelegramApiCommitUnknownError,
|
|
28
|
+
type TelegramApiCallOptions,
|
|
29
|
+
} from "./telegram-api.ts";
|
|
13
30
|
import type { TelegramTarget } from "./target.ts";
|
|
31
|
+
import { withTelegramFileTransaction } from "./locks.ts";
|
|
14
32
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
15
33
|
import {
|
|
16
34
|
resolveAgentDir,
|
|
@@ -58,6 +76,9 @@ export interface TelegramThreadPendingProvision {
|
|
|
58
76
|
id: string;
|
|
59
77
|
owner: "leader" | "manual-follower";
|
|
60
78
|
instanceId: string;
|
|
79
|
+
profileKey?: string;
|
|
80
|
+
status?: "in-flight" | "ambiguous";
|
|
81
|
+
threadName?: string;
|
|
61
82
|
slot?: string;
|
|
62
83
|
target?: TelegramTarget & { threadId: number };
|
|
63
84
|
startedAtMs: number;
|
|
@@ -65,6 +86,16 @@ export interface TelegramThreadPendingProvision {
|
|
|
65
86
|
leaderEpoch?: number | string;
|
|
66
87
|
}
|
|
67
88
|
|
|
89
|
+
type TelegramProvisionRecoveryFile = Record<
|
|
90
|
+
string,
|
|
91
|
+
{
|
|
92
|
+
instanceId: string;
|
|
93
|
+
profileKey?: string;
|
|
94
|
+
leaderEpoch?: number | string;
|
|
95
|
+
target: TelegramTarget & { threadId: number };
|
|
96
|
+
}
|
|
97
|
+
>;
|
|
98
|
+
|
|
68
99
|
export interface TelegramTopicSyncObservation {
|
|
69
100
|
target: TelegramTarget & { threadId: number };
|
|
70
101
|
syncStatus: TelegramTopicSyncStatus;
|
|
@@ -152,7 +183,11 @@ function getNextMonotonicSlot(
|
|
|
152
183
|
cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0));
|
|
153
184
|
}
|
|
154
185
|
for (const provision of pendingProvisions) {
|
|
155
|
-
if (
|
|
186
|
+
if (
|
|
187
|
+
provision.status !== "ambiguous" &&
|
|
188
|
+
provision.expiresAtMs !== undefined &&
|
|
189
|
+
provision.expiresAtMs <= nowMs
|
|
190
|
+
)
|
|
156
191
|
continue;
|
|
157
192
|
if (!provision.slot) continue;
|
|
158
193
|
cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0));
|
|
@@ -191,6 +226,10 @@ export interface TelegramTopicTargetStore {
|
|
|
191
226
|
listSyncObservations: () => TelegramTopicSyncObservation[];
|
|
192
227
|
reserveThread: (reservation: TelegramThreadReservation) => void;
|
|
193
228
|
upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void;
|
|
229
|
+
recordPendingProvisionTargetRecovery: (
|
|
230
|
+
provision: TelegramThreadPendingProvision,
|
|
231
|
+
target: TelegramTarget & { threadId: number },
|
|
232
|
+
) => Promise<boolean>;
|
|
194
233
|
removePendingProvision: (id: string) => boolean;
|
|
195
234
|
removeReservationByTarget: (target: TelegramTarget) => boolean;
|
|
196
235
|
getBotState: () => TelegramBotStateSnapshot;
|
|
@@ -234,10 +273,7 @@ export interface TelegramTopicTargetStore {
|
|
|
234
273
|
}
|
|
235
274
|
|
|
236
275
|
export function reconcileTelegramFreshAllocationCursor(
|
|
237
|
-
store: Pick<
|
|
238
|
-
TelegramTopicTargetStore,
|
|
239
|
-
"getBotState" | "list" | "setBotState"
|
|
240
|
-
>,
|
|
276
|
+
store: Pick<TelegramTopicTargetStore, "getBotState" | "list" | "setBotState">,
|
|
241
277
|
nowMs = Date.now(),
|
|
242
278
|
): boolean {
|
|
243
279
|
const currentCursor = store.getBotState().lastSlot;
|
|
@@ -270,6 +306,7 @@ export interface TelegramTopicTargetStoreOptions {
|
|
|
270
306
|
path: string | (() => string);
|
|
271
307
|
getNowMs?: () => number;
|
|
272
308
|
canPersist?: () => boolean;
|
|
309
|
+
commitPersist?: (commit: () => void) => boolean;
|
|
273
310
|
}
|
|
274
311
|
|
|
275
312
|
export interface TelegramTopicTargetProvisionerDeps {
|
|
@@ -285,7 +322,9 @@ export interface TelegramTopicTargetProvisionerDeps {
|
|
|
285
322
|
| "markStaleByTarget"
|
|
286
323
|
| "allocateSlot"
|
|
287
324
|
| "claimReusableTarget"
|
|
325
|
+
| "listPendingProvisions"
|
|
288
326
|
| "upsertPendingProvision"
|
|
327
|
+
| "recordPendingProvisionTargetRecovery"
|
|
289
328
|
| "removePendingProvision"
|
|
290
329
|
| "persist"
|
|
291
330
|
>;
|
|
@@ -753,6 +792,15 @@ function normalizePendingProvision(
|
|
|
753
792
|
id: record.id,
|
|
754
793
|
owner,
|
|
755
794
|
instanceId: record.instanceId,
|
|
795
|
+
...(typeof record.profileKey === "string"
|
|
796
|
+
? { profileKey: record.profileKey }
|
|
797
|
+
: {}),
|
|
798
|
+
...(record.status === "in-flight" || record.status === "ambiguous"
|
|
799
|
+
? { status: record.status }
|
|
800
|
+
: {}),
|
|
801
|
+
...(typeof record.threadName === "string"
|
|
802
|
+
? { threadName: record.threadName }
|
|
803
|
+
: {}),
|
|
756
804
|
...(typeof record.slot === "string" ? { slot: record.slot } : {}),
|
|
757
805
|
...(target ? { target } : {}),
|
|
758
806
|
startedAtMs: record.startedAtMs,
|
|
@@ -883,7 +931,11 @@ function parseFollowerRecoveryHints(
|
|
|
883
931
|
const hints = new Map<string, { slot?: string; threadName?: string }>();
|
|
884
932
|
if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
|
|
885
933
|
const liveRoster = (value as Record<string, unknown>).liveRoster;
|
|
886
|
-
if (
|
|
934
|
+
if (
|
|
935
|
+
!liveRoster ||
|
|
936
|
+
typeof liveRoster !== "object" ||
|
|
937
|
+
Array.isArray(liveRoster)
|
|
938
|
+
)
|
|
887
939
|
return hints;
|
|
888
940
|
const followers = (liveRoster as Record<string, unknown>).busFollowers;
|
|
889
941
|
if (!Array.isArray(followers)) return hints;
|
|
@@ -892,7 +944,8 @@ function parseFollowerRecoveryHints(
|
|
|
892
944
|
continue;
|
|
893
945
|
const record = follower as Record<string, unknown>;
|
|
894
946
|
const target = record.target;
|
|
895
|
-
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
947
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
948
|
+
continue;
|
|
896
949
|
const targetRecord = target as Record<string, unknown>;
|
|
897
950
|
if (typeof targetRecord.chatId !== "number") continue;
|
|
898
951
|
const normalizedTarget: TelegramTarget = {
|
|
@@ -941,6 +994,7 @@ function isPendingProvisionLiveOrTargeted(
|
|
|
941
994
|
provision: TelegramThreadPendingProvision,
|
|
942
995
|
nowMs: number,
|
|
943
996
|
): boolean {
|
|
997
|
+
if (provision.status === "ambiguous") return true;
|
|
944
998
|
if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
|
|
945
999
|
return true;
|
|
946
1000
|
}
|
|
@@ -964,6 +1018,8 @@ export function createTelegramTopicTargetStore(
|
|
|
964
1018
|
let loaded = false;
|
|
965
1019
|
let loadedPath: string | undefined;
|
|
966
1020
|
let dirty = false;
|
|
1021
|
+
let mutationRevision = 0;
|
|
1022
|
+
let persistQueue: Promise<void> = Promise.resolve();
|
|
967
1023
|
let statusSnapshot: {
|
|
968
1024
|
runtime?: Record<string, unknown>;
|
|
969
1025
|
liveRoster?: Record<string, unknown>;
|
|
@@ -987,6 +1043,21 @@ export function createTelegramTopicTargetStore(
|
|
|
987
1043
|
|
|
988
1044
|
const getPath = () =>
|
|
989
1045
|
typeof options.path === "function" ? options.path() : options.path;
|
|
1046
|
+
const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
|
|
1047
|
+
const readProvisionRecoveries = (
|
|
1048
|
+
path: string,
|
|
1049
|
+
): TelegramProvisionRecoveryFile => {
|
|
1050
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1051
|
+
if (!existsSync(recoveryPath)) return {};
|
|
1052
|
+
try {
|
|
1053
|
+
const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown;
|
|
1054
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1055
|
+
? (value as TelegramProvisionRecoveryFile)
|
|
1056
|
+
: {};
|
|
1057
|
+
} catch {
|
|
1058
|
+
return {};
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
990
1061
|
const resetForPath = (path: string) => {
|
|
991
1062
|
if (loadedPath === path) return;
|
|
992
1063
|
botState = { threadMode: "unknown" };
|
|
@@ -1042,12 +1113,25 @@ export function createTelegramTopicTargetStore(
|
|
|
1042
1113
|
reservation.expiresAtMs > nowMs,
|
|
1043
1114
|
)
|
|
1044
1115
|
.map((reservation) => ({ ...reservation }));
|
|
1116
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1045
1117
|
pendingProvisions = (file.pendingProvisions ?? [])
|
|
1046
1118
|
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
1047
|
-
.map((provision) =>
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1119
|
+
.map((provision) => {
|
|
1120
|
+
const recovery = recoveries[provision.id];
|
|
1121
|
+
const recoveryMatches =
|
|
1122
|
+
recovery?.instanceId === provision.instanceId &&
|
|
1123
|
+
recovery.profileKey === provision.profileKey &&
|
|
1124
|
+
recovery.leaderEpoch === provision.leaderEpoch &&
|
|
1125
|
+
Number.isInteger(recovery.target?.threadId);
|
|
1126
|
+
return {
|
|
1127
|
+
...provision,
|
|
1128
|
+
...(recoveryMatches
|
|
1129
|
+
? { target: { ...recovery.target }, status: "ambiguous" as const }
|
|
1130
|
+
: provision.target
|
|
1131
|
+
? { target: { ...provision.target } }
|
|
1132
|
+
: {}),
|
|
1133
|
+
};
|
|
1134
|
+
});
|
|
1051
1135
|
syncObservations = (file.syncObservations ?? []).map((observation) => ({
|
|
1052
1136
|
...observation,
|
|
1053
1137
|
target: { ...observation.target },
|
|
@@ -1056,69 +1140,98 @@ export function createTelegramTopicTargetStore(
|
|
|
1056
1140
|
dirty = false;
|
|
1057
1141
|
};
|
|
1058
1142
|
|
|
1143
|
+
const markDirty = (): void => {
|
|
1144
|
+
loaded = true;
|
|
1145
|
+
dirty = true;
|
|
1146
|
+
mutationRevision += 1;
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1059
1149
|
return {
|
|
1060
1150
|
async load() {
|
|
1061
1151
|
if (dirty) return;
|
|
1062
1152
|
await loadFromDisk();
|
|
1063
1153
|
},
|
|
1064
|
-
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
(
|
|
1077
|
-
reservation
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
...
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1154
|
+
persist() {
|
|
1155
|
+
const persist = persistQueue.then(async () => {
|
|
1156
|
+
const path = getPath();
|
|
1157
|
+
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
1158
|
+
if (options.canPersist && !options.canPersist()) {
|
|
1159
|
+
await loadFromDisk();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (!dirty || !loaded) await loadFromDisk();
|
|
1163
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1164
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1165
|
+
const nowMs = getNowMs();
|
|
1166
|
+
reservations = reservations.filter(
|
|
1167
|
+
(reservation) =>
|
|
1168
|
+
reservation.expiresAtMs === undefined ||
|
|
1169
|
+
reservation.expiresAtMs > nowMs,
|
|
1170
|
+
);
|
|
1171
|
+
pendingProvisions = pendingProvisions.filter((provision) =>
|
|
1172
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
1173
|
+
);
|
|
1174
|
+
const currentRecords = Array.from(records.values())
|
|
1175
|
+
.filter(isCurrentThreadRecord)
|
|
1176
|
+
.map(cloneRecord);
|
|
1177
|
+
records = new Map(
|
|
1178
|
+
currentRecords.map((record) => [
|
|
1179
|
+
getRecordOwnerKey(record),
|
|
1180
|
+
cloneRecord(record),
|
|
1181
|
+
]),
|
|
1182
|
+
);
|
|
1183
|
+
const persistedRevision = mutationRevision;
|
|
1184
|
+
const file = {
|
|
1185
|
+
version: 1,
|
|
1186
|
+
source: "snapshot",
|
|
1187
|
+
writtenAtMs: nowMs,
|
|
1188
|
+
bot: botState,
|
|
1189
|
+
...statusSnapshot,
|
|
1190
|
+
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
1191
|
+
reservations: reservations.map((reservation) => ({ ...reservation })),
|
|
1192
|
+
pendingProvisions: pendingProvisions.map((provision) => ({
|
|
1193
|
+
...provision,
|
|
1194
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1195
|
+
})),
|
|
1196
|
+
syncObservations: syncObservations.map((observation) => ({
|
|
1197
|
+
...observation,
|
|
1198
|
+
target: { ...observation.target },
|
|
1199
|
+
})),
|
|
1200
|
+
threads: currentRecords.map((record) => {
|
|
1201
|
+
const { profileKey: _profileKey, ...serialized } = record;
|
|
1202
|
+
return serialized;
|
|
1203
|
+
}),
|
|
1204
|
+
};
|
|
1205
|
+
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1206
|
+
encoding: "utf8",
|
|
1207
|
+
mode: 0o600,
|
|
1208
|
+
});
|
|
1209
|
+
await chmod(tempPath, 0o600);
|
|
1210
|
+
try {
|
|
1211
|
+
if (options.commitPersist) {
|
|
1212
|
+
const committed = options.commitPersist(() => {
|
|
1213
|
+
renameSync(tempPath, path);
|
|
1214
|
+
chmodSync(path, 0o600);
|
|
1215
|
+
});
|
|
1216
|
+
if (!committed) {
|
|
1217
|
+
await loadFromDisk();
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
"Telegram thread snapshot lost exact transport ownership before commit.",
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
} else {
|
|
1223
|
+
await rename(tempPath, path);
|
|
1224
|
+
await chmod(path, 0o600);
|
|
1225
|
+
}
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
await unlink(tempPath).catch(() => undefined);
|
|
1228
|
+
throw error;
|
|
1229
|
+
}
|
|
1230
|
+
loaded = true;
|
|
1231
|
+
if (mutationRevision === persistedRevision) dirty = false;
|
|
1116
1232
|
});
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
await chmod(path, 0o600);
|
|
1120
|
-
loaded = true;
|
|
1121
|
-
dirty = false;
|
|
1233
|
+
persistQueue = persist.catch(() => undefined);
|
|
1234
|
+
return persist;
|
|
1122
1235
|
},
|
|
1123
1236
|
list() {
|
|
1124
1237
|
return Array.from(records.values()).map(cloneRecord);
|
|
@@ -1162,8 +1275,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1162
1275
|
!targetMatches(existing.target, next.target),
|
|
1163
1276
|
);
|
|
1164
1277
|
reservations.push(next);
|
|
1165
|
-
|
|
1166
|
-
dirty = true;
|
|
1278
|
+
markDirty();
|
|
1167
1279
|
},
|
|
1168
1280
|
upsertPendingProvision(provision) {
|
|
1169
1281
|
const next = {
|
|
@@ -1174,8 +1286,41 @@ export function createTelegramTopicTargetStore(
|
|
|
1174
1286
|
(existing) => existing.id !== next.id,
|
|
1175
1287
|
);
|
|
1176
1288
|
pendingProvisions.push(next);
|
|
1177
|
-
|
|
1178
|
-
|
|
1289
|
+
markDirty();
|
|
1290
|
+
},
|
|
1291
|
+
async recordPendingProvisionTargetRecovery(provision, target) {
|
|
1292
|
+
const path = getPath();
|
|
1293
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1294
|
+
await mkdir(dirname(recoveryPath), { recursive: true });
|
|
1295
|
+
withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
|
|
1296
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1297
|
+
recoveries[provision.id] = {
|
|
1298
|
+
instanceId: provision.instanceId,
|
|
1299
|
+
...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
|
|
1300
|
+
...(provision.leaderEpoch !== undefined
|
|
1301
|
+
? { leaderEpoch: provision.leaderEpoch }
|
|
1302
|
+
: {}),
|
|
1303
|
+
target: { ...target },
|
|
1304
|
+
};
|
|
1305
|
+
const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1306
|
+
writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
|
|
1307
|
+
encoding: "utf8",
|
|
1308
|
+
mode: 0o600,
|
|
1309
|
+
});
|
|
1310
|
+
renameSync(tempPath, recoveryPath);
|
|
1311
|
+
chmodSync(recoveryPath, 0o600);
|
|
1312
|
+
});
|
|
1313
|
+
const current = pendingProvisions.find(
|
|
1314
|
+
(entry) =>
|
|
1315
|
+
entry.id === provision.id &&
|
|
1316
|
+
entry.instanceId === provision.instanceId &&
|
|
1317
|
+
entry.profileKey === provision.profileKey &&
|
|
1318
|
+
entry.leaderEpoch === provision.leaderEpoch,
|
|
1319
|
+
);
|
|
1320
|
+
if (!current) return false;
|
|
1321
|
+
current.target = { ...target };
|
|
1322
|
+
current.status = "ambiguous";
|
|
1323
|
+
return true;
|
|
1179
1324
|
},
|
|
1180
1325
|
removePendingProvision(id) {
|
|
1181
1326
|
const before = pendingProvisions.length;
|
|
@@ -1183,10 +1328,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1183
1328
|
(provision) => provision.id !== id,
|
|
1184
1329
|
);
|
|
1185
1330
|
const changed = pendingProvisions.length !== before;
|
|
1186
|
-
if (changed)
|
|
1187
|
-
loaded = true;
|
|
1188
|
-
dirty = true;
|
|
1189
|
-
}
|
|
1331
|
+
if (changed) markDirty();
|
|
1190
1332
|
return changed;
|
|
1191
1333
|
},
|
|
1192
1334
|
removeReservationByTarget(target) {
|
|
@@ -1195,10 +1337,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1195
1337
|
(reservation) => !targetMatches(reservation.target, target),
|
|
1196
1338
|
);
|
|
1197
1339
|
const changed = reservations.length !== before;
|
|
1198
|
-
if (changed)
|
|
1199
|
-
loaded = true;
|
|
1200
|
-
dirty = true;
|
|
1201
|
-
}
|
|
1340
|
+
if (changed) markDirty();
|
|
1202
1341
|
return changed;
|
|
1203
1342
|
},
|
|
1204
1343
|
getBotState() {
|
|
@@ -1208,8 +1347,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1208
1347
|
},
|
|
1209
1348
|
setBotState(state) {
|
|
1210
1349
|
botState = { ...botState, ...state };
|
|
1211
|
-
|
|
1212
|
-
dirty = true;
|
|
1350
|
+
markDirty();
|
|
1213
1351
|
},
|
|
1214
1352
|
setStatusSnapshot(snapshot) {
|
|
1215
1353
|
if (!loadedPath) loadedPath = getPath();
|
|
@@ -1245,8 +1383,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1245
1383
|
const removedOwner = identities.delete(ownerKey);
|
|
1246
1384
|
const removedProfile = identities.delete(profileKey);
|
|
1247
1385
|
if (!removedOwner && !removedProfile) return false;
|
|
1248
|
-
|
|
1249
|
-
dirty = true;
|
|
1386
|
+
markDirty();
|
|
1250
1387
|
return true;
|
|
1251
1388
|
},
|
|
1252
1389
|
upsert(record) {
|
|
@@ -1277,8 +1414,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1277
1414
|
if (!isCurrentThreadRecord(next)) {
|
|
1278
1415
|
rememberIdentity(next);
|
|
1279
1416
|
records.delete(nextOwnerKey);
|
|
1280
|
-
|
|
1281
|
-
dirty = true;
|
|
1417
|
+
markDirty();
|
|
1282
1418
|
return cloneRecord(next);
|
|
1283
1419
|
}
|
|
1284
1420
|
records.set(nextOwnerKey, next);
|
|
@@ -1289,8 +1425,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1289
1425
|
rememberSlot(next.slot, next.updatedAtMs);
|
|
1290
1426
|
}
|
|
1291
1427
|
rememberIdentity(next);
|
|
1292
|
-
|
|
1293
|
-
dirty = true;
|
|
1428
|
+
markDirty();
|
|
1294
1429
|
return cloneRecord(next);
|
|
1295
1430
|
},
|
|
1296
1431
|
markOfflineByInstanceId(instanceId) {
|
|
@@ -1304,10 +1439,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1304
1439
|
records.delete(getRecordOwnerKey(record));
|
|
1305
1440
|
count += 1;
|
|
1306
1441
|
}
|
|
1307
|
-
if (count > 0)
|
|
1308
|
-
loaded = true;
|
|
1309
|
-
dirty = true;
|
|
1310
|
-
}
|
|
1442
|
+
if (count > 0) markDirty();
|
|
1311
1443
|
return count;
|
|
1312
1444
|
},
|
|
1313
1445
|
markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
|
|
@@ -1328,8 +1460,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1328
1460
|
});
|
|
1329
1461
|
rememberIdentity(record);
|
|
1330
1462
|
records.delete(getRecordOwnerKey(record));
|
|
1331
|
-
|
|
1332
|
-
dirty = true;
|
|
1463
|
+
markDirty();
|
|
1333
1464
|
return true;
|
|
1334
1465
|
}
|
|
1335
1466
|
return false;
|
|
@@ -1345,8 +1476,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1345
1476
|
record.lastReconcileAction = "mark-active";
|
|
1346
1477
|
delete record.lastError;
|
|
1347
1478
|
delete record.lastSyncError;
|
|
1348
|
-
|
|
1349
|
-
dirty = true;
|
|
1479
|
+
markDirty();
|
|
1350
1480
|
return true;
|
|
1351
1481
|
}
|
|
1352
1482
|
return false;
|
|
@@ -1361,8 +1491,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1361
1491
|
record.threadName = normalizedThreadName;
|
|
1362
1492
|
record.updatedAtMs = nowMs;
|
|
1363
1493
|
rememberIdentity(record);
|
|
1364
|
-
|
|
1365
|
-
dirty = true;
|
|
1494
|
+
markDirty();
|
|
1366
1495
|
return cloneRecord(record);
|
|
1367
1496
|
}
|
|
1368
1497
|
return undefined;
|
|
@@ -1401,8 +1530,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1401
1530
|
record.threadName = threadName;
|
|
1402
1531
|
delete record.lastError;
|
|
1403
1532
|
rememberIdentity(record);
|
|
1404
|
-
|
|
1405
|
-
dirty = true;
|
|
1533
|
+
markDirty();
|
|
1406
1534
|
return cloneRecord(record);
|
|
1407
1535
|
},
|
|
1408
1536
|
allocateSlot(profileKey, preferredSlot) {
|
|
@@ -1456,7 +1584,11 @@ function isTelegramTopicTargetSlotOccupied(
|
|
|
1456
1584
|
if (reservation.slot === slot) return true;
|
|
1457
1585
|
}
|
|
1458
1586
|
for (const provision of pendingProvisions) {
|
|
1459
|
-
if (
|
|
1587
|
+
if (
|
|
1588
|
+
provision.status !== "ambiguous" &&
|
|
1589
|
+
provision.expiresAtMs !== undefined &&
|
|
1590
|
+
provision.expiresAtMs <= nowMs
|
|
1591
|
+
)
|
|
1460
1592
|
continue;
|
|
1461
1593
|
if (provision.slot === slot) return true;
|
|
1462
1594
|
}
|
|
@@ -1945,6 +2077,26 @@ export async function provisionOwnBusTopic(
|
|
|
1945
2077
|
threadId: record.target.threadId,
|
|
1946
2078
|
slot: record.slot,
|
|
1947
2079
|
});
|
|
2080
|
+
if (
|
|
2081
|
+
deps.getCurrentLeaderEpoch &&
|
|
2082
|
+
(action.leaderEpoch === undefined ||
|
|
2083
|
+
deps.getCurrentLeaderEpoch() !== action.leaderEpoch)
|
|
2084
|
+
) {
|
|
2085
|
+
deps.recordEvent(
|
|
2086
|
+
"bus",
|
|
2087
|
+
"Skipped previous-topic local cleanup after leader epoch loss",
|
|
2088
|
+
{
|
|
2089
|
+
phase: "leader-topic-previous-cleanup-stale-epoch-skip",
|
|
2090
|
+
actionLeaderEpoch: action.leaderEpoch,
|
|
2091
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch(),
|
|
2092
|
+
chatId: record.target.chatId,
|
|
2093
|
+
threadId: record.target.threadId,
|
|
2094
|
+
},
|
|
2095
|
+
);
|
|
2096
|
+
throw new Error(
|
|
2097
|
+
"Telegram leader ownership changed during topic reconciliation.",
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
1948
2100
|
deps.store.markStaleByTarget(record.target);
|
|
1949
2101
|
deps.store.reserveThread({
|
|
1950
2102
|
target: record.target,
|
|
@@ -2048,7 +2200,9 @@ export function resolveTelegramInstanceThreadIdentity(options: {
|
|
|
2048
2200
|
) => {
|
|
2049
2201
|
if (!candidate) return false;
|
|
2050
2202
|
if (!options.target) return true;
|
|
2051
|
-
return
|
|
2203
|
+
return (
|
|
2204
|
+
!!candidate.target && targetMatches(candidate.target, options.target)
|
|
2205
|
+
);
|
|
2052
2206
|
};
|
|
2053
2207
|
const local = targetMatchesCandidate(options.follower)
|
|
2054
2208
|
? options.follower
|
|
@@ -2061,18 +2215,95 @@ export function resolveTelegramInstanceThreadIdentity(options: {
|
|
|
2061
2215
|
? options.record
|
|
2062
2216
|
: undefined;
|
|
2063
2217
|
return {
|
|
2064
|
-
...(local?.target ?? record?.target
|
|
2218
|
+
...((local?.target ?? record?.target)
|
|
2065
2219
|
? { target: local?.target ?? record?.target }
|
|
2066
2220
|
: {}),
|
|
2067
|
-
...(local?.slot ?? record?.slot
|
|
2221
|
+
...((local?.slot ?? record?.slot)
|
|
2068
2222
|
? { slot: local?.slot ?? record?.slot }
|
|
2069
2223
|
: {}),
|
|
2070
|
-
...(local?.threadName ?? record?.threadName
|
|
2224
|
+
...((local?.threadName ?? record?.threadName)
|
|
2071
2225
|
? { threadName: local?.threadName ?? record?.threadName }
|
|
2072
2226
|
: {}),
|
|
2073
2227
|
};
|
|
2074
2228
|
}
|
|
2075
2229
|
|
|
2230
|
+
export interface TelegramLeaderThreadStateRuntime {
|
|
2231
|
+
getTarget(): TelegramTarget | undefined;
|
|
2232
|
+
getIdentity(): TelegramInstanceThreadIdentityCandidate | undefined;
|
|
2233
|
+
set(
|
|
2234
|
+
input: TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget },
|
|
2235
|
+
): void;
|
|
2236
|
+
clear(): void;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
export function createTelegramLeaderThreadStateRuntime(): TelegramLeaderThreadStateRuntime {
|
|
2240
|
+
let identity:
|
|
2241
|
+
| (TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget })
|
|
2242
|
+
| undefined;
|
|
2243
|
+
return {
|
|
2244
|
+
getTarget: () => identity?.target,
|
|
2245
|
+
getIdentity: () => identity,
|
|
2246
|
+
set(input) {
|
|
2247
|
+
identity = { ...input, target: { ...input.target } };
|
|
2248
|
+
},
|
|
2249
|
+
clear() {
|
|
2250
|
+
identity = undefined;
|
|
2251
|
+
},
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
export interface TelegramCurrentInstanceThreadRuntime {
|
|
2256
|
+
findRecord(): TelegramTopicTargetRecord | undefined;
|
|
2257
|
+
getRecord(): TelegramTopicTargetRecord | undefined;
|
|
2258
|
+
getIdentity(target?: TelegramTarget): TelegramInstanceThreadIdentityCandidate;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
export function createTelegramCurrentInstanceThreadRuntime(deps: {
|
|
2262
|
+
instanceId: string;
|
|
2263
|
+
listRecords(): readonly TelegramTopicTargetRecord[];
|
|
2264
|
+
getPreferredTarget(): TelegramTarget | undefined;
|
|
2265
|
+
getFollower():
|
|
2266
|
+
| (TelegramInstanceThreadIdentityCandidate & { registered: boolean })
|
|
2267
|
+
| undefined;
|
|
2268
|
+
getLeader(): TelegramInstanceThreadIdentityCandidate | undefined;
|
|
2269
|
+
}): TelegramCurrentInstanceThreadRuntime {
|
|
2270
|
+
const findRecord = function (): TelegramTopicTargetRecord | undefined {
|
|
2271
|
+
return findCurrentTelegramInstanceThreadRecord({
|
|
2272
|
+
records: deps.listRecords(),
|
|
2273
|
+
instanceId: deps.instanceId,
|
|
2274
|
+
preferredTarget: deps.getPreferredTarget(),
|
|
2275
|
+
});
|
|
2276
|
+
};
|
|
2277
|
+
const getRecord = function (): TelegramTopicTargetRecord | undefined {
|
|
2278
|
+
const record = findRecord();
|
|
2279
|
+
const follower = deps.getFollower();
|
|
2280
|
+
if (record?.owner?.kind === "manual-follower" && !follower?.registered) {
|
|
2281
|
+
return undefined;
|
|
2282
|
+
}
|
|
2283
|
+
return record;
|
|
2284
|
+
};
|
|
2285
|
+
return {
|
|
2286
|
+
findRecord,
|
|
2287
|
+
getRecord,
|
|
2288
|
+
getIdentity(target) {
|
|
2289
|
+
const follower = deps.getFollower();
|
|
2290
|
+
const record = target
|
|
2291
|
+
? findCurrentTelegramInstanceThreadRecord({
|
|
2292
|
+
records: deps.listRecords(),
|
|
2293
|
+
instanceId: deps.instanceId,
|
|
2294
|
+
preferredTarget: target,
|
|
2295
|
+
})
|
|
2296
|
+
: getRecord();
|
|
2297
|
+
return resolveTelegramInstanceThreadIdentity({
|
|
2298
|
+
target,
|
|
2299
|
+
follower: follower?.registered ? follower : undefined,
|
|
2300
|
+
leader: deps.getLeader(),
|
|
2301
|
+
record,
|
|
2302
|
+
});
|
|
2303
|
+
},
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2076
2307
|
export function findCurrentTelegramInstanceThreadRecord(options: {
|
|
2077
2308
|
records: readonly TelegramTopicTargetRecord[];
|
|
2078
2309
|
instanceId: string;
|
|
@@ -2111,6 +2342,88 @@ export function resolveTelegramInstanceThreadTarget(options: {
|
|
|
2111
2342
|
: undefined;
|
|
2112
2343
|
}
|
|
2113
2344
|
|
|
2345
|
+
export interface TelegramThreadStatusProjectionRuntime {
|
|
2346
|
+
getBusRole(): "leader" | "follower" | undefined;
|
|
2347
|
+
getBusFollowers(): ReturnType<typeof listTelegramThreadStatusFollowers>;
|
|
2348
|
+
getLocalBus(): {
|
|
2349
|
+
leaderSocketPath: string;
|
|
2350
|
+
leaderTransport: "socket" | "pipe";
|
|
2351
|
+
followerSocketPath: string;
|
|
2352
|
+
followerTransport: "socket" | "pipe";
|
|
2353
|
+
followerRegistered: boolean;
|
|
2354
|
+
followerTarget?: TelegramTarget;
|
|
2355
|
+
followerSlot?: string;
|
|
2356
|
+
followerThreadName?: string;
|
|
2357
|
+
};
|
|
2358
|
+
getTopicTargets(): ReturnType<typeof listTelegramThreadStatusTargets>;
|
|
2359
|
+
getThreadReservations(): ReturnType<
|
|
2360
|
+
typeof listTelegramThreadStatusReservations
|
|
2361
|
+
>;
|
|
2362
|
+
getTopicSyncObservations(): ReturnType<
|
|
2363
|
+
typeof listTelegramThreadStatusObservations
|
|
2364
|
+
>;
|
|
2365
|
+
getInstanceSlot(): string | undefined;
|
|
2366
|
+
getInstanceThreadName(): string | undefined;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
export function createTelegramThreadStatusProjectionRuntime(deps: {
|
|
2370
|
+
getThreadMode(): "unknown" | "enabled" | "disabled";
|
|
2371
|
+
isBusPollingStarted(): boolean;
|
|
2372
|
+
isFollowerRegistered(): boolean;
|
|
2373
|
+
listFollowers(): readonly TelegramThreadStatusFollowerView[];
|
|
2374
|
+
listRecords(): readonly TelegramTopicTargetRecord[];
|
|
2375
|
+
listReservations(): readonly TelegramThreadReservation[];
|
|
2376
|
+
listSyncObservations(): readonly TelegramTopicSyncObservation[];
|
|
2377
|
+
getLeaderSocketPath(): string;
|
|
2378
|
+
getFollowerSocketPath(): string;
|
|
2379
|
+
getTransportKind(path: string): "socket" | "pipe";
|
|
2380
|
+
getFollowerTarget(): TelegramTarget | undefined;
|
|
2381
|
+
getFollowerSlot(): string | undefined;
|
|
2382
|
+
getFollowerThreadName(): string | undefined;
|
|
2383
|
+
getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
|
|
2384
|
+
}): TelegramThreadStatusProjectionRuntime {
|
|
2385
|
+
return {
|
|
2386
|
+
getBusRole() {
|
|
2387
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2388
|
+
if (deps.isBusPollingStarted()) return "leader";
|
|
2389
|
+
return deps.isFollowerRegistered() ? "follower" : undefined;
|
|
2390
|
+
},
|
|
2391
|
+
getBusFollowers() {
|
|
2392
|
+
return listTelegramThreadStatusFollowers({
|
|
2393
|
+
followers: deps.listFollowers(),
|
|
2394
|
+
records: deps.listRecords(),
|
|
2395
|
+
});
|
|
2396
|
+
},
|
|
2397
|
+
getLocalBus() {
|
|
2398
|
+
const leaderSocketPath = deps.getLeaderSocketPath();
|
|
2399
|
+
const followerSocketPath = deps.getFollowerSocketPath();
|
|
2400
|
+
return {
|
|
2401
|
+
leaderSocketPath,
|
|
2402
|
+
leaderTransport: deps.getTransportKind(leaderSocketPath),
|
|
2403
|
+
followerSocketPath,
|
|
2404
|
+
followerTransport: deps.getTransportKind(followerSocketPath),
|
|
2405
|
+
followerRegistered: deps.isFollowerRegistered(),
|
|
2406
|
+
followerTarget: deps.getFollowerTarget(),
|
|
2407
|
+
followerSlot: deps.getFollowerSlot(),
|
|
2408
|
+
followerThreadName: deps.getFollowerThreadName(),
|
|
2409
|
+
};
|
|
2410
|
+
},
|
|
2411
|
+
getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()),
|
|
2412
|
+
getThreadReservations: () =>
|
|
2413
|
+
listTelegramThreadStatusReservations(deps.listReservations()),
|
|
2414
|
+
getTopicSyncObservations: () =>
|
|
2415
|
+
listTelegramThreadStatusObservations(deps.listSyncObservations()),
|
|
2416
|
+
getInstanceSlot() {
|
|
2417
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2418
|
+
return deps.getCurrentIdentity().slot;
|
|
2419
|
+
},
|
|
2420
|
+
getInstanceThreadName() {
|
|
2421
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2422
|
+
return deps.getCurrentIdentity().threadName;
|
|
2423
|
+
},
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2114
2427
|
export interface TelegramThreadStatusFollowerView {
|
|
2115
2428
|
instanceId: string;
|
|
2116
2429
|
cwd?: string;
|
|
@@ -2330,10 +2643,27 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2330
2643
|
const getNowMs = deps.getNowMs ?? (() => 0);
|
|
2331
2644
|
const getRandom = deps.getRandom;
|
|
2332
2645
|
return async (request) => {
|
|
2646
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
2647
|
+
const assertLeaderEpoch = (phase: string): void => {
|
|
2648
|
+
if (
|
|
2649
|
+
deps.getCurrentLeaderEpoch &&
|
|
2650
|
+
(leaderEpoch === undefined ||
|
|
2651
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch)
|
|
2652
|
+
) {
|
|
2653
|
+
throw new Error(
|
|
2654
|
+
`Telegram topic provisioning lost leader ownership (${phase}).`,
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
};
|
|
2658
|
+
assertLeaderEpoch("start");
|
|
2333
2659
|
normalizeCurrentThreadNameSlots(deps.store);
|
|
2334
2660
|
let existing = deps.store.getByProfileKey(request.profileKey);
|
|
2335
2661
|
const isManualFollowerRequest = request.owner?.kind === "manual-follower";
|
|
2336
|
-
if (
|
|
2662
|
+
if (
|
|
2663
|
+
isManualFollowerRequest &&
|
|
2664
|
+
existing &&
|
|
2665
|
+
isCurrentThreadRecord(existing)
|
|
2666
|
+
) {
|
|
2337
2667
|
deps.store.markStaleByTarget(
|
|
2338
2668
|
existing.target,
|
|
2339
2669
|
"unknown",
|
|
@@ -2342,9 +2672,10 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2342
2672
|
deps.store.forgetIdentityByProfileKey(request.profileKey);
|
|
2343
2673
|
existing = undefined;
|
|
2344
2674
|
}
|
|
2345
|
-
const identity =
|
|
2346
|
-
|
|
2347
|
-
|
|
2675
|
+
const identity =
|
|
2676
|
+
isManualFollowerRequest && !existing
|
|
2677
|
+
? undefined
|
|
2678
|
+
: deps.store.getIdentityByProfileKey(request.profileKey);
|
|
2348
2679
|
const nowMs = getNowMs();
|
|
2349
2680
|
if (existing && isCurrentThreadRecord(existing)) {
|
|
2350
2681
|
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
@@ -2371,6 +2702,35 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2371
2702
|
});
|
|
2372
2703
|
return { target: record.target, reused: true, record };
|
|
2373
2704
|
}
|
|
2705
|
+
const pendingForRequest = deps.store
|
|
2706
|
+
.listPendingProvisions()
|
|
2707
|
+
.find(
|
|
2708
|
+
(pending) =>
|
|
2709
|
+
pending.profileKey === request.profileKey ||
|
|
2710
|
+
pending.instanceId === request.instanceId,
|
|
2711
|
+
);
|
|
2712
|
+
if (pendingForRequest?.target) {
|
|
2713
|
+
const record = deps.store.upsert({
|
|
2714
|
+
profileKey: request.profileKey,
|
|
2715
|
+
owner: request.owner,
|
|
2716
|
+
target: pendingForRequest.target,
|
|
2717
|
+
status: "active",
|
|
2718
|
+
createdAtMs: pendingForRequest.startedAtMs,
|
|
2719
|
+
updatedAtMs: nowMs,
|
|
2720
|
+
threadName: pendingForRequest.threadName,
|
|
2721
|
+
instanceId: request.instanceId,
|
|
2722
|
+
slot: pendingForRequest.slot,
|
|
2723
|
+
});
|
|
2724
|
+
deps.store.removePendingProvision(pendingForRequest.id);
|
|
2725
|
+
await deps.store.persist();
|
|
2726
|
+
assertLeaderEpoch("after-recovered-binding");
|
|
2727
|
+
return { target: record.target, reused: true, record };
|
|
2728
|
+
}
|
|
2729
|
+
if (pendingForRequest) {
|
|
2730
|
+
throw new Error(
|
|
2731
|
+
`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`,
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2374
2734
|
const activeForInstance = deps.store.getActiveByInstanceId(
|
|
2375
2735
|
request.instanceId,
|
|
2376
2736
|
);
|
|
@@ -2403,7 +2763,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2403
2763
|
request.profileKey,
|
|
2404
2764
|
isManualFollowerRequest
|
|
2405
2765
|
? request.preferredSlot
|
|
2406
|
-
: request.preferredSlot ?? preferredNameSlot,
|
|
2766
|
+
: (request.preferredSlot ?? preferredNameSlot),
|
|
2407
2767
|
);
|
|
2408
2768
|
const requestThreadName =
|
|
2409
2769
|
candidateThreadName &&
|
|
@@ -2413,20 +2773,23 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2413
2773
|
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
2414
2774
|
const pendingOwner =
|
|
2415
2775
|
request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
2416
|
-
|
|
2776
|
+
assertLeaderEpoch("before-pending-intent");
|
|
2417
2777
|
const pendingBase: TelegramThreadPendingProvision = {
|
|
2418
2778
|
id: pendingId,
|
|
2419
2779
|
owner: pendingOwner,
|
|
2420
2780
|
instanceId: request.instanceId,
|
|
2781
|
+
profileKey: request.profileKey,
|
|
2782
|
+
threadName: requestThreadName,
|
|
2421
2783
|
slot,
|
|
2422
2784
|
startedAtMs: nowMs,
|
|
2423
|
-
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
2424
2785
|
...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
|
|
2425
2786
|
};
|
|
2426
2787
|
deps.store.upsertPendingProvision(pendingBase);
|
|
2427
2788
|
await deps.store.persist();
|
|
2789
|
+
assertLeaderEpoch("after-pending-intent");
|
|
2428
2790
|
let threadId: number | undefined;
|
|
2429
2791
|
try {
|
|
2792
|
+
assertLeaderEpoch("before-createForumTopic");
|
|
2430
2793
|
const topic = await deps.callApi<TelegramTopicResult>(
|
|
2431
2794
|
"createForumTopic",
|
|
2432
2795
|
{
|
|
@@ -2445,13 +2808,16 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2445
2808
|
);
|
|
2446
2809
|
threadId = topic.message_thread_id;
|
|
2447
2810
|
if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
|
|
2448
|
-
throw new
|
|
2449
|
-
"
|
|
2811
|
+
throw new TelegramApiCommitUnknownError(
|
|
2812
|
+
"createForumTopic",
|
|
2813
|
+
new Error("Telegram createForumTopic returned no message_thread_id."),
|
|
2450
2814
|
);
|
|
2451
2815
|
}
|
|
2816
|
+
assertLeaderEpoch("after-createForumTopic");
|
|
2452
2817
|
const target = { chatId: deps.topicChatId, threadId };
|
|
2453
2818
|
deps.store.upsertPendingProvision({ ...pendingBase, target });
|
|
2454
2819
|
await deps.store.persist();
|
|
2820
|
+
assertLeaderEpoch("after-pending-target");
|
|
2455
2821
|
deps.store.upsert({
|
|
2456
2822
|
profileKey: request.profileKey,
|
|
2457
2823
|
owner: request.owner,
|
|
@@ -2464,6 +2830,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2464
2830
|
slot,
|
|
2465
2831
|
});
|
|
2466
2832
|
await deps.store.persist();
|
|
2833
|
+
assertLeaderEpoch("after-starting-binding");
|
|
2467
2834
|
const record = deps.store.upsert({
|
|
2468
2835
|
profileKey: request.profileKey,
|
|
2469
2836
|
owner: request.owner,
|
|
@@ -2477,10 +2844,30 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2477
2844
|
});
|
|
2478
2845
|
deps.store.removePendingProvision(pendingId);
|
|
2479
2846
|
await deps.store.persist();
|
|
2847
|
+
assertLeaderEpoch("after-active-binding");
|
|
2480
2848
|
return { target: record.target, reused: false, record };
|
|
2481
2849
|
} catch (error) {
|
|
2850
|
+
if (
|
|
2851
|
+
threadId !== undefined &&
|
|
2852
|
+
deps.getCurrentLeaderEpoch &&
|
|
2853
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch
|
|
2854
|
+
) {
|
|
2855
|
+
await deps.store.recordPendingProvisionTargetRecovery(pendingBase, {
|
|
2856
|
+
chatId: deps.topicChatId,
|
|
2857
|
+
threadId,
|
|
2858
|
+
});
|
|
2859
|
+
throw error;
|
|
2860
|
+
}
|
|
2861
|
+
assertLeaderEpoch("failure-cleanup");
|
|
2482
2862
|
if (threadId === undefined) {
|
|
2483
|
-
|
|
2863
|
+
if (isTelegramApiCommitUnknownError(error)) {
|
|
2864
|
+
deps.store.upsertPendingProvision({
|
|
2865
|
+
...pendingBase,
|
|
2866
|
+
status: "ambiguous",
|
|
2867
|
+
});
|
|
2868
|
+
} else {
|
|
2869
|
+
deps.store.removePendingProvision(pendingId);
|
|
2870
|
+
}
|
|
2484
2871
|
await deps.store.persist();
|
|
2485
2872
|
} else {
|
|
2486
2873
|
try {
|