@llblab/pi-telegram 0.17.5 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +42 -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 +410 -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 +9 -2
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +409 -41
- 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/sync.ts
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram synchronization helpers
|
|
3
|
+
* Zones: Telegram bot reality mirror, demand-driven reconciliation, status diagnostics
|
|
4
|
+
* Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TelegramTarget } from "./target.ts";
|
|
8
|
+
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
9
|
+
import {
|
|
10
|
+
getTelegramTargetFromApiBody,
|
|
11
|
+
isTelegramTopicTargetStaleError,
|
|
12
|
+
provisionOwnBusTopic,
|
|
13
|
+
type TelegramOwnTopicProvisionResult,
|
|
14
|
+
type TelegramTopicTargetStore,
|
|
15
|
+
} from "./threads.ts";
|
|
16
|
+
|
|
17
|
+
export interface TelegramTopicLifecycleSyncUpdate<TMessage = unknown> {
|
|
18
|
+
kind: "created" | "closed" | "reopened";
|
|
19
|
+
target: TelegramTarget & { threadId: number };
|
|
20
|
+
message: TMessage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TelegramLeaderThreadSyncDeps {
|
|
24
|
+
getAllowedUserId: () => number | undefined;
|
|
25
|
+
instanceId: string;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
forceFreshUnnamed?: boolean;
|
|
28
|
+
getNowMs?: () => number;
|
|
29
|
+
getRandom?: () => number;
|
|
30
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
31
|
+
getThreadReconciliationMachineState?: () =>
|
|
32
|
+
| ThreadReconciler.ThreadReconciliationMachineState
|
|
33
|
+
| undefined;
|
|
34
|
+
recordThreadReconciliationPlan?: (
|
|
35
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
36
|
+
) => void;
|
|
37
|
+
topicTargetStore: TelegramTopicTargetStore;
|
|
38
|
+
callApi: <TResponse>(
|
|
39
|
+
method: string,
|
|
40
|
+
body: Record<string, unknown>,
|
|
41
|
+
) => Promise<TResponse>;
|
|
42
|
+
recordEvent: (
|
|
43
|
+
category: string,
|
|
44
|
+
message: unknown,
|
|
45
|
+
details?: Record<string, unknown>,
|
|
46
|
+
) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TelegramTopicLifecycleSyncDeps {
|
|
50
|
+
topicTargetStore: Pick<
|
|
51
|
+
TelegramTopicTargetStore,
|
|
52
|
+
| "load"
|
|
53
|
+
| "list"
|
|
54
|
+
| "listReservations"
|
|
55
|
+
| "listPendingProvisions"
|
|
56
|
+
| "markStaleByTarget"
|
|
57
|
+
| "markActiveByTarget"
|
|
58
|
+
| "removePendingProvision"
|
|
59
|
+
| "persist"
|
|
60
|
+
>;
|
|
61
|
+
isBusEnabled: () => boolean;
|
|
62
|
+
callApi: <TResponse>(
|
|
63
|
+
method: string,
|
|
64
|
+
body: Record<string, unknown>,
|
|
65
|
+
) => Promise<TResponse>;
|
|
66
|
+
isTopicProvisioningActive?: () => boolean;
|
|
67
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
68
|
+
getThreadReconciliationMachineState?: () =>
|
|
69
|
+
| ThreadReconciler.ThreadReconciliationMachineState
|
|
70
|
+
| undefined;
|
|
71
|
+
recordThreadReconciliationPlan?: (
|
|
72
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
73
|
+
) => void;
|
|
74
|
+
recordEvent?: (
|
|
75
|
+
category: string,
|
|
76
|
+
message: unknown,
|
|
77
|
+
details?: Record<string, unknown>,
|
|
78
|
+
) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TelegramTopicLifecycleSyncHandler<TMessage = unknown> = (
|
|
82
|
+
lifecycle: TelegramTopicLifecycleSyncUpdate<TMessage>,
|
|
83
|
+
) => Promise<void>;
|
|
84
|
+
|
|
85
|
+
export interface TelegramObservedTopicLifecycleSyncDeps<TSyncState>
|
|
86
|
+
extends TelegramTopicLifecycleSyncDeps {
|
|
87
|
+
getSyncState: () => TSyncState;
|
|
88
|
+
setSyncState: (state: TSyncState) => void;
|
|
89
|
+
getNowMs?: () => number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface TelegramLeaderHealthRuntimeDeps<TSyncState> {
|
|
93
|
+
getNowMs?: () => number;
|
|
94
|
+
intervalMs?: number;
|
|
95
|
+
callGetMe: () => Promise<unknown>;
|
|
96
|
+
getSyncState: () => TSyncState;
|
|
97
|
+
setSyncState: (state: TSyncState) => void;
|
|
98
|
+
recordEvent: (
|
|
99
|
+
category: string,
|
|
100
|
+
message: unknown,
|
|
101
|
+
details?: Record<string, unknown>,
|
|
102
|
+
) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TelegramLeaderHealthRuntime {
|
|
106
|
+
start: () => void;
|
|
107
|
+
stop: () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
111
|
+
instanceId: string;
|
|
112
|
+
getCurrentThreadRecord: () =>
|
|
113
|
+
| { target: TelegramTarget; instanceId?: string }
|
|
114
|
+
| undefined;
|
|
115
|
+
topicTargetStore: Pick<
|
|
116
|
+
TelegramTopicTargetStore,
|
|
117
|
+
"markOfflineByInstanceId" | "persist"
|
|
118
|
+
>;
|
|
119
|
+
callApi: <TResponse>(
|
|
120
|
+
method: string,
|
|
121
|
+
body: Record<string, unknown>,
|
|
122
|
+
) => Promise<TResponse>;
|
|
123
|
+
getLeaderTarget: () => TelegramTarget | undefined;
|
|
124
|
+
clearLeaderTarget: () => void;
|
|
125
|
+
getSyncState: () => TSyncState;
|
|
126
|
+
setSyncState: (state: TSyncState) => void;
|
|
127
|
+
stopPolling: () => Promise<string>;
|
|
128
|
+
recordRuntimeEvent: (
|
|
129
|
+
category: string,
|
|
130
|
+
error: unknown,
|
|
131
|
+
details?: Record<string, unknown>,
|
|
132
|
+
) => void;
|
|
133
|
+
getNowMs?: () => number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function markTelegramConfigSyncChange<TSyncState extends TelegramSyncState>(
|
|
137
|
+
state: TSyncState,
|
|
138
|
+
action: string,
|
|
139
|
+
options?: { nowMs?: number },
|
|
140
|
+
): TSyncState {
|
|
141
|
+
const nowMs = options?.nowMs ?? Date.now();
|
|
142
|
+
let nextState = markTelegramSyncSliceFresh(state, "pairing", {
|
|
143
|
+
nowMs,
|
|
144
|
+
action,
|
|
145
|
+
}) as TSyncState;
|
|
146
|
+
nextState = markTelegramSyncSliceFresh(nextState, "allowed-user", {
|
|
147
|
+
nowMs,
|
|
148
|
+
action,
|
|
149
|
+
}) as TSyncState;
|
|
150
|
+
nextState = markTelegramSyncSliceFresh(nextState, "bot-identity", {
|
|
151
|
+
nowMs,
|
|
152
|
+
action,
|
|
153
|
+
}) as TSyncState;
|
|
154
|
+
return nextState;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createTelegramManualThreadDisconnectHandler<
|
|
158
|
+
TSyncState extends TelegramSyncState,
|
|
159
|
+
>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
|
|
160
|
+
return async () => {
|
|
161
|
+
const currentRecord = deps.getCurrentThreadRecord();
|
|
162
|
+
if (currentRecord?.target.threadId) {
|
|
163
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
164
|
+
ThreadReconciler.planDisconnectedInstanceThreadCleanup({
|
|
165
|
+
target: currentRecord.target as TelegramTarget & { threadId: number },
|
|
166
|
+
instanceId: deps.instanceId,
|
|
167
|
+
}),
|
|
168
|
+
{
|
|
169
|
+
callApi(method, body) {
|
|
170
|
+
return deps.callApi(method, body);
|
|
171
|
+
},
|
|
172
|
+
persist() {
|
|
173
|
+
return deps.topicTargetStore.persist();
|
|
174
|
+
},
|
|
175
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
if (deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0) {
|
|
179
|
+
await deps.topicTargetStore.persist();
|
|
180
|
+
}
|
|
181
|
+
const leaderTarget = deps.getLeaderTarget();
|
|
182
|
+
if (
|
|
183
|
+
leaderTarget?.chatId === currentRecord.target.chatId &&
|
|
184
|
+
leaderTarget.threadId === currentRecord.target.threadId
|
|
185
|
+
) {
|
|
186
|
+
deps.clearLeaderTarget();
|
|
187
|
+
}
|
|
188
|
+
deps.setSyncState(
|
|
189
|
+
markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
190
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
191
|
+
action: "manual-disconnect",
|
|
192
|
+
}) as TSyncState,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return deps.stopPolling();
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createTelegramLeaderHealthRuntime<
|
|
200
|
+
TSyncState extends TelegramSyncState,
|
|
201
|
+
>(
|
|
202
|
+
deps: TelegramLeaderHealthRuntimeDeps<TSyncState>,
|
|
203
|
+
): TelegramLeaderHealthRuntime {
|
|
204
|
+
const intervalMs = deps.intervalMs ?? 60_000;
|
|
205
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
206
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
207
|
+
|
|
208
|
+
const markFresh = (): void => {
|
|
209
|
+
let state = markTelegramSyncSliceFresh(
|
|
210
|
+
deps.getSyncState(),
|
|
211
|
+
"transport-health",
|
|
212
|
+
{ nowMs: getNowMs(), action: "leader-health-tick" },
|
|
213
|
+
) as TSyncState;
|
|
214
|
+
state = markTelegramSyncSliceFresh(state, "bot-identity", {
|
|
215
|
+
nowMs: getNowMs(),
|
|
216
|
+
action: "leader-health-tick",
|
|
217
|
+
}) as TSyncState;
|
|
218
|
+
deps.setSyncState(state);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const markSuspect = (error: unknown): void => {
|
|
222
|
+
deps.setSyncState(
|
|
223
|
+
markTelegramSyncSliceSuspect(deps.getSyncState(), "transport-health", {
|
|
224
|
+
nowMs: getNowMs(),
|
|
225
|
+
reason: String(error),
|
|
226
|
+
action: "leader-health-tick",
|
|
227
|
+
}) as TSyncState,
|
|
228
|
+
);
|
|
229
|
+
deps.recordEvent("telegram", error, { phase: "leader-health-tick" });
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const stop = (): void => {
|
|
233
|
+
if (!interval) return;
|
|
234
|
+
clearInterval(interval);
|
|
235
|
+
interval = undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
start() {
|
|
240
|
+
stop();
|
|
241
|
+
interval = setInterval(() => {
|
|
242
|
+
void deps.callGetMe().then(markFresh).catch(markSuspect);
|
|
243
|
+
}, intervalMs);
|
|
244
|
+
interval.unref?.();
|
|
245
|
+
},
|
|
246
|
+
stop,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
|
|
251
|
+
topicTargetStore: Pick<
|
|
252
|
+
TelegramTopicTargetStore,
|
|
253
|
+
"load" | "markStaleByTarget" | "persist"
|
|
254
|
+
>;
|
|
255
|
+
getSyncState: () => TSyncState;
|
|
256
|
+
setSyncState: (state: TSyncState) => void;
|
|
257
|
+
recordEvent: (
|
|
258
|
+
category: string,
|
|
259
|
+
message: unknown,
|
|
260
|
+
details?: Record<string, unknown>,
|
|
261
|
+
) => void;
|
|
262
|
+
getNowMs?: () => number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function recoverStaleTelegramTopicApiError<
|
|
266
|
+
TSyncState extends TelegramSyncState,
|
|
267
|
+
>(
|
|
268
|
+
apiBody: unknown,
|
|
269
|
+
error: unknown,
|
|
270
|
+
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
|
|
271
|
+
): Promise<boolean> {
|
|
272
|
+
const target = getTelegramTargetFromApiBody(apiBody);
|
|
273
|
+
if (!target || !isTelegramTopicTargetStaleError(error)) return false;
|
|
274
|
+
await deps.topicTargetStore.load();
|
|
275
|
+
if (
|
|
276
|
+
!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))
|
|
277
|
+
) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
281
|
+
let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
282
|
+
nowMs,
|
|
283
|
+
reason: "stale-api-error",
|
|
284
|
+
action: "topic-target-stale",
|
|
285
|
+
}) as TSyncState;
|
|
286
|
+
state = markTelegramSyncSliceSuspect(state, "transport-health", {
|
|
287
|
+
nowMs,
|
|
288
|
+
reason: "stale-api-error",
|
|
289
|
+
action: "topic-target-stale",
|
|
290
|
+
}) as TSyncState;
|
|
291
|
+
deps.setSyncState(state);
|
|
292
|
+
await deps.topicTargetStore.persist();
|
|
293
|
+
deps.recordEvent("bus", error, {
|
|
294
|
+
phase: "topic-target-stale",
|
|
295
|
+
chatId: target.chatId,
|
|
296
|
+
threadId: target.threadId,
|
|
297
|
+
});
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function ensureTelegramLeaderThreadBinding(
|
|
302
|
+
deps: TelegramLeaderThreadSyncDeps,
|
|
303
|
+
): Promise<TelegramOwnTopicProvisionResult | undefined> {
|
|
304
|
+
await deps.topicTargetStore.load();
|
|
305
|
+
const priorTargets = deps.topicTargetStore
|
|
306
|
+
.list()
|
|
307
|
+
.filter((record) => {
|
|
308
|
+
return (
|
|
309
|
+
record.instanceId === deps.instanceId &&
|
|
310
|
+
(record.status === "active" || record.status === "starting")
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
// Short-circuit: when the instance already has an active thread and we are not
|
|
314
|
+
// force-freshing, reuse it without re-provisioning. A thread belongs to the
|
|
315
|
+
// live instance binding, not to one transient Pi session lifecycle.
|
|
316
|
+
if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
|
|
317
|
+
const record = priorTargets[0];
|
|
318
|
+
deps.recordEvent(
|
|
319
|
+
"telegram",
|
|
320
|
+
"Leader thread preserved after session lifecycle change",
|
|
321
|
+
{
|
|
322
|
+
phase: "leader-thread-reused",
|
|
323
|
+
instanceId: deps.instanceId,
|
|
324
|
+
chatId: record.target.chatId,
|
|
325
|
+
threadId: record.target.threadId,
|
|
326
|
+
slot: record.slot,
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
return {
|
|
330
|
+
target: record.target,
|
|
331
|
+
slot: record.slot ?? "A",
|
|
332
|
+
...(record.threadName ? { threadName: record.threadName } : {}),
|
|
333
|
+
reused: true,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
let forcedUnnamedStale = false;
|
|
337
|
+
if (deps.forceFreshUnnamed) {
|
|
338
|
+
for (const record of priorTargets) {
|
|
339
|
+
const isLeaderOwned =
|
|
340
|
+
record.owner?.kind === "leader" ||
|
|
341
|
+
(!record.owner &&
|
|
342
|
+
(record.profileKey.startsWith("cwd:") ||
|
|
343
|
+
record.profileKey.startsWith("leader:")));
|
|
344
|
+
if (!isLeaderOwned) continue;
|
|
345
|
+
if (record.threadName) continue;
|
|
346
|
+
forcedUnnamedStale =
|
|
347
|
+
deps.topicTargetStore.markStaleByTarget(record.target) ||
|
|
348
|
+
forcedUnnamedStale;
|
|
349
|
+
deps.recordEvent("telegram", "Unnamed leader thread binding refreshed", {
|
|
350
|
+
phase: "leader-thread-force-fresh-unnamed",
|
|
351
|
+
instanceId: deps.instanceId,
|
|
352
|
+
chatId: record.target.chatId,
|
|
353
|
+
threadId: record.target.threadId,
|
|
354
|
+
slot: record.slot,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (forcedUnnamedStale) await deps.topicTargetStore.persist();
|
|
358
|
+
}
|
|
359
|
+
const ownTarget = await provisionOwnBusTopic({
|
|
360
|
+
getAllowedUserId: deps.getAllowedUserId,
|
|
361
|
+
instanceId: deps.instanceId,
|
|
362
|
+
cwd: deps.cwd,
|
|
363
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
364
|
+
getThreadReconciliationMachineState:
|
|
365
|
+
deps.getThreadReconciliationMachineState,
|
|
366
|
+
recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
|
|
367
|
+
store: deps.topicTargetStore,
|
|
368
|
+
callApi: deps.callApi,
|
|
369
|
+
getNowMs: deps.getNowMs,
|
|
370
|
+
getRandom: deps.getRandom,
|
|
371
|
+
recordEvent: deps.recordEvent,
|
|
372
|
+
});
|
|
373
|
+
if (!ownTarget) return undefined;
|
|
374
|
+
const replacementPlan = ThreadReconciler.planThreadReconciliation({
|
|
375
|
+
nowMs: Date.now(),
|
|
376
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
377
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
378
|
+
records: priorTargets,
|
|
379
|
+
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
|
|
380
|
+
replacedBindings: [
|
|
381
|
+
{
|
|
382
|
+
instanceId: deps.instanceId,
|
|
383
|
+
replacementTarget: ownTarget.target,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
});
|
|
387
|
+
deps.recordThreadReconciliationPlan?.(replacementPlan);
|
|
388
|
+
await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
|
|
389
|
+
callApi: deps.callApi,
|
|
390
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
391
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
392
|
+
target,
|
|
393
|
+
syncStatus,
|
|
394
|
+
lastSyncError,
|
|
395
|
+
),
|
|
396
|
+
persist: () => deps.topicTargetStore.persist(),
|
|
397
|
+
removePendingProvisionById: (id) =>
|
|
398
|
+
deps.topicTargetStore.removePendingProvision(id),
|
|
399
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
400
|
+
recordRuntimeEvent: deps.recordEvent,
|
|
401
|
+
});
|
|
402
|
+
await deps.topicTargetStore.persist();
|
|
403
|
+
return ownTarget;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const TELEGRAM_SYNC_SLICES = [
|
|
407
|
+
"bot-identity",
|
|
408
|
+
"bot-capabilities",
|
|
409
|
+
"pairing",
|
|
410
|
+
"allowed-user",
|
|
411
|
+
"topic-capability",
|
|
412
|
+
"topic-state",
|
|
413
|
+
"target-bindings",
|
|
414
|
+
"reservations",
|
|
415
|
+
"transport-health",
|
|
416
|
+
] as const;
|
|
417
|
+
|
|
418
|
+
export type TelegramSyncSlice = (typeof TELEGRAM_SYNC_SLICES)[number];
|
|
419
|
+
|
|
420
|
+
export type TelegramSyncTrigger =
|
|
421
|
+
| "startup"
|
|
422
|
+
| "reload"
|
|
423
|
+
| "topic-lifecycle"
|
|
424
|
+
| "stale-api-error"
|
|
425
|
+
| "setup-change"
|
|
426
|
+
| "pairing-change"
|
|
427
|
+
| "follower-register"
|
|
428
|
+
| "follower-prune"
|
|
429
|
+
| "status-request"
|
|
430
|
+
| "leader-health-tick"
|
|
431
|
+
| "ordinary-message"
|
|
432
|
+
| "ordinary-send";
|
|
433
|
+
|
|
434
|
+
export interface TelegramSyncSliceState {
|
|
435
|
+
status: "fresh" | "suspect" | "unknown";
|
|
436
|
+
updatedAtMs?: number;
|
|
437
|
+
suspectAtMs?: number;
|
|
438
|
+
reason?: string;
|
|
439
|
+
lastReconcileAction?: string;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type TelegramSyncState = Partial<
|
|
443
|
+
Record<TelegramSyncSlice, TelegramSyncSliceState>
|
|
444
|
+
>;
|
|
445
|
+
|
|
446
|
+
export function createUnknownTelegramSyncState(): TelegramSyncState {
|
|
447
|
+
return Object.fromEntries(
|
|
448
|
+
TELEGRAM_SYNC_SLICES.map((slice) => [slice, { status: "unknown" }]),
|
|
449
|
+
) as TelegramSyncState;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
|
|
453
|
+
"startup",
|
|
454
|
+
"reload",
|
|
455
|
+
"topic-lifecycle",
|
|
456
|
+
"stale-api-error",
|
|
457
|
+
"setup-change",
|
|
458
|
+
"pairing-change",
|
|
459
|
+
"follower-register",
|
|
460
|
+
"follower-prune",
|
|
461
|
+
"status-request",
|
|
462
|
+
"leader-health-tick",
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
export function shouldReconcileTelegramSync(
|
|
466
|
+
trigger: TelegramSyncTrigger,
|
|
467
|
+
): boolean {
|
|
468
|
+
return RECONCILE_TRIGGERS.has(trigger);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function markTelegramSyncSliceSuspect(
|
|
472
|
+
state: TelegramSyncState,
|
|
473
|
+
slice: TelegramSyncSlice,
|
|
474
|
+
input: {
|
|
475
|
+
reason: string;
|
|
476
|
+
nowMs: number;
|
|
477
|
+
action?: string;
|
|
478
|
+
},
|
|
479
|
+
): TelegramSyncState {
|
|
480
|
+
return {
|
|
481
|
+
...state,
|
|
482
|
+
[slice]: {
|
|
483
|
+
...(state[slice] ?? { status: "unknown" }),
|
|
484
|
+
status: "suspect",
|
|
485
|
+
suspectAtMs: input.nowMs,
|
|
486
|
+
reason: input.reason,
|
|
487
|
+
lastReconcileAction: input.action,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function markTelegramSyncSliceFresh(
|
|
493
|
+
state: TelegramSyncState,
|
|
494
|
+
slice: TelegramSyncSlice,
|
|
495
|
+
input: {
|
|
496
|
+
nowMs: number;
|
|
497
|
+
action: string;
|
|
498
|
+
},
|
|
499
|
+
): TelegramSyncState {
|
|
500
|
+
return {
|
|
501
|
+
...state,
|
|
502
|
+
[slice]: {
|
|
503
|
+
status: "fresh",
|
|
504
|
+
updatedAtMs: input.nowMs,
|
|
505
|
+
lastReconcileAction: input.action,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function createTelegramObservedTopicLifecycleSyncHandler<
|
|
511
|
+
TMessage = unknown,
|
|
512
|
+
TSyncState extends TelegramSyncState = TelegramSyncState,
|
|
513
|
+
>(
|
|
514
|
+
deps: TelegramObservedTopicLifecycleSyncDeps<TSyncState>,
|
|
515
|
+
): TelegramTopicLifecycleSyncHandler<TMessage> {
|
|
516
|
+
const syncTopicLifecycle = createTelegramTopicLifecycleSyncHandler<TMessage>(
|
|
517
|
+
deps,
|
|
518
|
+
);
|
|
519
|
+
return async (lifecycle) => {
|
|
520
|
+
const nowMs = deps.getNowMs ?? Date.now;
|
|
521
|
+
deps.setSyncState(
|
|
522
|
+
markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
523
|
+
nowMs: nowMs(),
|
|
524
|
+
reason: `topic-${lifecycle.kind}`,
|
|
525
|
+
action: "topic-lifecycle",
|
|
526
|
+
}) as TSyncState,
|
|
527
|
+
);
|
|
528
|
+
await syncTopicLifecycle(lifecycle);
|
|
529
|
+
deps.setSyncState(
|
|
530
|
+
markTelegramSyncSliceFresh(deps.getSyncState(), "topic-state", {
|
|
531
|
+
nowMs: nowMs(),
|
|
532
|
+
action: "topic-lifecycle",
|
|
533
|
+
}) as TSyncState,
|
|
534
|
+
);
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(
|
|
539
|
+
deps: TelegramTopicLifecycleSyncDeps,
|
|
540
|
+
): TelegramTopicLifecycleSyncHandler<TMessage> {
|
|
541
|
+
return async (lifecycle) => {
|
|
542
|
+
await deps.topicTargetStore.load();
|
|
543
|
+
const nowMs = Date.now();
|
|
544
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
545
|
+
nowMs,
|
|
546
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
547
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
548
|
+
records: deps.topicTargetStore.list(),
|
|
549
|
+
reservations: deps.topicTargetStore.listReservations(),
|
|
550
|
+
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
|
|
551
|
+
observations: [
|
|
552
|
+
{
|
|
553
|
+
target: lifecycle.target,
|
|
554
|
+
syncStatus: lifecycle.kind === "closed" ? "closed" : "open",
|
|
555
|
+
observedAtMs: nowMs,
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
});
|
|
559
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
560
|
+
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
561
|
+
markActiveByTarget: (target) =>
|
|
562
|
+
deps.topicTargetStore.markActiveByTarget(target),
|
|
563
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
564
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
565
|
+
target,
|
|
566
|
+
syncStatus,
|
|
567
|
+
lastSyncError,
|
|
568
|
+
),
|
|
569
|
+
persist: () => deps.topicTargetStore.persist(),
|
|
570
|
+
removePendingProvisionById: (id) =>
|
|
571
|
+
deps.topicTargetStore.removePendingProvision(id),
|
|
572
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
573
|
+
recordRuntimeEvent: deps.recordEvent,
|
|
574
|
+
});
|
|
575
|
+
const changed = result.changed;
|
|
576
|
+
if (lifecycle.kind === "created" && deps.isBusEnabled()) {
|
|
577
|
+
const target = lifecycle.target;
|
|
578
|
+
const isKnownInRecords = deps.topicTargetStore
|
|
579
|
+
.list()
|
|
580
|
+
.some((record) => {
|
|
581
|
+
return (
|
|
582
|
+
record.target.chatId === target.chatId &&
|
|
583
|
+
record.target.threadId === target.threadId
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
const isKnownInReservations = deps.topicTargetStore
|
|
587
|
+
.listReservations()
|
|
588
|
+
.some((reservation) => {
|
|
589
|
+
return (
|
|
590
|
+
reservation.target.chatId === target.chatId &&
|
|
591
|
+
reservation.target.threadId === target.threadId
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
if (!isKnownInRecords && !isKnownInReservations) {
|
|
595
|
+
deps.recordEvent?.(
|
|
596
|
+
"telegram",
|
|
597
|
+
deps.isTopicProvisioningActive?.()
|
|
598
|
+
? "Telegram unknown topic creation observed during provisioning"
|
|
599
|
+
: "Telegram unknown topic creation observed",
|
|
600
|
+
{
|
|
601
|
+
phase: deps.isTopicProvisioningActive?.()
|
|
602
|
+
? "topic-lifecycle-provisioning-skip"
|
|
603
|
+
: "topic-lifecycle-unknown-created-observed",
|
|
604
|
+
chatId: target.chatId,
|
|
605
|
+
threadId: target.threadId,
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
deps.recordEvent?.("telegram", "Telegram topic lifecycle update", {
|
|
611
|
+
phase: "topic-lifecycle",
|
|
612
|
+
lifecycle: lifecycle.kind,
|
|
613
|
+
chatId: lifecycle.target.chatId,
|
|
614
|
+
threadId: lifecycle.target.threadId,
|
|
615
|
+
changed,
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
}
|
package/lib/target.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram transport destination value helpers
|
|
3
|
+
* Zones: Bot API transport, routing, replies/previews, ownership, multi-instance bus
|
|
4
|
+
* Owns the minimal `{ chatId, threadId? }` address shape shared by classic private chats
|
|
5
|
+
* and Telegram UI threads mapped through Bot API `message_thread_id`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface TelegramTarget {
|
|
9
|
+
chatId: number;
|
|
10
|
+
threadId?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PRIVATE_TARGET_THREAD_KEY = "private";
|
|
14
|
+
|
|
15
|
+
export function createTelegramPrivateTarget(chatId: number): TelegramTarget {
|
|
16
|
+
return { chatId };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createTelegramThreadTarget(
|
|
20
|
+
chatId: number,
|
|
21
|
+
threadId: number,
|
|
22
|
+
): TelegramTarget {
|
|
23
|
+
return { chatId, threadId };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getTelegramTargetKey(target: TelegramTarget): string {
|
|
27
|
+
return `${target.chatId}:${target.threadId ?? PRIVATE_TARGET_THREAD_KEY}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isTelegramThreadTarget(
|
|
31
|
+
target: TelegramTarget,
|
|
32
|
+
): target is TelegramTarget & { threadId: number } {
|
|
33
|
+
return Number.isInteger(target.threadId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function areTelegramTargetsEqual(
|
|
37
|
+
left: TelegramTarget,
|
|
38
|
+
right: TelegramTarget,
|
|
39
|
+
): boolean {
|
|
40
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getTelegramTargetThreadParams(
|
|
44
|
+
target: TelegramTarget,
|
|
45
|
+
): { message_thread_id?: number } {
|
|
46
|
+
return isTelegramThreadTarget(target)
|
|
47
|
+
? { message_thread_id: target.threadId }
|
|
48
|
+
: {};
|
|
49
|
+
}
|