@llblab/pi-telegram 0.17.5 → 0.18.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 +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +36 -15
- package/README.md +63 -35
- package/docs/README.md +3 -1
- package/docs/architecture.md +55 -23
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound.md +1 -1
- package/docs/locks.md +0 -2
- package/docs/multi-instance-bus.md +483 -0
- package/docs/outbound.md +4 -3
- package/docs/public-api.md +12 -10
- package/docs/sections.md +2 -2
- package/docs/ui-style.md +76 -0
- package/index.ts +789 -32
- package/lib/bindings.ts +68 -12
- package/lib/bus-api.ts +314 -0
- package/lib/bus-follower.ts +853 -0
- package/lib/bus-leader.ts +915 -0
- package/lib/bus.ts +866 -0
- package/lib/command-templates.ts +9 -11
- package/lib/commands.ts +133 -47
- package/lib/config.ts +53 -5
- package/lib/lifecycle.ts +23 -7
- package/lib/locks.ts +230 -66
- package/lib/media.ts +30 -2
- package/lib/menu-model.ts +48 -17
- package/lib/menu-queue.ts +51 -20
- package/lib/menu-settings.ts +9 -5
- package/lib/menu-status.ts +3 -0
- package/lib/menu-thinking.ts +3 -0
- package/lib/menu.ts +67 -26
- package/lib/outbound-attachments.ts +102 -17
- package/lib/outbound-buttons.ts +6 -2
- package/lib/outbound-voice.ts +31 -11
- package/lib/outbound.ts +6 -4
- package/lib/ownership.ts +119 -0
- package/lib/pi.ts +26 -3
- package/lib/polling.ts +477 -7
- package/lib/preview.ts +141 -88
- package/lib/prompt-templates.ts +3 -3
- package/lib/prompts.ts +80 -30
- package/lib/queue.ts +193 -91
- package/lib/rendering.ts +0 -25
- package/lib/replies.ts +187 -55
- package/lib/routing.ts +1673 -9
- package/lib/runtime-log.ts +123 -0
- package/lib/runtime.ts +84 -12
- package/lib/sections.ts +28 -21
- package/lib/setup.ts +1 -1
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +405 -40
- package/lib/text-groups.ts +5 -1
- package/lib/thread-reconciler.ts +915 -0
- package/lib/threads.ts +2205 -0
- package/lib/turns.ts +48 -3
- package/lib/updates.ts +355 -32
- package/package.json +24 -2
- package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/threads.ts
ADDED
|
@@ -0,0 +1,2205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram thread binding helpers
|
|
3
|
+
* Zones: multi-instance bus, Telegram UI threads, volatile extension state
|
|
4
|
+
* Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import type { TelegramTarget } from "./target.ts";
|
|
14
|
+
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
15
|
+
|
|
16
|
+
export interface TelegramThreadNameInput {
|
|
17
|
+
seed: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
role?: "leader" | "follower";
|
|
20
|
+
peers?: readonly string[];
|
|
21
|
+
slot?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TelegramTopicTargetStatus =
|
|
25
|
+
| "active"
|
|
26
|
+
| "offline"
|
|
27
|
+
| "stale"
|
|
28
|
+
| "pending"
|
|
29
|
+
| "starting"
|
|
30
|
+
| "failed";
|
|
31
|
+
|
|
32
|
+
export type TelegramTopicSyncStatus = "open" | "closed" | "deleted" | "unknown";
|
|
33
|
+
|
|
34
|
+
export type TelegramThreadOwner =
|
|
35
|
+
| { kind: "leader"; cwd?: string; instanceId?: string }
|
|
36
|
+
| { kind: "manual-follower"; instanceId: string }
|
|
37
|
+
| { kind: "pending-topic"; chatId: number; threadId: number }
|
|
38
|
+
| { kind: "legacy"; key: string };
|
|
39
|
+
|
|
40
|
+
const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
export interface TelegramThreadReservation {
|
|
43
|
+
target: TelegramTarget & { threadId: number };
|
|
44
|
+
slot: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
createdAtMs: number;
|
|
47
|
+
updatedAtMs: number;
|
|
48
|
+
expiresAtMs?: number;
|
|
49
|
+
instanceId?: string;
|
|
50
|
+
lastReconcileAction?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TelegramThreadPendingProvision {
|
|
54
|
+
id: string;
|
|
55
|
+
owner: "leader" | "manual-follower";
|
|
56
|
+
instanceId: string;
|
|
57
|
+
slot?: string;
|
|
58
|
+
target?: TelegramTarget & { threadId: number };
|
|
59
|
+
startedAtMs: number;
|
|
60
|
+
expiresAtMs?: number;
|
|
61
|
+
leaderEpoch?: number | string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TelegramTopicSyncObservation {
|
|
65
|
+
target: TelegramTarget & { threadId: number };
|
|
66
|
+
syncStatus: TelegramTopicSyncStatus;
|
|
67
|
+
observedAtMs: number;
|
|
68
|
+
instanceId?: string;
|
|
69
|
+
slot?: string;
|
|
70
|
+
lastSyncError?: string;
|
|
71
|
+
lastReconcileAction?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TelegramTopicTargetRecord {
|
|
75
|
+
/** Legacy string key derived from `owner`; always present in memory, never persisted. */
|
|
76
|
+
profileKey: string;
|
|
77
|
+
owner?: TelegramThreadOwner;
|
|
78
|
+
target: TelegramTarget & { threadId: number };
|
|
79
|
+
status: TelegramTopicTargetStatus;
|
|
80
|
+
createdAtMs: number;
|
|
81
|
+
updatedAtMs: number;
|
|
82
|
+
threadName?: string;
|
|
83
|
+
instanceId?: string;
|
|
84
|
+
slot?: string;
|
|
85
|
+
lastError?: string;
|
|
86
|
+
syncStatus?: TelegramTopicSyncStatus;
|
|
87
|
+
lastSyncObservedAtMs?: number;
|
|
88
|
+
lastSyncProbeAtMs?: number;
|
|
89
|
+
lastSyncError?: string;
|
|
90
|
+
lastReconcileAction?: string;
|
|
91
|
+
rerouteConfirmedAtMs?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TelegramThreadIdentityRecord {
|
|
95
|
+
profileKey: string;
|
|
96
|
+
threadName?: string;
|
|
97
|
+
slot?: string;
|
|
98
|
+
updatedAtMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type TelegramBotThreadMode = "unknown" | "enabled" | "disabled";
|
|
102
|
+
|
|
103
|
+
export interface TelegramBotStateSnapshot {
|
|
104
|
+
threadMode: TelegramBotThreadMode;
|
|
105
|
+
updatedAtMs?: number;
|
|
106
|
+
lastSlot?: string;
|
|
107
|
+
lastReconcileAction?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TelegramTopicTargetFile {
|
|
111
|
+
version: 1;
|
|
112
|
+
source: "snapshot";
|
|
113
|
+
writtenAtMs: number;
|
|
114
|
+
bot: TelegramBotStateSnapshot;
|
|
115
|
+
runtime?: Record<string, unknown>;
|
|
116
|
+
liveRoster?: Record<string, unknown>;
|
|
117
|
+
diagnostics?: Record<string, unknown>;
|
|
118
|
+
threads: TelegramTopicTargetRecord[];
|
|
119
|
+
identities?: TelegramThreadIdentityRecord[];
|
|
120
|
+
reservations?: TelegramThreadReservation[];
|
|
121
|
+
pendingProvisions?: TelegramThreadPendingProvision[];
|
|
122
|
+
syncObservations?: TelegramTopicSyncObservation[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getNextMonotonicSlot(
|
|
126
|
+
records: Map<string, TelegramTopicTargetRecord>,
|
|
127
|
+
reservations: readonly TelegramThreadReservation[],
|
|
128
|
+
pendingProvisions: readonly TelegramThreadPendingProvision[],
|
|
129
|
+
nowMs: number,
|
|
130
|
+
lastSlot?: string,
|
|
131
|
+
): string | undefined {
|
|
132
|
+
let maxCode = "A".charCodeAt(0) - 1;
|
|
133
|
+
for (const record of records.values()) {
|
|
134
|
+
if (!record.slot || !isCurrentThreadRecord(record)) continue;
|
|
135
|
+
maxCode = Math.max(maxCode, record.slot.charCodeAt(0));
|
|
136
|
+
}
|
|
137
|
+
for (const reservation of reservations) {
|
|
138
|
+
if (
|
|
139
|
+
reservation.expiresAtMs !== undefined &&
|
|
140
|
+
reservation.expiresAtMs <= nowMs
|
|
141
|
+
)
|
|
142
|
+
continue;
|
|
143
|
+
if (!reservation.slot) continue;
|
|
144
|
+
maxCode = Math.max(maxCode, reservation.slot.charCodeAt(0));
|
|
145
|
+
}
|
|
146
|
+
for (const provision of pendingProvisions) {
|
|
147
|
+
if (provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs)
|
|
148
|
+
continue;
|
|
149
|
+
if (!provision.slot) continue;
|
|
150
|
+
maxCode = Math.max(maxCode, provision.slot.charCodeAt(0));
|
|
151
|
+
}
|
|
152
|
+
if (lastSlot && /^[A-Z]$/.test(lastSlot)) {
|
|
153
|
+
maxCode = Math.max(maxCode, lastSlot.charCodeAt(0));
|
|
154
|
+
}
|
|
155
|
+
let code = maxCode + 1;
|
|
156
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
157
|
+
for (let attempt = 0; attempt < 26; attempt++) {
|
|
158
|
+
const candidate = String.fromCharCode(code);
|
|
159
|
+
if (
|
|
160
|
+
!isTelegramTopicTargetSlotOccupied(
|
|
161
|
+
candidate,
|
|
162
|
+
records,
|
|
163
|
+
reservations,
|
|
164
|
+
pendingProvisions,
|
|
165
|
+
nowMs,
|
|
166
|
+
)
|
|
167
|
+
) {
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
170
|
+
code += 1;
|
|
171
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface TelegramTopicTargetStore {
|
|
177
|
+
load: () => Promise<void>;
|
|
178
|
+
persist: () => Promise<void>;
|
|
179
|
+
list: () => TelegramTopicTargetRecord[];
|
|
180
|
+
listReservations: () => TelegramThreadReservation[];
|
|
181
|
+
listPendingProvisions: () => TelegramThreadPendingProvision[];
|
|
182
|
+
listSyncObservations: () => TelegramTopicSyncObservation[];
|
|
183
|
+
reserveThread: (reservation: TelegramThreadReservation) => void;
|
|
184
|
+
upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void;
|
|
185
|
+
removePendingProvision: (id: string) => boolean;
|
|
186
|
+
removeReservationByTarget: (target: TelegramTarget) => boolean;
|
|
187
|
+
getBotState: () => TelegramBotStateSnapshot;
|
|
188
|
+
setBotState: (state: Partial<TelegramBotStateSnapshot>) => void;
|
|
189
|
+
setStatusSnapshot: (snapshot: {
|
|
190
|
+
runtime?: Record<string, unknown>;
|
|
191
|
+
liveRoster?: Record<string, unknown>;
|
|
192
|
+
diagnostics?: Record<string, unknown>;
|
|
193
|
+
}) => void;
|
|
194
|
+
getByProfileKey: (
|
|
195
|
+
profileKey: string,
|
|
196
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
197
|
+
getActiveByInstanceId: (
|
|
198
|
+
instanceId: string,
|
|
199
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
200
|
+
getIdentityByProfileKey: (
|
|
201
|
+
profileKey: string,
|
|
202
|
+
) => TelegramThreadIdentityRecord | undefined;
|
|
203
|
+
upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord;
|
|
204
|
+
markOfflineByInstanceId: (instanceId: string) => number;
|
|
205
|
+
markStaleByTarget: (
|
|
206
|
+
target: TelegramTarget,
|
|
207
|
+
syncStatus?: TelegramTopicSyncStatus,
|
|
208
|
+
lastSyncError?: string,
|
|
209
|
+
) => boolean;
|
|
210
|
+
markActiveByTarget: (target: TelegramTarget) => boolean;
|
|
211
|
+
renameByTarget: (
|
|
212
|
+
target: TelegramTarget,
|
|
213
|
+
threadName: string,
|
|
214
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
215
|
+
allocateSlot: (
|
|
216
|
+
profileKey: string,
|
|
217
|
+
preferredSlot?: string,
|
|
218
|
+
) => string | undefined;
|
|
219
|
+
/** Claim the first reusable inactive thread for an instance, linking it to instanceId. */
|
|
220
|
+
claimReusableTarget: (
|
|
221
|
+
instanceId: string,
|
|
222
|
+
threadName?: string,
|
|
223
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface TelegramTopicTargetStoreOptions {
|
|
227
|
+
path: string;
|
|
228
|
+
getNowMs?: () => number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface TelegramTopicTargetProvisionerDeps {
|
|
232
|
+
topicChatId: number;
|
|
233
|
+
store: Pick<
|
|
234
|
+
TelegramTopicTargetStore,
|
|
235
|
+
| "list"
|
|
236
|
+
| "getByProfileKey"
|
|
237
|
+
| "getActiveByInstanceId"
|
|
238
|
+
| "getIdentityByProfileKey"
|
|
239
|
+
| "upsert"
|
|
240
|
+
| "allocateSlot"
|
|
241
|
+
| "claimReusableTarget"
|
|
242
|
+
| "upsertPendingProvision"
|
|
243
|
+
| "removePendingProvision"
|
|
244
|
+
| "persist"
|
|
245
|
+
>;
|
|
246
|
+
callApi: <TResponse>(
|
|
247
|
+
method: string,
|
|
248
|
+
body: Record<string, unknown>,
|
|
249
|
+
) => Promise<TResponse>;
|
|
250
|
+
topicNameTemplate?: string;
|
|
251
|
+
getNowMs?: () => number;
|
|
252
|
+
getRandom?: () => number;
|
|
253
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
254
|
+
claimPendingTargets?: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface TelegramTopicTargetRenamerDeps {
|
|
258
|
+
store: Pick<TelegramTopicTargetStore, "renameByTarget">;
|
|
259
|
+
callApi: <TResponse>(
|
|
260
|
+
method: string,
|
|
261
|
+
body: Record<string, unknown>,
|
|
262
|
+
) => Promise<TResponse>;
|
|
263
|
+
topicNameTemplate?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface TelegramTopicTargetProvisionRequest {
|
|
267
|
+
instanceId: string;
|
|
268
|
+
owner?: TelegramThreadOwner;
|
|
269
|
+
/** Legacy string key derived from `owner`; always present in memory. */
|
|
270
|
+
profileKey: string;
|
|
271
|
+
threadName?: string;
|
|
272
|
+
preferredSlot?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface TelegramTopicTargetRenameRequest {
|
|
276
|
+
target: TelegramTarget & { threadId: number };
|
|
277
|
+
threadName: string;
|
|
278
|
+
slot?: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface TelegramTopicTargetProvisionResult {
|
|
282
|
+
target: TelegramTarget & { threadId: number };
|
|
283
|
+
reused: boolean;
|
|
284
|
+
record: TelegramTopicTargetRecord;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface TelegramTopicResult {
|
|
288
|
+
message_thread_id?: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function getAgentDir(): string {
|
|
292
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
293
|
+
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
294
|
+
: join(homedir(), ".pi", "agent");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function hashString(value: string): number {
|
|
298
|
+
let hash = 2166136261;
|
|
299
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
300
|
+
hash ^= value.charCodeAt(index);
|
|
301
|
+
hash = Math.imul(hash, 16777619);
|
|
302
|
+
}
|
|
303
|
+
return hash >>> 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getWorkspaceHint(cwd: string | undefined): string | undefined {
|
|
307
|
+
if (!cwd) return undefined;
|
|
308
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
309
|
+
const last = parts.at(-1)?.trim();
|
|
310
|
+
if (!last) return undefined;
|
|
311
|
+
return (
|
|
312
|
+
last
|
|
313
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
314
|
+
.trim()
|
|
315
|
+
.slice(0, 32) || undefined
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function createTelegramThreadName(
|
|
320
|
+
input: TelegramThreadNameInput,
|
|
321
|
+
): string {
|
|
322
|
+
const workspace = getWorkspaceHint(input.cwd);
|
|
323
|
+
const roleMark =
|
|
324
|
+
input.role === "leader"
|
|
325
|
+
? "Leader"
|
|
326
|
+
: input.role === "follower"
|
|
327
|
+
? "Follower"
|
|
328
|
+
: undefined;
|
|
329
|
+
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
330
|
+
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
331
|
+
const fallback = `Instance ${hashString(
|
|
332
|
+
`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`,
|
|
333
|
+
)
|
|
334
|
+
.toString(36)
|
|
335
|
+
.slice(0, 4)}`;
|
|
336
|
+
return (
|
|
337
|
+
[slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
338
|
+
fallback
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function getTelegramStatePath(agentDir = getAgentDir()): string {
|
|
343
|
+
return join(agentDir, "tmp", "telegram", "state.json");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function getTelegramTopicTargetsPath(agentDir = getAgentDir()): string {
|
|
347
|
+
return getTelegramStatePath(agentDir);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string {
|
|
351
|
+
switch (owner.kind) {
|
|
352
|
+
case "leader":
|
|
353
|
+
return owner.cwd
|
|
354
|
+
? `cwd:${owner.cwd}`
|
|
355
|
+
: `leader:${owner.instanceId ?? "default"}`;
|
|
356
|
+
case "manual-follower":
|
|
357
|
+
return `manual:${owner.instanceId}`;
|
|
358
|
+
case "pending-topic":
|
|
359
|
+
return `topic:${owner.chatId}:${owner.threadId}`;
|
|
360
|
+
case "legacy":
|
|
361
|
+
return `legacy:${owner.key}`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getTelegramThreadOwnerFromProfileKey(
|
|
366
|
+
profileKey: string,
|
|
367
|
+
): TelegramThreadOwner {
|
|
368
|
+
if (profileKey.startsWith("cwd:"))
|
|
369
|
+
return { kind: "leader", cwd: profileKey.slice(4) };
|
|
370
|
+
if (profileKey.startsWith("manual:")) {
|
|
371
|
+
return { kind: "manual-follower", instanceId: profileKey.slice(7) };
|
|
372
|
+
}
|
|
373
|
+
if (profileKey.startsWith("topic:")) {
|
|
374
|
+
const [, chatIdText, threadIdText] = profileKey.split(":");
|
|
375
|
+
const chatId = Number(chatIdText);
|
|
376
|
+
const threadId = Number(threadIdText);
|
|
377
|
+
if (Number.isInteger(chatId) && Number.isInteger(threadId)) {
|
|
378
|
+
return { kind: "pending-topic", chatId, threadId };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (profileKey.startsWith("leader:")) {
|
|
382
|
+
return { kind: "leader", instanceId: profileKey.slice(7) };
|
|
383
|
+
}
|
|
384
|
+
return { kind: "legacy", key: profileKey };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function parseThreadOwner(value: unknown): TelegramThreadOwner | undefined {
|
|
388
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
389
|
+
return undefined;
|
|
390
|
+
const record = value as Record<string, unknown>;
|
|
391
|
+
if (record.kind === "leader") {
|
|
392
|
+
return {
|
|
393
|
+
kind: "leader",
|
|
394
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
395
|
+
instanceId:
|
|
396
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (
|
|
400
|
+
record.kind === "manual-follower" &&
|
|
401
|
+
typeof record.instanceId === "string"
|
|
402
|
+
) {
|
|
403
|
+
return { kind: "manual-follower", instanceId: record.instanceId };
|
|
404
|
+
}
|
|
405
|
+
if (
|
|
406
|
+
record.kind === "pending-topic" &&
|
|
407
|
+
typeof record.chatId === "number" &&
|
|
408
|
+
typeof record.threadId === "number" &&
|
|
409
|
+
Number.isInteger(record.threadId)
|
|
410
|
+
) {
|
|
411
|
+
return {
|
|
412
|
+
kind: "pending-topic",
|
|
413
|
+
chatId: record.chatId,
|
|
414
|
+
threadId: record.threadId,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (record.kind === "legacy" && typeof record.key === "string") {
|
|
418
|
+
return { kind: "legacy", key: record.key };
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function getRecordOwner(
|
|
424
|
+
record: TelegramTopicTargetRecord,
|
|
425
|
+
): TelegramThreadOwner {
|
|
426
|
+
return (
|
|
427
|
+
record.owner ?? getTelegramThreadOwnerFromProfileKey(record.profileKey)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function getRecordOwnerKey(record: TelegramTopicTargetRecord): string {
|
|
432
|
+
return getTelegramThreadOwnerKey(getRecordOwner(record));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function cloneRecord(
|
|
436
|
+
record: TelegramTopicTargetRecord,
|
|
437
|
+
): TelegramTopicTargetRecord {
|
|
438
|
+
const owner = getRecordOwner(record);
|
|
439
|
+
return {
|
|
440
|
+
...record,
|
|
441
|
+
owner: { ...owner },
|
|
442
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
443
|
+
target: { ...record.target },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function getPersistedThreadName(record: Record<string, unknown>): string | undefined {
|
|
448
|
+
const value =
|
|
449
|
+
typeof record.threadName === "string"
|
|
450
|
+
? record.threadName
|
|
451
|
+
: typeof record.displayName === "string"
|
|
452
|
+
? record.displayName
|
|
453
|
+
: undefined;
|
|
454
|
+
return value ? normalizeTelegramTopicTargetThreadName(value) : undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function normalizeRecord(
|
|
458
|
+
value: unknown,
|
|
459
|
+
): TelegramTopicTargetRecord | undefined {
|
|
460
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
461
|
+
return undefined;
|
|
462
|
+
const record = value as Record<string, unknown>;
|
|
463
|
+
const target = record.target;
|
|
464
|
+
const owner =
|
|
465
|
+
parseThreadOwner(record.owner) ??
|
|
466
|
+
(typeof record.profileKey === "string" && record.profileKey.length > 0
|
|
467
|
+
? getTelegramThreadOwnerFromProfileKey(record.profileKey)
|
|
468
|
+
: undefined);
|
|
469
|
+
if (!owner) return undefined;
|
|
470
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
471
|
+
return undefined;
|
|
472
|
+
const targetRecord = target as Record<string, unknown>;
|
|
473
|
+
if (
|
|
474
|
+
typeof targetRecord.chatId !== "number" ||
|
|
475
|
+
typeof targetRecord.threadId !== "number" ||
|
|
476
|
+
!Number.isInteger(targetRecord.threadId)
|
|
477
|
+
) {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
const status = record.status;
|
|
481
|
+
if (
|
|
482
|
+
status !== "active" &&
|
|
483
|
+
status !== "offline" &&
|
|
484
|
+
status !== "stale" &&
|
|
485
|
+
status !== "pending" &&
|
|
486
|
+
status !== "starting" &&
|
|
487
|
+
status !== "failed"
|
|
488
|
+
)
|
|
489
|
+
return undefined;
|
|
490
|
+
if (
|
|
491
|
+
typeof record.createdAtMs !== "number" ||
|
|
492
|
+
typeof record.updatedAtMs !== "number"
|
|
493
|
+
)
|
|
494
|
+
return undefined;
|
|
495
|
+
const normalized: TelegramTopicTargetRecord = {
|
|
496
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
497
|
+
owner,
|
|
498
|
+
target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId },
|
|
499
|
+
status,
|
|
500
|
+
createdAtMs: record.createdAtMs,
|
|
501
|
+
updatedAtMs: record.updatedAtMs,
|
|
502
|
+
threadName: getPersistedThreadName(record),
|
|
503
|
+
instanceId:
|
|
504
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
505
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
506
|
+
};
|
|
507
|
+
const syncStatus = record.syncStatus ?? record.twinStatus;
|
|
508
|
+
if (
|
|
509
|
+
syncStatus === "open" ||
|
|
510
|
+
syncStatus === "closed" ||
|
|
511
|
+
syncStatus === "deleted" ||
|
|
512
|
+
syncStatus === "unknown"
|
|
513
|
+
) {
|
|
514
|
+
normalized.syncStatus = syncStatus;
|
|
515
|
+
}
|
|
516
|
+
if (typeof record.lastError === "string")
|
|
517
|
+
normalized.lastError = record.lastError;
|
|
518
|
+
const lastSyncObservedAtMs =
|
|
519
|
+
record.lastSyncObservedAtMs ?? record.lastTwinObservedAtMs;
|
|
520
|
+
if (typeof lastSyncObservedAtMs === "number") {
|
|
521
|
+
normalized.lastSyncObservedAtMs = lastSyncObservedAtMs;
|
|
522
|
+
}
|
|
523
|
+
const lastSyncProbeAtMs =
|
|
524
|
+
record.lastSyncProbeAtMs ?? record.lastTwinProbeAtMs;
|
|
525
|
+
if (typeof lastSyncProbeAtMs === "number") {
|
|
526
|
+
normalized.lastSyncProbeAtMs = lastSyncProbeAtMs;
|
|
527
|
+
}
|
|
528
|
+
const lastSyncError = record.lastSyncError ?? record.lastTwinError;
|
|
529
|
+
if (typeof lastSyncError === "string") {
|
|
530
|
+
normalized.lastSyncError = lastSyncError;
|
|
531
|
+
}
|
|
532
|
+
if (typeof record.lastReconcileAction === "string") {
|
|
533
|
+
normalized.lastReconcileAction = record.lastReconcileAction;
|
|
534
|
+
}
|
|
535
|
+
if (typeof record.rerouteConfirmedAtMs === "number") {
|
|
536
|
+
normalized.rerouteConfirmedAtMs = record.rerouteConfirmedAtMs;
|
|
537
|
+
}
|
|
538
|
+
return normalized;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function isCurrentThreadRecord(record: TelegramTopicTargetRecord): boolean {
|
|
542
|
+
return (
|
|
543
|
+
record.status === "active" ||
|
|
544
|
+
record.status === "starting" ||
|
|
545
|
+
record.status === "pending"
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function normalizeIdentityRecord(
|
|
550
|
+
value: unknown,
|
|
551
|
+
): TelegramThreadIdentityRecord | undefined {
|
|
552
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
553
|
+
return undefined;
|
|
554
|
+
const record = value as Record<string, unknown>;
|
|
555
|
+
if (typeof record.profileKey !== "string" || record.profileKey.length === 0)
|
|
556
|
+
return undefined;
|
|
557
|
+
if (typeof record.updatedAtMs !== "number") return undefined;
|
|
558
|
+
const identity: TelegramThreadIdentityRecord = {
|
|
559
|
+
profileKey: record.profileKey,
|
|
560
|
+
updatedAtMs: record.updatedAtMs,
|
|
561
|
+
};
|
|
562
|
+
const persistedThreadName = getPersistedThreadName(record);
|
|
563
|
+
if (persistedThreadName) {
|
|
564
|
+
const threadName = normalizeTelegramTopicTargetThreadName(
|
|
565
|
+
persistedThreadName,
|
|
566
|
+
);
|
|
567
|
+
if (threadName) identity.threadName = threadName;
|
|
568
|
+
}
|
|
569
|
+
if (typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)) {
|
|
570
|
+
identity.slot = record.slot;
|
|
571
|
+
}
|
|
572
|
+
return identity.threadName || identity.slot ? identity : undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function cloneIdentityRecord(
|
|
576
|
+
identity: TelegramThreadIdentityRecord,
|
|
577
|
+
): TelegramThreadIdentityRecord {
|
|
578
|
+
return { ...identity };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function normalizeBotStateSnapshot(value: unknown): TelegramBotStateSnapshot {
|
|
582
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
583
|
+
return { threadMode: "unknown" };
|
|
584
|
+
}
|
|
585
|
+
const record = value as Record<string, unknown>;
|
|
586
|
+
const threadMode =
|
|
587
|
+
record.threadMode === "enabled" || record.threadMode === "disabled"
|
|
588
|
+
? record.threadMode
|
|
589
|
+
: "unknown";
|
|
590
|
+
return {
|
|
591
|
+
threadMode,
|
|
592
|
+
updatedAtMs:
|
|
593
|
+
typeof record.updatedAtMs === "number" ? record.updatedAtMs : undefined,
|
|
594
|
+
lastSlot:
|
|
595
|
+
typeof record.lastSlot === "string" && /^[A-Z]$/.test(record.lastSlot)
|
|
596
|
+
? record.lastSlot
|
|
597
|
+
: undefined,
|
|
598
|
+
lastReconcileAction:
|
|
599
|
+
typeof record.lastReconcileAction === "string"
|
|
600
|
+
? record.lastReconcileAction
|
|
601
|
+
: undefined,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function normalizeSyncObservation(
|
|
606
|
+
value: unknown,
|
|
607
|
+
): TelegramTopicSyncObservation | undefined {
|
|
608
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
609
|
+
return undefined;
|
|
610
|
+
const record = value as Record<string, unknown>;
|
|
611
|
+
const targetValue = record.target;
|
|
612
|
+
if (
|
|
613
|
+
!targetValue ||
|
|
614
|
+
typeof targetValue !== "object" ||
|
|
615
|
+
Array.isArray(targetValue)
|
|
616
|
+
) {
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
620
|
+
const target =
|
|
621
|
+
typeof targetRecord.chatId === "number" &&
|
|
622
|
+
typeof targetRecord.threadId === "number" &&
|
|
623
|
+
Number.isInteger(targetRecord.threadId)
|
|
624
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
625
|
+
: undefined;
|
|
626
|
+
const syncStatus = record.syncStatus;
|
|
627
|
+
if (
|
|
628
|
+
!target ||
|
|
629
|
+
(syncStatus !== "open" &&
|
|
630
|
+
syncStatus !== "closed" &&
|
|
631
|
+
syncStatus !== "deleted" &&
|
|
632
|
+
syncStatus !== "unknown")
|
|
633
|
+
) {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
target,
|
|
638
|
+
syncStatus,
|
|
639
|
+
observedAtMs:
|
|
640
|
+
typeof record.observedAtMs === "number" ? record.observedAtMs : 0,
|
|
641
|
+
instanceId:
|
|
642
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
643
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
644
|
+
lastSyncError:
|
|
645
|
+
typeof record.lastSyncError === "string"
|
|
646
|
+
? record.lastSyncError
|
|
647
|
+
: undefined,
|
|
648
|
+
lastReconcileAction:
|
|
649
|
+
typeof record.lastReconcileAction === "string"
|
|
650
|
+
? record.lastReconcileAction
|
|
651
|
+
: undefined,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function normalizePendingProvision(
|
|
656
|
+
value: unknown,
|
|
657
|
+
): TelegramThreadPendingProvision | undefined {
|
|
658
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
659
|
+
return undefined;
|
|
660
|
+
const record = value as Record<string, unknown>;
|
|
661
|
+
const owner = record.owner;
|
|
662
|
+
if (owner !== "leader" && owner !== "manual-follower") return undefined;
|
|
663
|
+
if (typeof record.id !== "string" || record.id.length === 0) return undefined;
|
|
664
|
+
if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
|
|
665
|
+
return undefined;
|
|
666
|
+
if (typeof record.startedAtMs !== "number") return undefined;
|
|
667
|
+
let target: (TelegramTarget & { threadId: number }) | undefined;
|
|
668
|
+
const targetValue = record.target;
|
|
669
|
+
if (
|
|
670
|
+
targetValue &&
|
|
671
|
+
typeof targetValue === "object" &&
|
|
672
|
+
!Array.isArray(targetValue)
|
|
673
|
+
) {
|
|
674
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
675
|
+
if (
|
|
676
|
+
typeof targetRecord.chatId === "number" &&
|
|
677
|
+
typeof targetRecord.threadId === "number" &&
|
|
678
|
+
Number.isInteger(targetRecord.threadId)
|
|
679
|
+
) {
|
|
680
|
+
target = { chatId: targetRecord.chatId, threadId: targetRecord.threadId };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
id: record.id,
|
|
685
|
+
owner,
|
|
686
|
+
instanceId: record.instanceId,
|
|
687
|
+
...(typeof record.slot === "string" ? { slot: record.slot } : {}),
|
|
688
|
+
...(target ? { target } : {}),
|
|
689
|
+
startedAtMs: record.startedAtMs,
|
|
690
|
+
...(typeof record.expiresAtMs === "number"
|
|
691
|
+
? { expiresAtMs: record.expiresAtMs }
|
|
692
|
+
: {}),
|
|
693
|
+
...(typeof record.leaderEpoch === "number" ||
|
|
694
|
+
typeof record.leaderEpoch === "string"
|
|
695
|
+
? { leaderEpoch: record.leaderEpoch }
|
|
696
|
+
: {}),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function normalizeReservation(
|
|
701
|
+
value: unknown,
|
|
702
|
+
): TelegramThreadReservation | undefined {
|
|
703
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
704
|
+
return undefined;
|
|
705
|
+
const record = value as Record<string, unknown>;
|
|
706
|
+
const targetValue = record.target;
|
|
707
|
+
if (
|
|
708
|
+
!targetValue ||
|
|
709
|
+
typeof targetValue !== "object" ||
|
|
710
|
+
Array.isArray(targetValue)
|
|
711
|
+
) {
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
715
|
+
const target =
|
|
716
|
+
typeof targetRecord.chatId === "number" &&
|
|
717
|
+
typeof targetRecord.threadId === "number" &&
|
|
718
|
+
Number.isInteger(targetRecord.threadId)
|
|
719
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
720
|
+
: undefined;
|
|
721
|
+
const slot = typeof record.slot === "string" ? record.slot : undefined;
|
|
722
|
+
const reason = typeof record.reason === "string" ? record.reason : undefined;
|
|
723
|
+
if (!target || !slot || !reason) return undefined;
|
|
724
|
+
return {
|
|
725
|
+
target,
|
|
726
|
+
slot,
|
|
727
|
+
reason,
|
|
728
|
+
createdAtMs:
|
|
729
|
+
typeof record.createdAtMs === "number" ? record.createdAtMs : 0,
|
|
730
|
+
updatedAtMs:
|
|
731
|
+
typeof record.updatedAtMs === "number" ? record.updatedAtMs : 0,
|
|
732
|
+
expiresAtMs:
|
|
733
|
+
typeof record.expiresAtMs === "number" ? record.expiresAtMs : undefined,
|
|
734
|
+
instanceId:
|
|
735
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
736
|
+
lastReconcileAction:
|
|
737
|
+
typeof record.lastReconcileAction === "string"
|
|
738
|
+
? record.lastReconcileAction
|
|
739
|
+
: undefined,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
744
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
745
|
+
return {
|
|
746
|
+
version: 1,
|
|
747
|
+
source: "snapshot",
|
|
748
|
+
writtenAtMs: 0,
|
|
749
|
+
bot: { threadMode: "unknown" },
|
|
750
|
+
threads: [],
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const file = value as Record<string, unknown>;
|
|
754
|
+
if (file.version !== 1) {
|
|
755
|
+
return {
|
|
756
|
+
version: 1,
|
|
757
|
+
source: "snapshot",
|
|
758
|
+
writtenAtMs: 0,
|
|
759
|
+
bot: { threadMode: "unknown" },
|
|
760
|
+
threads: [],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const rawThreads = Array.isArray(file.threads) ? file.threads : [];
|
|
764
|
+
const threads = rawThreads
|
|
765
|
+
.map((record) => normalizeRecord(record))
|
|
766
|
+
.filter(
|
|
767
|
+
(record): record is TelegramTopicTargetRecord =>
|
|
768
|
+
!!record && isCurrentThreadRecord(record),
|
|
769
|
+
);
|
|
770
|
+
return {
|
|
771
|
+
version: 1,
|
|
772
|
+
source: "snapshot",
|
|
773
|
+
writtenAtMs: typeof file.writtenAtMs === "number" ? file.writtenAtMs : 0,
|
|
774
|
+
bot: normalizeBotStateSnapshot(file.bot),
|
|
775
|
+
threads,
|
|
776
|
+
identities: Array.isArray(file.identities)
|
|
777
|
+
? file.identities.flatMap((identity) => {
|
|
778
|
+
const normalized = normalizeIdentityRecord(identity);
|
|
779
|
+
return normalized ? [normalized] : [];
|
|
780
|
+
})
|
|
781
|
+
: [],
|
|
782
|
+
reservations: Array.isArray(file.reservations)
|
|
783
|
+
? file.reservations.flatMap((reservation) => {
|
|
784
|
+
const normalized = normalizeReservation(reservation);
|
|
785
|
+
return normalized ? [normalized] : [];
|
|
786
|
+
})
|
|
787
|
+
: [],
|
|
788
|
+
pendingProvisions: Array.isArray(file.pendingProvisions)
|
|
789
|
+
? file.pendingProvisions.flatMap((provision) => {
|
|
790
|
+
const normalized = normalizePendingProvision(provision);
|
|
791
|
+
return normalized ? [normalized] : [];
|
|
792
|
+
})
|
|
793
|
+
: [],
|
|
794
|
+
syncObservations: Array.isArray(file.syncObservations)
|
|
795
|
+
? file.syncObservations.flatMap((observation) => {
|
|
796
|
+
const normalized = normalizeSyncObservation(observation);
|
|
797
|
+
return normalized ? [normalized] : [];
|
|
798
|
+
})
|
|
799
|
+
: [],
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
|
|
804
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function getInstanceProcessKey(
|
|
808
|
+
instanceId: string | undefined,
|
|
809
|
+
): string | undefined {
|
|
810
|
+
if (!instanceId) return undefined;
|
|
811
|
+
const [pid] = instanceId.split(":", 1);
|
|
812
|
+
return pid && /^\d+$/.test(pid) ? pid : undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function isSameProcessInstance(
|
|
816
|
+
left: string | undefined,
|
|
817
|
+
right: string | undefined,
|
|
818
|
+
): boolean {
|
|
819
|
+
const leftProcess = getInstanceProcessKey(left);
|
|
820
|
+
return !!leftProcess && leftProcess === getInstanceProcessKey(right);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function isPendingProvisionLiveOrTargeted(
|
|
824
|
+
provision: TelegramThreadPendingProvision,
|
|
825
|
+
nowMs: number,
|
|
826
|
+
): boolean {
|
|
827
|
+
if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
return !!provision.target;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function createTelegramTopicTargetStore(
|
|
834
|
+
options: TelegramTopicTargetStoreOptions,
|
|
835
|
+
): TelegramTopicTargetStore {
|
|
836
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
837
|
+
let botState: TelegramBotStateSnapshot = { threadMode: "unknown" };
|
|
838
|
+
let records = new Map<string, TelegramTopicTargetRecord>();
|
|
839
|
+
let identities = new Map<string, TelegramThreadIdentityRecord>();
|
|
840
|
+
let reservations: TelegramThreadReservation[] = [];
|
|
841
|
+
let pendingProvisions: TelegramThreadPendingProvision[] = [];
|
|
842
|
+
let syncObservations: TelegramTopicSyncObservation[] = [];
|
|
843
|
+
let loaded = false;
|
|
844
|
+
let dirty = false;
|
|
845
|
+
let statusSnapshot: {
|
|
846
|
+
runtime?: Record<string, unknown>;
|
|
847
|
+
liveRoster?: Record<string, unknown>;
|
|
848
|
+
diagnostics?: Record<string, unknown>;
|
|
849
|
+
} = {};
|
|
850
|
+
|
|
851
|
+
const rememberSlot = (slot: string | undefined, nowMs = getNowMs()) => {
|
|
852
|
+
if (!slot || !/^[A-Z]$/.test(slot)) return;
|
|
853
|
+
const currentCode =
|
|
854
|
+
botState.lastSlot?.charCodeAt(0) ?? "A".charCodeAt(0) - 1;
|
|
855
|
+
if (slot.charCodeAt(0) < currentCode) return;
|
|
856
|
+
botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
|
|
857
|
+
};
|
|
858
|
+
const rememberIdentity = (record: TelegramTopicTargetRecord) => {
|
|
859
|
+
const profileKey = getRecordOwnerKey(record);
|
|
860
|
+
if (!record.threadName && !record.slot) return;
|
|
861
|
+
identities.set(profileKey, {
|
|
862
|
+
profileKey,
|
|
863
|
+
...(record.threadName ? { threadName: record.threadName } : {}),
|
|
864
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
865
|
+
updatedAtMs: record.updatedAtMs,
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const loadFromDisk = async () => {
|
|
870
|
+
if (!existsSync(options.path)) {
|
|
871
|
+
botState = { threadMode: "unknown" };
|
|
872
|
+
records = new Map();
|
|
873
|
+
identities = new Map();
|
|
874
|
+
reservations = [];
|
|
875
|
+
pendingProvisions = [];
|
|
876
|
+
syncObservations = [];
|
|
877
|
+
loaded = true;
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const content = await readFile(options.path, "utf8");
|
|
881
|
+
const file = parseTopicTargetFile(JSON.parse(content));
|
|
882
|
+
botState = file.bot;
|
|
883
|
+
records = new Map(
|
|
884
|
+
file.threads.map((record) => [
|
|
885
|
+
getRecordOwnerKey(record),
|
|
886
|
+
cloneRecord(record),
|
|
887
|
+
]),
|
|
888
|
+
);
|
|
889
|
+
identities = new Map(
|
|
890
|
+
(file.identities ?? []).map((identity) => [
|
|
891
|
+
identity.profileKey,
|
|
892
|
+
cloneIdentityRecord(identity),
|
|
893
|
+
]),
|
|
894
|
+
);
|
|
895
|
+
for (const record of records.values()) rememberIdentity(record);
|
|
896
|
+
const nowMs = getNowMs();
|
|
897
|
+
reservations = (file.reservations ?? [])
|
|
898
|
+
.filter(
|
|
899
|
+
(reservation) =>
|
|
900
|
+
reservation.expiresAtMs === undefined ||
|
|
901
|
+
reservation.expiresAtMs > nowMs,
|
|
902
|
+
)
|
|
903
|
+
.map((reservation) => ({ ...reservation }));
|
|
904
|
+
pendingProvisions = (file.pendingProvisions ?? [])
|
|
905
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
906
|
+
.map((provision) => ({
|
|
907
|
+
...provision,
|
|
908
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
909
|
+
}));
|
|
910
|
+
syncObservations = (file.syncObservations ?? []).map((observation) => ({
|
|
911
|
+
...observation,
|
|
912
|
+
target: { ...observation.target },
|
|
913
|
+
}));
|
|
914
|
+
loaded = true;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
async load() {
|
|
919
|
+
if (dirty) return;
|
|
920
|
+
await loadFromDisk();
|
|
921
|
+
},
|
|
922
|
+
async persist() {
|
|
923
|
+
if (!loaded && !dirty) await loadFromDisk();
|
|
924
|
+
await mkdir(dirname(options.path), { recursive: true });
|
|
925
|
+
const tempPath = `${options.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
926
|
+
const nowMs = getNowMs();
|
|
927
|
+
reservations = reservations.filter(
|
|
928
|
+
(reservation) =>
|
|
929
|
+
reservation.expiresAtMs === undefined ||
|
|
930
|
+
reservation.expiresAtMs > nowMs,
|
|
931
|
+
);
|
|
932
|
+
pendingProvisions = pendingProvisions.filter((provision) =>
|
|
933
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
934
|
+
);
|
|
935
|
+
const currentRecords = Array.from(records.values())
|
|
936
|
+
.filter(isCurrentThreadRecord)
|
|
937
|
+
.map(cloneRecord);
|
|
938
|
+
records = new Map(
|
|
939
|
+
currentRecords.map((record) => [
|
|
940
|
+
getRecordOwnerKey(record),
|
|
941
|
+
cloneRecord(record),
|
|
942
|
+
]),
|
|
943
|
+
);
|
|
944
|
+
const file = {
|
|
945
|
+
version: 1,
|
|
946
|
+
source: "snapshot",
|
|
947
|
+
writtenAtMs: nowMs,
|
|
948
|
+
bot: botState,
|
|
949
|
+
...statusSnapshot,
|
|
950
|
+
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
951
|
+
reservations: reservations.map((reservation) => ({ ...reservation })),
|
|
952
|
+
pendingProvisions: pendingProvisions.map((provision) => ({
|
|
953
|
+
...provision,
|
|
954
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
955
|
+
})),
|
|
956
|
+
syncObservations: syncObservations.map((observation) => ({
|
|
957
|
+
...observation,
|
|
958
|
+
target: { ...observation.target },
|
|
959
|
+
})),
|
|
960
|
+
threads: currentRecords.map((record) => {
|
|
961
|
+
const { profileKey: _profileKey, ...serialized } = record;
|
|
962
|
+
return serialized;
|
|
963
|
+
}),
|
|
964
|
+
};
|
|
965
|
+
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
966
|
+
encoding: "utf8",
|
|
967
|
+
mode: 0o600,
|
|
968
|
+
});
|
|
969
|
+
await chmod(tempPath, 0o600);
|
|
970
|
+
await rename(tempPath, options.path);
|
|
971
|
+
await chmod(options.path, 0o600);
|
|
972
|
+
loaded = true;
|
|
973
|
+
dirty = false;
|
|
974
|
+
},
|
|
975
|
+
list() {
|
|
976
|
+
return Array.from(records.values()).map(cloneRecord);
|
|
977
|
+
},
|
|
978
|
+
listReservations() {
|
|
979
|
+
const nowMs = getNowMs();
|
|
980
|
+
return reservations
|
|
981
|
+
.filter(
|
|
982
|
+
(reservation) =>
|
|
983
|
+
reservation.expiresAtMs === undefined ||
|
|
984
|
+
reservation.expiresAtMs > nowMs,
|
|
985
|
+
)
|
|
986
|
+
.map((reservation) => ({ ...reservation }));
|
|
987
|
+
},
|
|
988
|
+
listPendingProvisions() {
|
|
989
|
+
const nowMs = getNowMs();
|
|
990
|
+
return pendingProvisions
|
|
991
|
+
.filter((provision) =>
|
|
992
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
993
|
+
)
|
|
994
|
+
.map((provision) => ({
|
|
995
|
+
...provision,
|
|
996
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
997
|
+
}));
|
|
998
|
+
},
|
|
999
|
+
listSyncObservations() {
|
|
1000
|
+
return syncObservations.map((observation) => ({
|
|
1001
|
+
...observation,
|
|
1002
|
+
target: { ...observation.target },
|
|
1003
|
+
}));
|
|
1004
|
+
},
|
|
1005
|
+
reserveThread(reservation) {
|
|
1006
|
+
const next = { ...reservation };
|
|
1007
|
+
reservations = reservations.filter(
|
|
1008
|
+
(existing) =>
|
|
1009
|
+
existing.slot !== next.slot &&
|
|
1010
|
+
!targetMatches(existing.target, next.target),
|
|
1011
|
+
);
|
|
1012
|
+
reservations.push(next);
|
|
1013
|
+
loaded = true;
|
|
1014
|
+
dirty = true;
|
|
1015
|
+
},
|
|
1016
|
+
upsertPendingProvision(provision) {
|
|
1017
|
+
const next = {
|
|
1018
|
+
...provision,
|
|
1019
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1020
|
+
};
|
|
1021
|
+
pendingProvisions = pendingProvisions.filter(
|
|
1022
|
+
(existing) => existing.id !== next.id,
|
|
1023
|
+
);
|
|
1024
|
+
pendingProvisions.push(next);
|
|
1025
|
+
loaded = true;
|
|
1026
|
+
dirty = true;
|
|
1027
|
+
},
|
|
1028
|
+
removePendingProvision(id) {
|
|
1029
|
+
const before = pendingProvisions.length;
|
|
1030
|
+
pendingProvisions = pendingProvisions.filter(
|
|
1031
|
+
(provision) => provision.id !== id,
|
|
1032
|
+
);
|
|
1033
|
+
const changed = pendingProvisions.length !== before;
|
|
1034
|
+
if (changed) {
|
|
1035
|
+
loaded = true;
|
|
1036
|
+
dirty = true;
|
|
1037
|
+
}
|
|
1038
|
+
return changed;
|
|
1039
|
+
},
|
|
1040
|
+
removeReservationByTarget(target) {
|
|
1041
|
+
const before = reservations.length;
|
|
1042
|
+
reservations = reservations.filter(
|
|
1043
|
+
(reservation) => !targetMatches(reservation.target, target),
|
|
1044
|
+
);
|
|
1045
|
+
const changed = reservations.length !== before;
|
|
1046
|
+
if (changed) {
|
|
1047
|
+
loaded = true;
|
|
1048
|
+
dirty = true;
|
|
1049
|
+
}
|
|
1050
|
+
return changed;
|
|
1051
|
+
},
|
|
1052
|
+
getBotState() {
|
|
1053
|
+
return Object.fromEntries(
|
|
1054
|
+
Object.entries(botState).filter(([, value]) => value !== undefined),
|
|
1055
|
+
) as TelegramBotStateSnapshot;
|
|
1056
|
+
},
|
|
1057
|
+
setBotState(state) {
|
|
1058
|
+
botState = { ...botState, ...state };
|
|
1059
|
+
loaded = true;
|
|
1060
|
+
dirty = true;
|
|
1061
|
+
},
|
|
1062
|
+
setStatusSnapshot(snapshot) {
|
|
1063
|
+
statusSnapshot = { ...snapshot };
|
|
1064
|
+
},
|
|
1065
|
+
getByProfileKey(profileKey) {
|
|
1066
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1067
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1068
|
+
);
|
|
1069
|
+
const record = records.get(ownerKey) ?? records.get(profileKey);
|
|
1070
|
+
return record ? cloneRecord(record) : undefined;
|
|
1071
|
+
},
|
|
1072
|
+
getActiveByInstanceId(instanceId) {
|
|
1073
|
+
for (const record of records.values()) {
|
|
1074
|
+
if (record.instanceId !== instanceId) continue;
|
|
1075
|
+
if (record.status !== "active" && record.status !== "starting")
|
|
1076
|
+
continue;
|
|
1077
|
+
return cloneRecord(record);
|
|
1078
|
+
}
|
|
1079
|
+
return undefined;
|
|
1080
|
+
},
|
|
1081
|
+
getIdentityByProfileKey(profileKey) {
|
|
1082
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1083
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1084
|
+
);
|
|
1085
|
+
const identity = identities.get(ownerKey) ?? identities.get(profileKey);
|
|
1086
|
+
return identity ? cloneIdentityRecord(identity) : undefined;
|
|
1087
|
+
},
|
|
1088
|
+
upsert(record) {
|
|
1089
|
+
const next = cloneRecord(record);
|
|
1090
|
+
const nextOwnerKey = getRecordOwnerKey(next);
|
|
1091
|
+
if (isCurrentThreadRecord(next)) {
|
|
1092
|
+
for (const existing of Array.from(records.values())) {
|
|
1093
|
+
const existingOwnerKey = getRecordOwnerKey(existing);
|
|
1094
|
+
if (existingOwnerKey === nextOwnerKey) continue;
|
|
1095
|
+
if (!targetMatches(existing.target, next.target)) continue;
|
|
1096
|
+
records.delete(existingOwnerKey);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (
|
|
1100
|
+
next.instanceId &&
|
|
1101
|
+
(next.status === "active" || next.status === "starting")
|
|
1102
|
+
) {
|
|
1103
|
+
for (const existing of records.values()) {
|
|
1104
|
+
if (existing.instanceId !== next.instanceId) continue;
|
|
1105
|
+
if (getRecordOwnerKey(existing) === nextOwnerKey) continue;
|
|
1106
|
+
if (targetMatches(existing.target, next.target)) continue;
|
|
1107
|
+
if (existing.status !== "active" && existing.status !== "starting")
|
|
1108
|
+
continue;
|
|
1109
|
+
records.delete(getRecordOwnerKey(existing));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (!isCurrentThreadRecord(next)) {
|
|
1113
|
+
rememberIdentity(next);
|
|
1114
|
+
records.delete(nextOwnerKey);
|
|
1115
|
+
loaded = true;
|
|
1116
|
+
dirty = true;
|
|
1117
|
+
return cloneRecord(next);
|
|
1118
|
+
}
|
|
1119
|
+
records.set(nextOwnerKey, next);
|
|
1120
|
+
rememberSlot(next.slot, next.updatedAtMs);
|
|
1121
|
+
rememberIdentity(next);
|
|
1122
|
+
loaded = true;
|
|
1123
|
+
dirty = true;
|
|
1124
|
+
return cloneRecord(next);
|
|
1125
|
+
},
|
|
1126
|
+
markOfflineByInstanceId(instanceId) {
|
|
1127
|
+
let count = 0;
|
|
1128
|
+
for (const record of Array.from(records.values())) {
|
|
1129
|
+
if (
|
|
1130
|
+
record.instanceId !== instanceId ||
|
|
1131
|
+
(record.status !== "active" && record.status !== "starting")
|
|
1132
|
+
)
|
|
1133
|
+
continue;
|
|
1134
|
+
records.delete(getRecordOwnerKey(record));
|
|
1135
|
+
count += 1;
|
|
1136
|
+
}
|
|
1137
|
+
if (count > 0) {
|
|
1138
|
+
loaded = true;
|
|
1139
|
+
dirty = true;
|
|
1140
|
+
}
|
|
1141
|
+
return count;
|
|
1142
|
+
},
|
|
1143
|
+
markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
|
|
1144
|
+
for (const record of Array.from(records.values())) {
|
|
1145
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1146
|
+
const nowMs = getNowMs();
|
|
1147
|
+
syncObservations = syncObservations.filter(
|
|
1148
|
+
(observation) => !targetMatches(observation.target, record.target),
|
|
1149
|
+
);
|
|
1150
|
+
syncObservations.push({
|
|
1151
|
+
target: record.target,
|
|
1152
|
+
syncStatus,
|
|
1153
|
+
observedAtMs: nowMs,
|
|
1154
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
1155
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1156
|
+
...(lastSyncError ? { lastSyncError } : {}),
|
|
1157
|
+
lastReconcileAction: "mark-stale",
|
|
1158
|
+
});
|
|
1159
|
+
rememberIdentity(record);
|
|
1160
|
+
records.delete(getRecordOwnerKey(record));
|
|
1161
|
+
loaded = true;
|
|
1162
|
+
dirty = true;
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
},
|
|
1167
|
+
markActiveByTarget(target) {
|
|
1168
|
+
const nowMs = getNowMs();
|
|
1169
|
+
for (const record of records.values()) {
|
|
1170
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1171
|
+
record.status = "active";
|
|
1172
|
+
record.updatedAtMs = nowMs;
|
|
1173
|
+
record.syncStatus = "open";
|
|
1174
|
+
record.lastSyncObservedAtMs = nowMs;
|
|
1175
|
+
record.lastReconcileAction = "mark-active";
|
|
1176
|
+
delete record.lastError;
|
|
1177
|
+
delete record.lastSyncError;
|
|
1178
|
+
loaded = true;
|
|
1179
|
+
dirty = true;
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
return false;
|
|
1183
|
+
},
|
|
1184
|
+
renameByTarget(target, threadName) {
|
|
1185
|
+
const nowMs = getNowMs();
|
|
1186
|
+
const normalizedThreadName =
|
|
1187
|
+
normalizeTelegramTopicTargetThreadName(threadName);
|
|
1188
|
+
if (!normalizedThreadName) return undefined;
|
|
1189
|
+
for (const record of records.values()) {
|
|
1190
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1191
|
+
record.threadName = normalizedThreadName;
|
|
1192
|
+
record.updatedAtMs = nowMs;
|
|
1193
|
+
rememberIdentity(record);
|
|
1194
|
+
loaded = true;
|
|
1195
|
+
dirty = true;
|
|
1196
|
+
return cloneRecord(record);
|
|
1197
|
+
}
|
|
1198
|
+
return undefined;
|
|
1199
|
+
},
|
|
1200
|
+
claimReusableTarget(instanceId, threadName) {
|
|
1201
|
+
const nowMs = getNowMs();
|
|
1202
|
+
const candidates = Array.from(records.values())
|
|
1203
|
+
.filter((record) => {
|
|
1204
|
+
if (record.instanceId) return false;
|
|
1205
|
+
if (record.slot === "A") return false;
|
|
1206
|
+
if (record.status !== "pending") return false;
|
|
1207
|
+
if (!record.slot) return true;
|
|
1208
|
+
return !Array.from(records.values()).some(
|
|
1209
|
+
(other) =>
|
|
1210
|
+
other !== record &&
|
|
1211
|
+
other.slot === record.slot &&
|
|
1212
|
+
(other.status === "active" || other.status === "starting"),
|
|
1213
|
+
);
|
|
1214
|
+
})
|
|
1215
|
+
.sort((left, right) => {
|
|
1216
|
+
const leftSlot = left.slot ?? "Z";
|
|
1217
|
+
const rightSlot = right.slot ?? "Z";
|
|
1218
|
+
if (leftSlot !== rightSlot) return leftSlot.localeCompare(rightSlot);
|
|
1219
|
+
return left.createdAtMs - right.createdAtMs;
|
|
1220
|
+
});
|
|
1221
|
+
const record = candidates[0];
|
|
1222
|
+
if (!record) return undefined;
|
|
1223
|
+
record.status = "active";
|
|
1224
|
+
record.instanceId = instanceId;
|
|
1225
|
+
record.updatedAtMs = nowMs;
|
|
1226
|
+
if (
|
|
1227
|
+
!record.threadName &&
|
|
1228
|
+
threadName &&
|
|
1229
|
+
isTelegramTopicThreadNameValidForSlot(threadName, record.slot)
|
|
1230
|
+
)
|
|
1231
|
+
record.threadName = threadName;
|
|
1232
|
+
delete record.lastError;
|
|
1233
|
+
rememberIdentity(record);
|
|
1234
|
+
loaded = true;
|
|
1235
|
+
dirty = true;
|
|
1236
|
+
return cloneRecord(record);
|
|
1237
|
+
},
|
|
1238
|
+
allocateSlot(profileKey, preferredSlot) {
|
|
1239
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1240
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1241
|
+
);
|
|
1242
|
+
const existing = records.get(ownerKey) ?? records.get(profileKey);
|
|
1243
|
+
if (existing?.slot && isCurrentThreadRecord(existing)) {
|
|
1244
|
+
return existing.slot;
|
|
1245
|
+
}
|
|
1246
|
+
const nowMs = getNowMs();
|
|
1247
|
+
if (
|
|
1248
|
+
preferredSlot &&
|
|
1249
|
+
!isTelegramTopicTargetSlotOccupied(
|
|
1250
|
+
preferredSlot,
|
|
1251
|
+
records,
|
|
1252
|
+
reservations,
|
|
1253
|
+
pendingProvisions,
|
|
1254
|
+
nowMs,
|
|
1255
|
+
)
|
|
1256
|
+
) {
|
|
1257
|
+
return preferredSlot;
|
|
1258
|
+
}
|
|
1259
|
+
return getNextMonotonicSlot(
|
|
1260
|
+
records,
|
|
1261
|
+
reservations,
|
|
1262
|
+
pendingProvisions,
|
|
1263
|
+
nowMs,
|
|
1264
|
+
botState.lastSlot,
|
|
1265
|
+
);
|
|
1266
|
+
},
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function isTelegramTopicTargetSlotOccupied(
|
|
1271
|
+
slot: string,
|
|
1272
|
+
records: Map<string, TelegramTopicTargetRecord>,
|
|
1273
|
+
reservations: readonly TelegramThreadReservation[] = [],
|
|
1274
|
+
pendingProvisions: readonly TelegramThreadPendingProvision[] = [],
|
|
1275
|
+
nowMs = Date.now(),
|
|
1276
|
+
): boolean {
|
|
1277
|
+
for (const record of records.values()) {
|
|
1278
|
+
if (record.slot === slot && isCurrentThreadRecord(record)) return true;
|
|
1279
|
+
}
|
|
1280
|
+
for (const reservation of reservations) {
|
|
1281
|
+
if (
|
|
1282
|
+
reservation.expiresAtMs !== undefined &&
|
|
1283
|
+
reservation.expiresAtMs <= nowMs
|
|
1284
|
+
)
|
|
1285
|
+
continue;
|
|
1286
|
+
if (reservation.slot === slot) return true;
|
|
1287
|
+
}
|
|
1288
|
+
for (const provision of pendingProvisions) {
|
|
1289
|
+
if (provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs)
|
|
1290
|
+
continue;
|
|
1291
|
+
if (provision.slot === slot) return true;
|
|
1292
|
+
}
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
export function normalizeTelegramTopicTargetThreadName(
|
|
1297
|
+
threadName: string,
|
|
1298
|
+
): string {
|
|
1299
|
+
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function getGraphemeSegments(value: string): string[] {
|
|
1303
|
+
const segmenter = (
|
|
1304
|
+
Intl as unknown as {
|
|
1305
|
+
Segmenter?: new (
|
|
1306
|
+
locale?: string,
|
|
1307
|
+
options?: { granularity: "grapheme" },
|
|
1308
|
+
) => { segment(input: string): Iterable<{ segment: string }> };
|
|
1309
|
+
}
|
|
1310
|
+
).Segmenter;
|
|
1311
|
+
if (!segmenter) return Array.from(value);
|
|
1312
|
+
return Array.from(
|
|
1313
|
+
new segmenter(undefined, { granularity: "grapheme" }).segment(value),
|
|
1314
|
+
(part) => part.segment,
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export function getTelegramTopicIdentityName(threadName: string): string {
|
|
1319
|
+
return getGraphemeSegments(
|
|
1320
|
+
normalizeTelegramTopicTargetThreadName(threadName),
|
|
1321
|
+
)
|
|
1322
|
+
.join("")
|
|
1323
|
+
.trim();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
|
|
1327
|
+
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
1328
|
+
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
1329
|
+
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
1330
|
+
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
1331
|
+
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
1332
|
+
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
1333
|
+
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
1334
|
+
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
1335
|
+
I: ["Iris", "Ivory", "Iron", "Isle", "Ibis"],
|
|
1336
|
+
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
1337
|
+
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
1338
|
+
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
1339
|
+
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
1340
|
+
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
1341
|
+
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
1342
|
+
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
1343
|
+
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
1344
|
+
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
1345
|
+
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
1346
|
+
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
1347
|
+
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
1348
|
+
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
1349
|
+
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
1350
|
+
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
1351
|
+
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
1352
|
+
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
export function chooseTelegramThreadName(input: {
|
|
1356
|
+
slot: string | undefined;
|
|
1357
|
+
entropy?: number | string;
|
|
1358
|
+
getRandom?: () => number;
|
|
1359
|
+
}): string | undefined {
|
|
1360
|
+
if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
|
|
1361
|
+
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
1362
|
+
if (!names || names.length === 0) return undefined;
|
|
1363
|
+
const index = input.getRandom
|
|
1364
|
+
? Math.max(
|
|
1365
|
+
0,
|
|
1366
|
+
Math.min(names.length - 1, Math.floor(input.getRandom() * names.length)),
|
|
1367
|
+
)
|
|
1368
|
+
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
1369
|
+
return names[index];
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function getTelegramThreadNameLeadingSlot(
|
|
1373
|
+
threadName: string | undefined,
|
|
1374
|
+
): string | undefined {
|
|
1375
|
+
if (!threadName) return undefined;
|
|
1376
|
+
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
1377
|
+
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function isTelegramSlotOccupiedByOtherCurrentRecord(
|
|
1381
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
1382
|
+
slot: string,
|
|
1383
|
+
currentRecord: TelegramTopicTargetRecord,
|
|
1384
|
+
): boolean {
|
|
1385
|
+
return records.some(
|
|
1386
|
+
(record) =>
|
|
1387
|
+
!targetMatches(record.target, currentRecord.target) &&
|
|
1388
|
+
isCurrentThreadRecord(record) &&
|
|
1389
|
+
record.slot === slot,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function normalizeCurrentThreadNameSlots(
|
|
1394
|
+
store: Pick<TelegramTopicTargetStore, "list" | "upsert">,
|
|
1395
|
+
): void {
|
|
1396
|
+
for (const record of store.list()) {
|
|
1397
|
+
if (!isCurrentThreadRecord(record)) continue;
|
|
1398
|
+
const slot = getTelegramThreadNameLeadingSlot(record.threadName);
|
|
1399
|
+
if (!slot || record.slot === slot) continue;
|
|
1400
|
+
if (isTelegramSlotOccupiedByOtherCurrentRecord(store.list(), slot, record))
|
|
1401
|
+
continue;
|
|
1402
|
+
store.upsert({ ...record, slot });
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function getNextTelegramThreadNamePaletteSlot(
|
|
1407
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
1408
|
+
fallbackSlot: string | undefined,
|
|
1409
|
+
): string | undefined {
|
|
1410
|
+
let maxCode = "A".charCodeAt(0) - 1;
|
|
1411
|
+
for (const record of records) {
|
|
1412
|
+
if (!isCurrentThreadRecord(record) || !record.threadName) continue;
|
|
1413
|
+
const identity = getTelegramTopicIdentityName(record.threadName);
|
|
1414
|
+
const first = identity[0];
|
|
1415
|
+
if (!first || !/^[A-Z]$/.test(first)) continue;
|
|
1416
|
+
maxCode = Math.max(maxCode, first.charCodeAt(0));
|
|
1417
|
+
}
|
|
1418
|
+
if (maxCode < "A".charCodeAt(0)) return fallbackSlot;
|
|
1419
|
+
let code = maxCode + 1;
|
|
1420
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
1421
|
+
return String.fromCharCode(code);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function getTelegramThreadNameEntropyIndex(
|
|
1425
|
+
entropy: number | string | undefined,
|
|
1426
|
+
length: number,
|
|
1427
|
+
): number {
|
|
1428
|
+
if (length <= 1) return 0;
|
|
1429
|
+
if (typeof entropy === "number" && entropy < 1_000_000_000_000) return 0;
|
|
1430
|
+
const value = entropy === undefined ? "0" : String(entropy);
|
|
1431
|
+
let hash = 2166136261;
|
|
1432
|
+
for (const char of value) {
|
|
1433
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
1434
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
1435
|
+
}
|
|
1436
|
+
return hash % length;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export function getTelegramTopicThreadNameValidationError(
|
|
1440
|
+
threadName: string,
|
|
1441
|
+
slot: string | undefined,
|
|
1442
|
+
): string | undefined {
|
|
1443
|
+
const identity = getTelegramTopicIdentityName(threadName);
|
|
1444
|
+
const reasons: string[] = [];
|
|
1445
|
+
if (!identity) reasons.push("it is empty after trimming");
|
|
1446
|
+
if (/\s/.test(identity)) reasons.push("it contains spaces");
|
|
1447
|
+
if (/[^A-Za-z]/.test(identity)) {
|
|
1448
|
+
reasons.push("it contains characters outside Latin A-Z letters");
|
|
1449
|
+
}
|
|
1450
|
+
if (!/^[A-Z]/.test(identity)) {
|
|
1451
|
+
reasons.push("it does not start with an uppercase Latin letter");
|
|
1452
|
+
}
|
|
1453
|
+
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
1454
|
+
if (genericLabels.has(identity.toLowerCase())) {
|
|
1455
|
+
reasons.push("it is a generic role label");
|
|
1456
|
+
}
|
|
1457
|
+
if (/^[A-Z]$/.test(identity)) {
|
|
1458
|
+
reasons.push("it is only a bare slot letter");
|
|
1459
|
+
}
|
|
1460
|
+
if (reasons.length === 0) return undefined;
|
|
1461
|
+
return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
export function isTelegramTopicThreadNameValidForSlot(
|
|
1465
|
+
threadName: string,
|
|
1466
|
+
slot: string | undefined,
|
|
1467
|
+
): boolean {
|
|
1468
|
+
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function applyTopicNameTemplate(
|
|
1472
|
+
template: string,
|
|
1473
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
1474
|
+
slot?: string,
|
|
1475
|
+
): string {
|
|
1476
|
+
const threadName =
|
|
1477
|
+
request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
1478
|
+
let result = template
|
|
1479
|
+
.replaceAll("{threadName}", threadName)
|
|
1480
|
+
.replaceAll("{profileKey}", request.profileKey)
|
|
1481
|
+
.replaceAll("{instanceId}", request.instanceId);
|
|
1482
|
+
if (slot) result = result.replaceAll("{slot}", slot);
|
|
1483
|
+
return result;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
export function getTelegramTopicName(
|
|
1487
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
1488
|
+
template = "{slot}",
|
|
1489
|
+
slot?: string,
|
|
1490
|
+
): string {
|
|
1491
|
+
const name = applyTopicNameTemplate(template, request, slot)
|
|
1492
|
+
.replace(/\s+/g, " ")
|
|
1493
|
+
.trim();
|
|
1494
|
+
return (name || slot || "Pi").slice(0, 128);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function asInteger(value: unknown): number | undefined {
|
|
1498
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
1499
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
1500
|
+
const parsed = Number(value);
|
|
1501
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export interface TelegramOwnTopicProvisionDeps {
|
|
1505
|
+
getAllowedUserId: () => number | undefined;
|
|
1506
|
+
instanceId: string;
|
|
1507
|
+
cwd?: string;
|
|
1508
|
+
getNowMs?: () => number;
|
|
1509
|
+
getRandom?: () => number;
|
|
1510
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
1511
|
+
getThreadReconciliationMachineState?: () =>
|
|
1512
|
+
| ThreadReconciler.ThreadReconciliationMachineState
|
|
1513
|
+
| undefined;
|
|
1514
|
+
recordThreadReconciliationPlan?: (
|
|
1515
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
1516
|
+
) => void;
|
|
1517
|
+
store: TelegramTopicTargetStore;
|
|
1518
|
+
callApi: <TResponse>(
|
|
1519
|
+
method: string,
|
|
1520
|
+
body: Record<string, unknown>,
|
|
1521
|
+
) => Promise<TResponse>;
|
|
1522
|
+
recordEvent: (
|
|
1523
|
+
category: string,
|
|
1524
|
+
message: string,
|
|
1525
|
+
details?: Record<string, unknown>,
|
|
1526
|
+
) => void;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
export interface TelegramOwnTopicProvisionResult {
|
|
1530
|
+
target: TelegramTarget & { threadId: number };
|
|
1531
|
+
slot: string;
|
|
1532
|
+
threadName?: string;
|
|
1533
|
+
reused: boolean;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Provision a topic for the bus leader's own use (slot A).
|
|
1538
|
+
* This is a thread-binding primitive; sync policy decides when startup/connect
|
|
1539
|
+
* should call it to ensure the leader has a visible working thread.
|
|
1540
|
+
*/
|
|
1541
|
+
export async function provisionOwnBusTopic(
|
|
1542
|
+
deps: TelegramOwnTopicProvisionDeps,
|
|
1543
|
+
): Promise<TelegramOwnTopicProvisionResult | undefined> {
|
|
1544
|
+
const chatId = deps.getAllowedUserId();
|
|
1545
|
+
let profileKey = deps.cwd ? `cwd:${deps.cwd}` : `leader:${deps.instanceId}`;
|
|
1546
|
+
if (typeof chatId !== "number") return undefined;
|
|
1547
|
+
await deps.store.load();
|
|
1548
|
+
const reservationCleanupPorts = {
|
|
1549
|
+
callApi: deps.callApi,
|
|
1550
|
+
markStaleByTarget: (
|
|
1551
|
+
target: TelegramTarget & { threadId: number },
|
|
1552
|
+
syncStatus?: "closed" | "deleted",
|
|
1553
|
+
lastSyncError?: string,
|
|
1554
|
+
) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
1555
|
+
removeReservationByTarget: (
|
|
1556
|
+
target: TelegramTarget & { threadId: number },
|
|
1557
|
+
) => deps.store.removeReservationByTarget(target),
|
|
1558
|
+
removePendingProvisionById: (id: string) =>
|
|
1559
|
+
deps.store.removePendingProvision(id),
|
|
1560
|
+
persist: () => deps.store.persist(),
|
|
1561
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
1562
|
+
recordRuntimeEvent(
|
|
1563
|
+
category: string,
|
|
1564
|
+
error: unknown,
|
|
1565
|
+
details?: Record<string, unknown>,
|
|
1566
|
+
) {
|
|
1567
|
+
deps.recordEvent(
|
|
1568
|
+
category,
|
|
1569
|
+
error instanceof Error ? error.message : String(error),
|
|
1570
|
+
details,
|
|
1571
|
+
);
|
|
1572
|
+
},
|
|
1573
|
+
};
|
|
1574
|
+
const reservationCleanupNowMs = Date.now();
|
|
1575
|
+
const reservationsBeforeCleanup = deps.store.listReservations();
|
|
1576
|
+
const reservationCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
1577
|
+
nowMs: reservationCleanupNowMs,
|
|
1578
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
1579
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
1580
|
+
records: deps.store.list(),
|
|
1581
|
+
reservations: reservationsBeforeCleanup,
|
|
1582
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
1583
|
+
proactiveReservationCleanup: true,
|
|
1584
|
+
});
|
|
1585
|
+
deps.recordThreadReconciliationPlan?.(reservationCleanupPlan);
|
|
1586
|
+
const reservationCleanupApplyStartedAtMs = Date.now();
|
|
1587
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
1588
|
+
reservationCleanupPlan,
|
|
1589
|
+
reservationCleanupPorts,
|
|
1590
|
+
);
|
|
1591
|
+
deps.recordEvent("bus", "Bus leader reservation cleanup reconciled", {
|
|
1592
|
+
phase: "leader-topic-reservation-cleanup-duration",
|
|
1593
|
+
durationMs: Date.now() - reservationCleanupApplyStartedAtMs,
|
|
1594
|
+
actions: reservationCleanupPlan.actions.length,
|
|
1595
|
+
});
|
|
1596
|
+
const reservationProbeResults: ThreadReconciler.ThreadReservationProbeResult[] =
|
|
1597
|
+
[];
|
|
1598
|
+
deps.recordEvent("bus", "Bus leader reservation probes skipped", {
|
|
1599
|
+
phase: "leader-topic-reservation-probe-skipped",
|
|
1600
|
+
reservations: reservationsBeforeCleanup.length,
|
|
1601
|
+
});
|
|
1602
|
+
const reservationProbePlan = ThreadReconciler.planThreadReconciliation({
|
|
1603
|
+
nowMs: Date.now(),
|
|
1604
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
1605
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
1606
|
+
records: deps.store.list(),
|
|
1607
|
+
reservations: deps.store.listReservations(),
|
|
1608
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
1609
|
+
reservationProbeResults,
|
|
1610
|
+
});
|
|
1611
|
+
deps.recordThreadReconciliationPlan?.(reservationProbePlan);
|
|
1612
|
+
const reservationProbeApplyStartedAtMs = Date.now();
|
|
1613
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
1614
|
+
reservationProbePlan,
|
|
1615
|
+
reservationCleanupPorts,
|
|
1616
|
+
);
|
|
1617
|
+
deps.recordEvent(
|
|
1618
|
+
"bus",
|
|
1619
|
+
"Bus leader reservation probe reconciliation applied",
|
|
1620
|
+
{
|
|
1621
|
+
phase: "leader-topic-reservation-probe-apply-duration",
|
|
1622
|
+
durationMs: Date.now() - reservationProbeApplyStartedAtMs,
|
|
1623
|
+
actions: reservationProbePlan.actions.length,
|
|
1624
|
+
},
|
|
1625
|
+
);
|
|
1626
|
+
const nowMs = Date.now();
|
|
1627
|
+
const currentLeaderOwner: TelegramThreadOwner = profileKey.startsWith(
|
|
1628
|
+
"leader:",
|
|
1629
|
+
)
|
|
1630
|
+
? { kind: "leader", instanceId: deps.instanceId }
|
|
1631
|
+
: { kind: "leader", cwd: deps.cwd, instanceId: deps.instanceId };
|
|
1632
|
+
const recordsBeforePreviousLeaderCleanup = deps.store.list();
|
|
1633
|
+
const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
1634
|
+
nowMs,
|
|
1635
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
1636
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
1637
|
+
records: recordsBeforePreviousLeaderCleanup.map((record) => ({
|
|
1638
|
+
...record,
|
|
1639
|
+
ownerKind: record.owner?.kind,
|
|
1640
|
+
})),
|
|
1641
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
1642
|
+
previousLeaderCleanup: { currentInstanceId: deps.instanceId },
|
|
1643
|
+
});
|
|
1644
|
+
deps.recordThreadReconciliationPlan?.(previousLeaderCleanupPlan);
|
|
1645
|
+
for (const action of previousLeaderCleanupPlan.actions) {
|
|
1646
|
+
if (action.kind !== "close-delete-previous-leader-topic") continue;
|
|
1647
|
+
const record = recordsBeforePreviousLeaderCleanup.find((candidate) =>
|
|
1648
|
+
targetMatches(candidate.target, action.target),
|
|
1649
|
+
);
|
|
1650
|
+
if (!record) continue;
|
|
1651
|
+
const isSameProfile = record.profileKey === profileKey;
|
|
1652
|
+
if (isSameProfile) {
|
|
1653
|
+
deps.recordEvent("bus", "Bus leader same-profile topic preserved", {
|
|
1654
|
+
phase: "leader-topic-same-profile-preserve",
|
|
1655
|
+
chatId: record.target.chatId,
|
|
1656
|
+
threadId: record.target.threadId,
|
|
1657
|
+
slot: record.slot,
|
|
1658
|
+
previousInstanceId: record.instanceId,
|
|
1659
|
+
instanceId: deps.instanceId,
|
|
1660
|
+
profileKey,
|
|
1661
|
+
});
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (isSameProcessInstance(record.instanceId, deps.instanceId)) {
|
|
1665
|
+
deps.store.upsert({
|
|
1666
|
+
...record,
|
|
1667
|
+
profileKey,
|
|
1668
|
+
owner: currentLeaderOwner,
|
|
1669
|
+
status: "active",
|
|
1670
|
+
instanceId: deps.instanceId,
|
|
1671
|
+
updatedAtMs: nowMs,
|
|
1672
|
+
lastError: undefined,
|
|
1673
|
+
lastReconcileAction: "leader-topic-same-process-preserve",
|
|
1674
|
+
});
|
|
1675
|
+
deps.recordEvent("bus", "Bus leader same-process topic preserved", {
|
|
1676
|
+
phase: "leader-topic-same-process-preserve",
|
|
1677
|
+
chatId: record.target.chatId,
|
|
1678
|
+
threadId: record.target.threadId,
|
|
1679
|
+
slot: record.slot,
|
|
1680
|
+
previousInstanceId: record.instanceId,
|
|
1681
|
+
instanceId: deps.instanceId,
|
|
1682
|
+
profileKey,
|
|
1683
|
+
});
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
const previousLeaderCleanupStartedAtMs = Date.now();
|
|
1687
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
1688
|
+
{ actions: [action] },
|
|
1689
|
+
{
|
|
1690
|
+
callApi: deps.callApi,
|
|
1691
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
1692
|
+
deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
1693
|
+
persist: () => deps.store.persist(),
|
|
1694
|
+
removePendingProvisionById: (id) =>
|
|
1695
|
+
deps.store.removePendingProvision(id),
|
|
1696
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
1697
|
+
recordRuntimeEvent(category, error, details) {
|
|
1698
|
+
deps.recordEvent(
|
|
1699
|
+
category,
|
|
1700
|
+
error instanceof Error ? error.message : String(error),
|
|
1701
|
+
details,
|
|
1702
|
+
);
|
|
1703
|
+
},
|
|
1704
|
+
},
|
|
1705
|
+
);
|
|
1706
|
+
deps.recordEvent("bus", "Bus leader previous-topic cleanup applied", {
|
|
1707
|
+
phase: "leader-topic-previous-cleanup-duration",
|
|
1708
|
+
durationMs: Date.now() - previousLeaderCleanupStartedAtMs,
|
|
1709
|
+
chatId: record.target.chatId,
|
|
1710
|
+
threadId: record.target.threadId,
|
|
1711
|
+
slot: record.slot,
|
|
1712
|
+
});
|
|
1713
|
+
deps.store.markStaleByTarget(record.target);
|
|
1714
|
+
deps.store.reserveThread({
|
|
1715
|
+
target: record.target,
|
|
1716
|
+
slot: record.slot ?? "A",
|
|
1717
|
+
reason: "previous-process-cleaned-without-visible-probe",
|
|
1718
|
+
createdAtMs: nowMs,
|
|
1719
|
+
updatedAtMs: nowMs,
|
|
1720
|
+
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
1721
|
+
instanceId: record.instanceId,
|
|
1722
|
+
lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
|
|
1723
|
+
});
|
|
1724
|
+
deps.store.setBotState({
|
|
1725
|
+
threadMode: "enabled",
|
|
1726
|
+
updatedAtMs: nowMs,
|
|
1727
|
+
lastReconcileAction: "leader-topic-next-slot-after-unprobed-previous",
|
|
1728
|
+
});
|
|
1729
|
+
deps.recordEvent(
|
|
1730
|
+
"bus",
|
|
1731
|
+
"Bus leader previous-process topic reserved after cleanup without visible probe",
|
|
1732
|
+
{
|
|
1733
|
+
phase: "leader-topic-previous-instance-reserve-no-probe",
|
|
1734
|
+
chatId: record.target.chatId,
|
|
1735
|
+
threadId: record.target.threadId,
|
|
1736
|
+
slot: record.slot,
|
|
1737
|
+
previousInstanceId: record.instanceId,
|
|
1738
|
+
instanceId: deps.instanceId,
|
|
1739
|
+
},
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
const provision = createTelegramTopicTargetProvisioner({
|
|
1743
|
+
topicChatId: chatId,
|
|
1744
|
+
store: deps.store,
|
|
1745
|
+
callApi: deps.callApi,
|
|
1746
|
+
getNowMs: deps.getNowMs,
|
|
1747
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
1748
|
+
getRandom: deps.getRandom,
|
|
1749
|
+
claimPendingTargets: false,
|
|
1750
|
+
});
|
|
1751
|
+
let result = await provision({
|
|
1752
|
+
instanceId: deps.instanceId,
|
|
1753
|
+
owner: currentLeaderOwner,
|
|
1754
|
+
profileKey,
|
|
1755
|
+
});
|
|
1756
|
+
if (result.reused) {
|
|
1757
|
+
// Reused topics may already have a human-chosen Telegram title. Do not edit
|
|
1758
|
+
// them during leader startup: startup reconciliation must not reset a named
|
|
1759
|
+
// topic back to its bare slot or create redundant "renamed the thread" service
|
|
1760
|
+
// messages. Also do not probe with Bot API chat actions: every chat action is
|
|
1761
|
+
// user-visible as native typing/activity, so reload would falsely signal that
|
|
1762
|
+
// the agent is working. Treat the reused binding as optimistically open;
|
|
1763
|
+
// ordinary target-scoped sends still detect stale topics and trigger the
|
|
1764
|
+
// stale-api-error reconciliation path when real delivery happens.
|
|
1765
|
+
const nowMs = Date.now();
|
|
1766
|
+
deps.store.upsert({
|
|
1767
|
+
...result.record,
|
|
1768
|
+
syncStatus: "open",
|
|
1769
|
+
lastSyncObservedAtMs: nowMs,
|
|
1770
|
+
lastReconcileAction: "leader-startup-skip-probe",
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
deps.store.setBotState({
|
|
1774
|
+
threadMode: "enabled",
|
|
1775
|
+
updatedAtMs: Date.now(),
|
|
1776
|
+
lastReconcileAction: result.reused
|
|
1777
|
+
? "leader-startup-skip-probe"
|
|
1778
|
+
: "leader-topic-created",
|
|
1779
|
+
});
|
|
1780
|
+
await deps.store.persist();
|
|
1781
|
+
deps.recordEvent("bus", "Bus leader own topic assigned", {
|
|
1782
|
+
phase: "leader-topic",
|
|
1783
|
+
chatId: result.target.chatId,
|
|
1784
|
+
threadId: result.target.threadId,
|
|
1785
|
+
slot: result.record.slot,
|
|
1786
|
+
threadName: result.record.threadName,
|
|
1787
|
+
reused: result.reused,
|
|
1788
|
+
});
|
|
1789
|
+
return {
|
|
1790
|
+
target: result.target,
|
|
1791
|
+
slot: result.record.slot ?? "A",
|
|
1792
|
+
...(result.record.threadName
|
|
1793
|
+
? { threadName: result.record.threadName }
|
|
1794
|
+
: {}),
|
|
1795
|
+
reused: result.reused,
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
export function findCurrentTelegramInstanceThreadRecord(options: {
|
|
1800
|
+
records: readonly TelegramTopicTargetRecord[];
|
|
1801
|
+
instanceId: string;
|
|
1802
|
+
preferredTarget?: TelegramTarget;
|
|
1803
|
+
}): TelegramTopicTargetRecord | undefined {
|
|
1804
|
+
const target = options.preferredTarget;
|
|
1805
|
+
if (typeof target?.threadId === "number") {
|
|
1806
|
+
const targetRecord = options.records.find((record) => {
|
|
1807
|
+
return (
|
|
1808
|
+
record.target.chatId === target.chatId &&
|
|
1809
|
+
record.target.threadId === target.threadId
|
|
1810
|
+
);
|
|
1811
|
+
});
|
|
1812
|
+
if (targetRecord) return targetRecord;
|
|
1813
|
+
}
|
|
1814
|
+
return options.records.find((record) => {
|
|
1815
|
+
return (
|
|
1816
|
+
record.instanceId === options.instanceId && record.status === "active"
|
|
1817
|
+
);
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
export function resolveTelegramInstanceThreadTarget(options: {
|
|
1822
|
+
followerTarget?: TelegramTarget;
|
|
1823
|
+
leaderTarget?: TelegramTarget;
|
|
1824
|
+
currentRecord?: TelegramTopicTargetRecord;
|
|
1825
|
+
}): (TelegramTarget & { threadId: number }) | undefined {
|
|
1826
|
+
const raw =
|
|
1827
|
+
typeof options.followerTarget?.threadId === "number"
|
|
1828
|
+
? options.followerTarget
|
|
1829
|
+
: (options.currentRecord?.target ?? options.leaderTarget);
|
|
1830
|
+
return raw &&
|
|
1831
|
+
typeof raw.chatId === "number" &&
|
|
1832
|
+
typeof raw.threadId === "number"
|
|
1833
|
+
? { chatId: raw.chatId, threadId: raw.threadId }
|
|
1834
|
+
: undefined;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
export interface TelegramThreadStatusFollowerView {
|
|
1838
|
+
instanceId: string;
|
|
1839
|
+
cwd?: string;
|
|
1840
|
+
lastHeartbeatMs: number;
|
|
1841
|
+
target?: TelegramTarget;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function getTelegramThreadStatusName(
|
|
1845
|
+
record: TelegramTopicTargetRecord | undefined,
|
|
1846
|
+
): string | undefined {
|
|
1847
|
+
if (!record) return undefined;
|
|
1848
|
+
if (
|
|
1849
|
+
record.threadName &&
|
|
1850
|
+
isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
|
|
1851
|
+
)
|
|
1852
|
+
return record.threadName;
|
|
1853
|
+
return chooseTelegramThreadName({ slot: record.slot });
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export function listTelegramThreadStatusFollowers(options: {
|
|
1857
|
+
followers: readonly TelegramThreadStatusFollowerView[];
|
|
1858
|
+
records: readonly TelegramTopicTargetRecord[];
|
|
1859
|
+
}): Array<{
|
|
1860
|
+
instanceId: string;
|
|
1861
|
+
cwd?: string;
|
|
1862
|
+
lastHeartbeatMs: number;
|
|
1863
|
+
target?: TelegramTarget;
|
|
1864
|
+
slot?: string;
|
|
1865
|
+
threadName?: string;
|
|
1866
|
+
status?: string;
|
|
1867
|
+
}> {
|
|
1868
|
+
return options.followers.map((follower) => {
|
|
1869
|
+
const record = options.records.find((record) => {
|
|
1870
|
+
return (
|
|
1871
|
+
record.target.chatId === follower.target?.chatId &&
|
|
1872
|
+
record.target.threadId === follower.target?.threadId
|
|
1873
|
+
);
|
|
1874
|
+
});
|
|
1875
|
+
return {
|
|
1876
|
+
instanceId: follower.instanceId,
|
|
1877
|
+
cwd: follower.cwd,
|
|
1878
|
+
lastHeartbeatMs: follower.lastHeartbeatMs,
|
|
1879
|
+
target: follower.target,
|
|
1880
|
+
slot: record?.slot,
|
|
1881
|
+
threadName: getTelegramThreadStatusName(record),
|
|
1882
|
+
status: record?.status,
|
|
1883
|
+
};
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export function listTelegramThreadStatusTargets(
|
|
1888
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
1889
|
+
): Array<{
|
|
1890
|
+
instanceId?: string;
|
|
1891
|
+
status: TelegramTopicTargetStatus;
|
|
1892
|
+
target: TelegramTarget & { threadId: number };
|
|
1893
|
+
slot?: string;
|
|
1894
|
+
threadName?: string;
|
|
1895
|
+
syncStatus?: TelegramTopicSyncStatus;
|
|
1896
|
+
lastSyncObservedAtMs?: number;
|
|
1897
|
+
lastSyncProbeAtMs?: number;
|
|
1898
|
+
lastSyncError?: string;
|
|
1899
|
+
lastReconcileAction?: string;
|
|
1900
|
+
}> {
|
|
1901
|
+
return records.map((record) => {
|
|
1902
|
+
return {
|
|
1903
|
+
instanceId: record.instanceId,
|
|
1904
|
+
status: record.status,
|
|
1905
|
+
target: record.target,
|
|
1906
|
+
slot: record.slot,
|
|
1907
|
+
threadName: getTelegramThreadStatusName(record),
|
|
1908
|
+
syncStatus: record.syncStatus,
|
|
1909
|
+
lastSyncObservedAtMs: record.lastSyncObservedAtMs,
|
|
1910
|
+
lastSyncProbeAtMs: record.lastSyncProbeAtMs,
|
|
1911
|
+
lastSyncError: record.lastSyncError,
|
|
1912
|
+
lastReconcileAction: record.lastReconcileAction,
|
|
1913
|
+
};
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
export function listTelegramThreadStatusReservations(
|
|
1918
|
+
reservations: readonly TelegramThreadReservation[],
|
|
1919
|
+
): Array<{
|
|
1920
|
+
target: TelegramTarget & { threadId: number };
|
|
1921
|
+
slot: string;
|
|
1922
|
+
reason: string;
|
|
1923
|
+
instanceId?: string;
|
|
1924
|
+
expiresAtMs?: number;
|
|
1925
|
+
lastReconcileAction?: string;
|
|
1926
|
+
}> {
|
|
1927
|
+
return reservations.map((reservation) => {
|
|
1928
|
+
return {
|
|
1929
|
+
target: reservation.target,
|
|
1930
|
+
slot: reservation.slot,
|
|
1931
|
+
reason: reservation.reason,
|
|
1932
|
+
instanceId: reservation.instanceId,
|
|
1933
|
+
expiresAtMs: reservation.expiresAtMs,
|
|
1934
|
+
lastReconcileAction: reservation.lastReconcileAction,
|
|
1935
|
+
};
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
export function listTelegramThreadStatusObservations(
|
|
1940
|
+
observations: readonly TelegramTopicSyncObservation[],
|
|
1941
|
+
): Array<{
|
|
1942
|
+
target: TelegramTarget & { threadId: number };
|
|
1943
|
+
syncStatus: TelegramTopicSyncStatus;
|
|
1944
|
+
observedAtMs: number;
|
|
1945
|
+
instanceId?: string;
|
|
1946
|
+
slot?: string;
|
|
1947
|
+
lastSyncError?: string;
|
|
1948
|
+
lastReconcileAction?: string;
|
|
1949
|
+
}> {
|
|
1950
|
+
return observations.map((observation) => {
|
|
1951
|
+
return {
|
|
1952
|
+
target: observation.target,
|
|
1953
|
+
syncStatus: observation.syncStatus,
|
|
1954
|
+
observedAtMs: observation.observedAtMs,
|
|
1955
|
+
instanceId: observation.instanceId,
|
|
1956
|
+
slot: observation.slot,
|
|
1957
|
+
lastSyncError: observation.lastSyncError,
|
|
1958
|
+
lastReconcileAction: observation.lastReconcileAction,
|
|
1959
|
+
};
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
export function getTelegramTargetFromApiBody(
|
|
1964
|
+
body: unknown,
|
|
1965
|
+
): (TelegramTarget & { threadId: number }) | undefined {
|
|
1966
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
1967
|
+
return undefined;
|
|
1968
|
+
const record = body as Record<string, unknown>;
|
|
1969
|
+
const chatId = asInteger(record.chat_id);
|
|
1970
|
+
const threadId = asInteger(record.message_thread_id);
|
|
1971
|
+
return chatId !== undefined && threadId !== undefined
|
|
1972
|
+
? { chatId, threadId }
|
|
1973
|
+
: undefined;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
export function isTelegramTopicTargetStaleError(error: unknown): boolean {
|
|
1977
|
+
if (!(error instanceof Error)) return false;
|
|
1978
|
+
const message = error.message.toLowerCase();
|
|
1979
|
+
return (
|
|
1980
|
+
message.includes("topic_id_invalid") ||
|
|
1981
|
+
message.includes("message thread not found") ||
|
|
1982
|
+
message.includes("thread not found") ||
|
|
1983
|
+
message.includes("topic not found") ||
|
|
1984
|
+
message.includes("topic deleted") ||
|
|
1985
|
+
message.includes("topic closed") ||
|
|
1986
|
+
message.includes("thread closed") ||
|
|
1987
|
+
message.includes("forum topic closed") ||
|
|
1988
|
+
message.includes("message thread closed")
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
export function isTelegramTopicModeUnavailableError(error: unknown): boolean {
|
|
1993
|
+
if (!(error instanceof Error)) return false;
|
|
1994
|
+
const message = error.message.toLowerCase();
|
|
1995
|
+
return (
|
|
1996
|
+
message.includes("not a forum") ||
|
|
1997
|
+
message.includes("forum topic") ||
|
|
1998
|
+
message.includes("topics are disabled") ||
|
|
1999
|
+
message.includes("threaded mode") ||
|
|
2000
|
+
message.includes("method is available only for")
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
export function getTelegramTopicTitleForThreadName(
|
|
2005
|
+
threadName: string,
|
|
2006
|
+
slot: string,
|
|
2007
|
+
template = "{threadName}",
|
|
2008
|
+
): string {
|
|
2009
|
+
return getTelegramTopicName(
|
|
2010
|
+
{
|
|
2011
|
+
instanceId: "",
|
|
2012
|
+
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
2013
|
+
threadName,
|
|
2014
|
+
},
|
|
2015
|
+
template,
|
|
2016
|
+
slot,
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
export function createTelegramTopicTargetRenamer(
|
|
2021
|
+
deps: TelegramTopicTargetRenamerDeps,
|
|
2022
|
+
): (
|
|
2023
|
+
request: TelegramTopicTargetRenameRequest,
|
|
2024
|
+
) => Promise<TelegramTopicTargetRecord | undefined> {
|
|
2025
|
+
return async (request) => {
|
|
2026
|
+
const threadName = normalizeTelegramTopicTargetThreadName(
|
|
2027
|
+
request.threadName,
|
|
2028
|
+
);
|
|
2029
|
+
if (
|
|
2030
|
+
!threadName ||
|
|
2031
|
+
!isTelegramTopicThreadNameValidForSlot(threadName, request.slot)
|
|
2032
|
+
)
|
|
2033
|
+
return undefined;
|
|
2034
|
+
const name = getTelegramTopicTitleForThreadName(
|
|
2035
|
+
threadName,
|
|
2036
|
+
request.slot ?? "",
|
|
2037
|
+
deps.topicNameTemplate,
|
|
2038
|
+
);
|
|
2039
|
+
await deps.callApi("editForumTopic", {
|
|
2040
|
+
chat_id: request.target.chatId,
|
|
2041
|
+
message_thread_id: request.target.threadId,
|
|
2042
|
+
name,
|
|
2043
|
+
});
|
|
2044
|
+
return deps.store.renameByTarget(request.target, threadName);
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
export function createTelegramTopicTargetProvisioner(
|
|
2049
|
+
deps: TelegramTopicTargetProvisionerDeps,
|
|
2050
|
+
): (
|
|
2051
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
2052
|
+
) => Promise<TelegramTopicTargetProvisionResult> {
|
|
2053
|
+
const getNowMs = deps.getNowMs ?? (() => 0);
|
|
2054
|
+
const getRandom = deps.getRandom;
|
|
2055
|
+
return async (request) => {
|
|
2056
|
+
normalizeCurrentThreadNameSlots(deps.store);
|
|
2057
|
+
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
2058
|
+
const identity = deps.store.getIdentityByProfileKey(request.profileKey);
|
|
2059
|
+
const nowMs = getNowMs();
|
|
2060
|
+
if (existing && isCurrentThreadRecord(existing)) {
|
|
2061
|
+
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
2062
|
+
const identityThreadName =
|
|
2063
|
+
identity?.threadName &&
|
|
2064
|
+
isTelegramTopicThreadNameValidForSlot(identity.threadName, slot)
|
|
2065
|
+
? identity.threadName
|
|
2066
|
+
: undefined;
|
|
2067
|
+
const bakedThreadName = chooseTelegramThreadName({
|
|
2068
|
+
slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
|
|
2069
|
+
entropy: nowMs,
|
|
2070
|
+
getRandom,
|
|
2071
|
+
});
|
|
2072
|
+
const record = deps.store.upsert({
|
|
2073
|
+
...existing,
|
|
2074
|
+
status: "active",
|
|
2075
|
+
updatedAtMs: nowMs,
|
|
2076
|
+
threadName:
|
|
2077
|
+
existing.threadName ?? identityThreadName ?? bakedThreadName,
|
|
2078
|
+
instanceId: request.instanceId,
|
|
2079
|
+
slot,
|
|
2080
|
+
owner: existing.owner ?? request.owner,
|
|
2081
|
+
lastError: undefined,
|
|
2082
|
+
});
|
|
2083
|
+
return { target: record.target, reused: true, record };
|
|
2084
|
+
}
|
|
2085
|
+
const activeForInstance = deps.store.getActiveByInstanceId(
|
|
2086
|
+
request.instanceId,
|
|
2087
|
+
);
|
|
2088
|
+
if (activeForInstance) {
|
|
2089
|
+
return {
|
|
2090
|
+
target: activeForInstance.target,
|
|
2091
|
+
reused: true,
|
|
2092
|
+
record: activeForInstance,
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
// No profileKey match — try to claim an existing inactive thread before creating another Telegram tab.
|
|
2096
|
+
if (deps.claimPendingTargets !== false) {
|
|
2097
|
+
const claimed = deps.store.claimReusableTarget(
|
|
2098
|
+
request.instanceId,
|
|
2099
|
+
identity?.threadName,
|
|
2100
|
+
);
|
|
2101
|
+
if (claimed) {
|
|
2102
|
+
return { target: claimed.target, reused: true, record: claimed };
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
const candidateThreadName = identity?.threadName;
|
|
2106
|
+
const preferredNameSlot =
|
|
2107
|
+
getTelegramThreadNameLeadingSlot(candidateThreadName) ??
|
|
2108
|
+
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
2109
|
+
request.preferredSlot;
|
|
2110
|
+
const slot =
|
|
2111
|
+
existing?.slot ??
|
|
2112
|
+
(candidateThreadName ? undefined : identity?.slot) ??
|
|
2113
|
+
deps.store.allocateSlot(
|
|
2114
|
+
request.profileKey,
|
|
2115
|
+
request.preferredSlot ?? preferredNameSlot,
|
|
2116
|
+
);
|
|
2117
|
+
const requestThreadName =
|
|
2118
|
+
candidateThreadName &&
|
|
2119
|
+
isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot)
|
|
2120
|
+
? candidateThreadName
|
|
2121
|
+
: chooseTelegramThreadName({ slot, entropy: nowMs, getRandom });
|
|
2122
|
+
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
2123
|
+
const pendingOwner =
|
|
2124
|
+
request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
2125
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
2126
|
+
const pendingBase: TelegramThreadPendingProvision = {
|
|
2127
|
+
id: pendingId,
|
|
2128
|
+
owner: pendingOwner,
|
|
2129
|
+
instanceId: request.instanceId,
|
|
2130
|
+
slot,
|
|
2131
|
+
startedAtMs: nowMs,
|
|
2132
|
+
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
2133
|
+
...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
|
|
2134
|
+
};
|
|
2135
|
+
deps.store.upsertPendingProvision(pendingBase);
|
|
2136
|
+
await deps.store.persist();
|
|
2137
|
+
let threadId: number | undefined;
|
|
2138
|
+
try {
|
|
2139
|
+
const topic = await deps.callApi<TelegramTopicResult>(
|
|
2140
|
+
"createForumTopic",
|
|
2141
|
+
{
|
|
2142
|
+
chat_id: deps.topicChatId,
|
|
2143
|
+
name: getTelegramTopicName(
|
|
2144
|
+
{
|
|
2145
|
+
...request,
|
|
2146
|
+
...(requestThreadName
|
|
2147
|
+
? { threadName: requestThreadName }
|
|
2148
|
+
: {}),
|
|
2149
|
+
},
|
|
2150
|
+
deps.topicNameTemplate ??
|
|
2151
|
+
(requestThreadName ? "{threadName}" : "{slot}"),
|
|
2152
|
+
slot,
|
|
2153
|
+
),
|
|
2154
|
+
},
|
|
2155
|
+
);
|
|
2156
|
+
threadId = topic.message_thread_id;
|
|
2157
|
+
if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
|
|
2158
|
+
throw new Error(
|
|
2159
|
+
"Telegram createForumTopic returned no message_thread_id.",
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
const target = { chatId: deps.topicChatId, threadId };
|
|
2163
|
+
deps.store.upsertPendingProvision({ ...pendingBase, target });
|
|
2164
|
+
await deps.store.persist();
|
|
2165
|
+
deps.store.upsert({
|
|
2166
|
+
profileKey: request.profileKey,
|
|
2167
|
+
owner: request.owner,
|
|
2168
|
+
target,
|
|
2169
|
+
status: "starting",
|
|
2170
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
2171
|
+
updatedAtMs: nowMs,
|
|
2172
|
+
threadName: requestThreadName,
|
|
2173
|
+
instanceId: request.instanceId,
|
|
2174
|
+
slot,
|
|
2175
|
+
});
|
|
2176
|
+
await deps.store.persist();
|
|
2177
|
+
const record = deps.store.upsert({
|
|
2178
|
+
profileKey: request.profileKey,
|
|
2179
|
+
owner: request.owner,
|
|
2180
|
+
target,
|
|
2181
|
+
status: "active",
|
|
2182
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
2183
|
+
updatedAtMs: nowMs,
|
|
2184
|
+
threadName: requestThreadName,
|
|
2185
|
+
instanceId: request.instanceId,
|
|
2186
|
+
slot,
|
|
2187
|
+
});
|
|
2188
|
+
deps.store.removePendingProvision(pendingId);
|
|
2189
|
+
await deps.store.persist();
|
|
2190
|
+
return { target: record.target, reused: false, record };
|
|
2191
|
+
} catch (error) {
|
|
2192
|
+
if (threadId === undefined) {
|
|
2193
|
+
deps.store.removePendingProvision(pendingId);
|
|
2194
|
+
await deps.store.persist();
|
|
2195
|
+
} else {
|
|
2196
|
+
try {
|
|
2197
|
+
await deps.store.persist();
|
|
2198
|
+
} catch {
|
|
2199
|
+
// Keep the original post-create failure visible to the caller.
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
}
|