@llblab/pi-telegram 0.22.1 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -13
- package/BACKLOG.md +1 -51
- package/CHANGELOG.md +41 -19
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +16 -15
- package/docs/locks.md +21 -15
- package/docs/multi-instance-bus.md +17 -16
- package/docs/outbound.md +16 -0
- package/docs/public-api.md +9 -4
- package/index.ts +76 -65
- package/lib/activity.ts +100 -3
- package/lib/bindings.ts +86 -15
- package/lib/bus-follower.ts +205 -17
- package/lib/bus-leader.ts +333 -244
- package/lib/bus.ts +82 -19
- package/lib/commands.ts +28 -4
- package/lib/config.ts +44 -33
- package/lib/media.ts +102 -1
- package/lib/menu-settings.ts +4 -1
- package/lib/outbound-attachments.ts +150 -1
- package/lib/outbound.ts +106 -1
- package/lib/polling.ts +5 -0
- package/lib/queue.ts +41 -48
- package/lib/routing.ts +88 -1
- package/lib/sync.ts +40 -3
- package/lib/telegram-api.ts +77 -13
- package/lib/text-groups.ts +183 -16
- package/lib/thread-reconciler.ts +69 -93
- package/lib/threads.ts +131 -66
- package/lib/turns.ts +102 -13
- package/lib/updates.ts +2 -0
- package/package.json +3 -2
package/lib/text-groups.ts
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { setTimeout as waitForTimeout } from "node:timers/promises";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
extractTelegramMessageText,
|
|
11
|
+
type TelegramMessageForwardOrigin,
|
|
12
|
+
type TelegramMessageUser,
|
|
13
|
+
type TelegramRichMessage,
|
|
14
|
+
} from "./media.ts";
|
|
15
|
+
|
|
7
16
|
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
|
|
8
17
|
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
|
|
9
18
|
const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
|
|
@@ -13,9 +22,13 @@ export interface TelegramTextGroupMessage {
|
|
|
13
22
|
media_group_id?: string;
|
|
14
23
|
chat: { id: number };
|
|
15
24
|
message_thread_id?: number;
|
|
16
|
-
from?: { id
|
|
25
|
+
from?: { id?: number; is_bot?: boolean };
|
|
17
26
|
text?: string;
|
|
18
27
|
caption?: string;
|
|
28
|
+
rich_message?: TelegramRichMessage;
|
|
29
|
+
forward_origin?: TelegramMessageForwardOrigin;
|
|
30
|
+
forward_from?: TelegramMessageUser;
|
|
31
|
+
forward_sender_name?: string;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
@@ -24,10 +37,24 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
|
24
37
|
flushTimer?: ReturnType<typeof setTimeout>;
|
|
25
38
|
dispatching?: boolean;
|
|
26
39
|
suspended?: boolean;
|
|
27
|
-
reschedule?: () => void;
|
|
40
|
+
reschedule?: (delayMs?: number) => void;
|
|
41
|
+
dispatchLimit?: number;
|
|
42
|
+
forwardCommentCandidate?: boolean;
|
|
28
43
|
}
|
|
29
44
|
|
|
45
|
+
export type TelegramForwardCommentBatchPosition = "comment" | "forward";
|
|
46
|
+
|
|
30
47
|
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
48
|
+
prepareUpdateBatch: (
|
|
49
|
+
updates: readonly { message?: TMessage }[],
|
|
50
|
+
) => void;
|
|
51
|
+
getPreparedForwardingPosition: (
|
|
52
|
+
message: TelegramTextGroupMessage,
|
|
53
|
+
) => TelegramForwardCommentBatchPosition | undefined;
|
|
54
|
+
prepareForwardedMessage: (
|
|
55
|
+
message: TelegramTextGroupMessage,
|
|
56
|
+
position: TelegramForwardCommentBatchPosition,
|
|
57
|
+
) => void;
|
|
31
58
|
queueMessage: (options: {
|
|
32
59
|
message: TMessage;
|
|
33
60
|
context: TContext;
|
|
@@ -43,6 +70,7 @@ export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
|
43
70
|
|
|
44
71
|
export interface TelegramTextGroupControllerOptions {
|
|
45
72
|
debounceMs?: number;
|
|
73
|
+
forwardCommentWaitMs?: number | false;
|
|
46
74
|
minSplitLength?: number;
|
|
47
75
|
setTimer?: (
|
|
48
76
|
callback: () => void,
|
|
@@ -66,19 +94,44 @@ export interface TelegramGroupedInputClearerDeps {
|
|
|
66
94
|
function extractTelegramTextGroupText(
|
|
67
95
|
message: TelegramTextGroupMessage,
|
|
68
96
|
): string {
|
|
69
|
-
return
|
|
97
|
+
return extractTelegramMessageText(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isTelegramForwardedMessage(
|
|
101
|
+
message: TelegramTextGroupMessage,
|
|
102
|
+
): boolean {
|
|
103
|
+
return (
|
|
104
|
+
message.forward_origin !== undefined ||
|
|
105
|
+
message.forward_from !== undefined ||
|
|
106
|
+
typeof message.forward_sender_name === "string"
|
|
107
|
+
);
|
|
70
108
|
}
|
|
71
109
|
|
|
72
110
|
function isTelegramTextGroupCommand(text: string): boolean {
|
|
73
111
|
return text.trimStart().startsWith("/");
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
function isTelegramTextGroupClearingCommand(text: string): boolean {
|
|
115
|
+
const command = text.trimStart().split(/\s+/, 1)[0]?.split("@", 1)[0];
|
|
116
|
+
return command === "/stop";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getTelegramTextGroupMessageIdentity(
|
|
120
|
+
message: TelegramTextGroupMessage,
|
|
121
|
+
): string {
|
|
122
|
+
const threadKey =
|
|
123
|
+
typeof message.message_thread_id === "number"
|
|
124
|
+
? `thread:${message.message_thread_id}`
|
|
125
|
+
: "private";
|
|
126
|
+
return `${message.chat.id}:${threadKey}:${message.message_id}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
76
129
|
function getTelegramTextGroupKey(
|
|
77
130
|
message: TelegramTextGroupMessage,
|
|
78
131
|
): string | undefined {
|
|
79
132
|
if (message.media_group_id) return undefined;
|
|
80
133
|
if (!message.from || message.from.is_bot) return undefined;
|
|
81
|
-
if (
|
|
134
|
+
if (!extractTelegramTextGroupText(message)) return undefined;
|
|
82
135
|
const threadKey =
|
|
83
136
|
typeof message.message_thread_id === "number"
|
|
84
137
|
? `thread:${message.message_thread_id}`
|
|
@@ -127,12 +180,20 @@ export function queueTelegramTextGroupMessage<
|
|
|
127
180
|
messages: TMessage[],
|
|
128
181
|
ctx: TContext,
|
|
129
182
|
) => unknown | Promise<unknown>;
|
|
183
|
+
forceStart?: boolean;
|
|
184
|
+
dispatchImmediately?: boolean;
|
|
185
|
+
forwardCommentCandidate?: boolean;
|
|
186
|
+
delayMs?: number;
|
|
130
187
|
}): boolean {
|
|
131
188
|
const key = getTelegramTextGroupKey(options.message);
|
|
132
189
|
if (!key) return false;
|
|
133
190
|
const existing = options.groups.get(key);
|
|
191
|
+
if (existing?.messages.some(
|
|
192
|
+
(message) => message.message_id === options.message.message_id,
|
|
193
|
+
)) return true;
|
|
134
194
|
if (
|
|
135
195
|
!existing &&
|
|
196
|
+
!options.forceStart &&
|
|
136
197
|
!canStartTelegramTextGroup(options.message, options.minSplitLength)
|
|
137
198
|
)
|
|
138
199
|
return false;
|
|
@@ -141,9 +202,8 @@ export function queueTelegramTextGroupMessage<
|
|
|
141
202
|
const state = existing ?? { messages: [] };
|
|
142
203
|
state.messages.push(options.message);
|
|
143
204
|
state.context = options.context;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
state.flushTimer = options.setTimer(() => {
|
|
205
|
+
state.forwardCommentCandidate = options.forwardCommentCandidate;
|
|
206
|
+
const dispatchQueued = (): void => {
|
|
147
207
|
state.flushTimer = undefined;
|
|
148
208
|
const queued = options.groups.get(key);
|
|
149
209
|
if (!queued || queued.context === undefined) return;
|
|
@@ -151,7 +211,9 @@ export function queueTelegramTextGroupMessage<
|
|
|
151
211
|
scheduleDispatch();
|
|
152
212
|
return;
|
|
153
213
|
}
|
|
154
|
-
const
|
|
214
|
+
const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
|
|
215
|
+
queued.dispatchLimit = undefined;
|
|
216
|
+
const dispatchedMessages = queued.messages.slice(0, dispatchCount);
|
|
155
217
|
const dispatchedIds = new Set(
|
|
156
218
|
dispatchedMessages.map((message) => message.message_id),
|
|
157
219
|
);
|
|
@@ -174,12 +236,17 @@ export function queueTelegramTextGroupMessage<
|
|
|
174
236
|
if (!queued.flushTimer) scheduleDispatch();
|
|
175
237
|
},
|
|
176
238
|
);
|
|
177
|
-
|
|
239
|
+
};
|
|
240
|
+
const scheduleDispatch = (delayMs = options.debounceMs): void => {
|
|
241
|
+
if (state.suspended) return;
|
|
242
|
+
state.flushTimer = options.setTimer(dispatchQueued, delayMs);
|
|
178
243
|
state.flushTimer.unref?.();
|
|
179
244
|
};
|
|
180
245
|
state.reschedule = scheduleDispatch;
|
|
181
246
|
if (state.flushTimer) options.clearTimer(state.flushTimer);
|
|
182
|
-
scheduleDispatch(
|
|
247
|
+
scheduleDispatch(
|
|
248
|
+
options.dispatchImmediately ? 0 : (options.delayMs ?? options.debounceMs),
|
|
249
|
+
);
|
|
183
250
|
options.groups.set(key, state);
|
|
184
251
|
return true;
|
|
185
252
|
}
|
|
@@ -191,17 +258,102 @@ export function createTelegramTextGroupController<
|
|
|
191
258
|
options: TelegramTextGroupControllerOptions = {},
|
|
192
259
|
): TelegramTextGroupController<TMessage, TContext> {
|
|
193
260
|
const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
|
|
261
|
+
const plannedForwardCommentStarts = new Set<string>();
|
|
262
|
+
const plannedForwardCommentEnds = new Set<string>();
|
|
194
263
|
const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
|
|
195
264
|
const minSplitLength =
|
|
196
265
|
options.minSplitLength ?? TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH;
|
|
266
|
+
const forwardCommentWaitMs =
|
|
267
|
+
options.forwardCommentWaitMs === undefined
|
|
268
|
+
? debounceMs
|
|
269
|
+
: options.forwardCommentWaitMs;
|
|
197
270
|
const setTimer =
|
|
198
271
|
options.setTimer ??
|
|
199
|
-
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
200
|
-
|
|
201
|
-
|
|
272
|
+
((callback: () => void, ms: number): ReturnType<typeof setTimeout> => {
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
void waitForTimeout(ms, undefined, {
|
|
275
|
+
signal: controller.signal,
|
|
276
|
+
}).then(callback, () => undefined);
|
|
277
|
+
return controller as unknown as ReturnType<typeof setTimeout>;
|
|
278
|
+
});
|
|
279
|
+
const clearTimer =
|
|
280
|
+
options.clearTimer ??
|
|
281
|
+
(options.setTimer
|
|
282
|
+
? clearTimeout
|
|
283
|
+
: (timer: ReturnType<typeof setTimeout>): void => {
|
|
284
|
+
(timer as unknown as AbortController).abort();
|
|
285
|
+
});
|
|
202
286
|
return {
|
|
203
|
-
|
|
204
|
-
|
|
287
|
+
prepareUpdateBatch(updates) {
|
|
288
|
+
for (let index = 0; index + 1 < updates.length; index += 1) {
|
|
289
|
+
const comment = updates[index]?.message;
|
|
290
|
+
const forwarded = updates[index + 1]?.message;
|
|
291
|
+
if (!comment || !forwarded) continue;
|
|
292
|
+
const commentText = extractTelegramTextGroupText(comment);
|
|
293
|
+
const commentKey = getTelegramTextGroupKey(comment);
|
|
294
|
+
const forwardedKey = getTelegramTextGroupKey(forwarded);
|
|
295
|
+
if (
|
|
296
|
+
!commentKey ||
|
|
297
|
+
commentKey !== forwardedKey ||
|
|
298
|
+
!commentText ||
|
|
299
|
+
isTelegramTextGroupCommand(commentText) ||
|
|
300
|
+
isTelegramForwardedMessage(comment) ||
|
|
301
|
+
!isTelegramForwardedMessage(forwarded) ||
|
|
302
|
+
forwarded.message_id <= comment.message_id ||
|
|
303
|
+
forwarded.message_id >
|
|
304
|
+
comment.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP
|
|
305
|
+
) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
plannedForwardCommentStarts.add(
|
|
309
|
+
getTelegramTextGroupMessageIdentity(comment),
|
|
310
|
+
);
|
|
311
|
+
plannedForwardCommentEnds.add(
|
|
312
|
+
getTelegramTextGroupMessageIdentity(forwarded),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
getPreparedForwardingPosition(message) {
|
|
317
|
+
const identity = getTelegramTextGroupMessageIdentity(message);
|
|
318
|
+
if (plannedForwardCommentStarts.has(identity)) return "comment";
|
|
319
|
+
if (plannedForwardCommentEnds.has(identity)) return "forward";
|
|
320
|
+
return undefined;
|
|
321
|
+
},
|
|
322
|
+
prepareForwardedMessage(message, position) {
|
|
323
|
+
const identity = getTelegramTextGroupMessageIdentity(message);
|
|
324
|
+
if (position === "comment") plannedForwardCommentStarts.add(identity);
|
|
325
|
+
else plannedForwardCommentEnds.add(identity);
|
|
326
|
+
},
|
|
327
|
+
queueMessage: ({ message, context, dispatchMessages }) => {
|
|
328
|
+
const identity = getTelegramTextGroupMessageIdentity(message);
|
|
329
|
+
const key = getTelegramTextGroupKey(message);
|
|
330
|
+
const plannedStart = plannedForwardCommentStarts.delete(identity);
|
|
331
|
+
const forwarded = isTelegramForwardedMessage(message);
|
|
332
|
+
const existing = key ? groups.get(key) : undefined;
|
|
333
|
+
const text = extractTelegramTextGroupText(message);
|
|
334
|
+
if (existing && isTelegramTextGroupClearingCommand(text)) {
|
|
335
|
+
if (existing.flushTimer) clearTimer(existing.flushTimer);
|
|
336
|
+
groups.delete(key!);
|
|
337
|
+
}
|
|
338
|
+
const separateFromCandidate =
|
|
339
|
+
!!existing?.forwardCommentCandidate &&
|
|
340
|
+
!forwarded &&
|
|
341
|
+
!isTelegramTextGroupCommand(text);
|
|
342
|
+
if (separateFromCandidate) {
|
|
343
|
+
existing.dispatchLimit = existing.messages.length;
|
|
344
|
+
}
|
|
345
|
+
const forceStart =
|
|
346
|
+
plannedStart ||
|
|
347
|
+
(forwardCommentWaitMs !== false &&
|
|
348
|
+
!forwarded &&
|
|
349
|
+
!!key &&
|
|
350
|
+
typeof message.text === "string" &&
|
|
351
|
+
!isTelegramTextGroupCommand(extractTelegramTextGroupText(message)));
|
|
352
|
+
const dispatchImmediately =
|
|
353
|
+
separateFromCandidate ||
|
|
354
|
+
plannedForwardCommentEnds.delete(identity) ||
|
|
355
|
+
(forwarded && !!key && groups.has(key));
|
|
356
|
+
return queueTelegramTextGroupMessage({
|
|
205
357
|
message,
|
|
206
358
|
context,
|
|
207
359
|
groups,
|
|
@@ -210,7 +362,20 @@ export function createTelegramTextGroupController<
|
|
|
210
362
|
setTimer,
|
|
211
363
|
clearTimer,
|
|
212
364
|
dispatchMessages,
|
|
213
|
-
|
|
365
|
+
forceStart,
|
|
366
|
+
dispatchImmediately,
|
|
367
|
+
forwardCommentCandidate:
|
|
368
|
+
forceStart &&
|
|
369
|
+
!forwarded &&
|
|
370
|
+
!canStartTelegramTextGroup(message, minSplitLength),
|
|
371
|
+
delayMs:
|
|
372
|
+
forceStart && !canStartTelegramTextGroup(message, minSplitLength)
|
|
373
|
+
? forwardCommentWaitMs === false
|
|
374
|
+
? undefined
|
|
375
|
+
: forwardCommentWaitMs
|
|
376
|
+
: undefined,
|
|
377
|
+
});
|
|
378
|
+
},
|
|
214
379
|
suspend: () => {
|
|
215
380
|
for (const state of groups.values()) {
|
|
216
381
|
state.suspended = true;
|
|
@@ -230,6 +395,8 @@ export function createTelegramTextGroupController<
|
|
|
230
395
|
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
231
396
|
}
|
|
232
397
|
groups.clear();
|
|
398
|
+
plannedForwardCommentStarts.clear();
|
|
399
|
+
plannedForwardCommentEnds.clear();
|
|
233
400
|
},
|
|
234
401
|
};
|
|
235
402
|
}
|
package/lib/thread-reconciler.ts
CHANGED
|
@@ -10,7 +10,13 @@ import type { TelegramTarget } from "./target.ts";
|
|
|
10
10
|
type ThreadTarget = TelegramTarget & { threadId: number };
|
|
11
11
|
|
|
12
12
|
type ThreadRecordStatus =
|
|
13
|
-
|
|
13
|
+
| "active"
|
|
14
|
+
| "offline"
|
|
15
|
+
| "stale"
|
|
16
|
+
| "pending"
|
|
17
|
+
| "starting"
|
|
18
|
+
| "probe-required"
|
|
19
|
+
| "failed";
|
|
14
20
|
|
|
15
21
|
export interface ThreadReconciliationRecord {
|
|
16
22
|
target: ThreadTarget;
|
|
@@ -56,11 +62,6 @@ export interface TelegramReservedThreadMessageObservation {
|
|
|
56
62
|
leaderEpoch?: number | string;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
export interface ThreadReservationProbeResult {
|
|
60
|
-
target: ThreadTarget;
|
|
61
|
-
stale: boolean;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
65
|
export interface ReplacedInstanceBindingInput {
|
|
65
66
|
instanceId: string;
|
|
66
67
|
replacementTarget: ThreadTarget;
|
|
@@ -105,14 +106,6 @@ export type ThreadReconciliationAction =
|
|
|
105
106
|
instanceId?: string;
|
|
106
107
|
leaderEpoch?: number | string;
|
|
107
108
|
}
|
|
108
|
-
| {
|
|
109
|
-
kind: "close-delete-pruned-follower-topic";
|
|
110
|
-
target: TelegramTarget & { threadId: number };
|
|
111
|
-
reason: "pruned-follower";
|
|
112
|
-
instanceId?: string;
|
|
113
|
-
messageId?: number;
|
|
114
|
-
leaderEpoch?: number | string;
|
|
115
|
-
}
|
|
116
109
|
| {
|
|
117
110
|
kind: "close-delete-replaced-follower-topic";
|
|
118
111
|
target: TelegramTarget & { threadId: number };
|
|
@@ -144,11 +137,6 @@ export type ThreadReconciliationAction =
|
|
|
144
137
|
pendingProvisionId: string;
|
|
145
138
|
instanceId?: string;
|
|
146
139
|
leaderEpoch?: number | string;
|
|
147
|
-
}
|
|
148
|
-
| {
|
|
149
|
-
kind: "remove-reservation";
|
|
150
|
-
target: TelegramTarget & { threadId: number };
|
|
151
|
-
reason: "reservation-probe-stale";
|
|
152
140
|
};
|
|
153
141
|
|
|
154
142
|
export type ThreadReconciliationPhase =
|
|
@@ -182,6 +170,7 @@ export interface ThreadReconciliationPlan {
|
|
|
182
170
|
|
|
183
171
|
export interface ThreadReconciliationApplyResult {
|
|
184
172
|
changed: boolean;
|
|
173
|
+
incompleteActions?: ThreadReconciliationAction[];
|
|
185
174
|
}
|
|
186
175
|
|
|
187
176
|
export interface ThreadReconciliationApplyPorts {
|
|
@@ -196,7 +185,6 @@ export interface ThreadReconciliationApplyPorts {
|
|
|
196
185
|
lastSyncError?: string,
|
|
197
186
|
) => boolean;
|
|
198
187
|
persist?: () => Promise<void>;
|
|
199
|
-
removeReservationByTarget?: (target: ThreadTarget) => boolean;
|
|
200
188
|
removePendingProvisionById?: (id: string) => boolean;
|
|
201
189
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
202
190
|
recordRuntimeEvent?: (
|
|
@@ -216,8 +204,6 @@ export interface ThreadReconciliationInput {
|
|
|
216
204
|
unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
|
|
217
205
|
reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
|
|
218
206
|
proactiveReservationCleanup?: boolean;
|
|
219
|
-
reservationProbeResults?: readonly ThreadReservationProbeResult[];
|
|
220
|
-
prunedFollowerInstanceIds?: readonly string[];
|
|
221
207
|
replacedBindings?: readonly ReplacedInstanceBindingInput[];
|
|
222
208
|
previousLeaderCleanup?: PreviousLeaderCleanupInput;
|
|
223
209
|
previousState?: ThreadReconciliationMachineState;
|
|
@@ -302,7 +288,6 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
|
302
288
|
action.kind === "close-delete-unbound-topic" ||
|
|
303
289
|
action.kind === "close-delete-reserved-topic" ||
|
|
304
290
|
action.kind === "close-stale-replaced-topic" ||
|
|
305
|
-
action.kind === "close-delete-pruned-follower-topic" ||
|
|
306
291
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
307
292
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
308
293
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
@@ -311,11 +296,7 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
|
311
296
|
}
|
|
312
297
|
|
|
313
298
|
function isSyncAction(action: ThreadReconciliationAction): boolean {
|
|
314
|
-
return
|
|
315
|
-
action.kind === "mark-topic-active" ||
|
|
316
|
-
action.kind === "mark-topic-stale" ||
|
|
317
|
-
action.kind === "remove-reservation"
|
|
318
|
-
);
|
|
299
|
+
return action.kind === "mark-topic-active" || action.kind === "mark-topic-stale";
|
|
319
300
|
}
|
|
320
301
|
|
|
321
302
|
function createThreadReconciliationMachineState(
|
|
@@ -376,7 +357,7 @@ function getActionLeaderEpoch(
|
|
|
376
357
|
return "leaderEpoch" in action ? action.leaderEpoch : undefined;
|
|
377
358
|
}
|
|
378
359
|
|
|
379
|
-
function
|
|
360
|
+
function isTelegramTopicTargetDeletedOrMissingError(error: unknown): boolean {
|
|
380
361
|
if (!(error instanceof Error)) return false;
|
|
381
362
|
const message = error.message.toLowerCase();
|
|
382
363
|
return (
|
|
@@ -384,7 +365,15 @@ function isTelegramTopicTargetGoneError(error: unknown): boolean {
|
|
|
384
365
|
message.includes("message thread not found") ||
|
|
385
366
|
message.includes("thread not found") ||
|
|
386
367
|
message.includes("topic not found") ||
|
|
387
|
-
message.includes("topic deleted")
|
|
368
|
+
message.includes("topic deleted")
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isTelegramTopicTargetGoneError(error: unknown): boolean {
|
|
373
|
+
if (isTelegramTopicTargetDeletedOrMissingError(error)) return true;
|
|
374
|
+
if (!(error instanceof Error)) return false;
|
|
375
|
+
const message = error.message.toLowerCase();
|
|
376
|
+
return (
|
|
388
377
|
message.includes("topic closed") ||
|
|
389
378
|
message.includes("thread closed") ||
|
|
390
379
|
message.includes("forum topic closed") ||
|
|
@@ -503,12 +492,8 @@ export async function applyThreadReconciliationPlan(
|
|
|
503
492
|
): Promise<ThreadReconciliationApplyResult> {
|
|
504
493
|
let shouldPersist = false;
|
|
505
494
|
const persistFences: ThreadReconciliationAction[] = [];
|
|
495
|
+
const incompleteActions: ThreadReconciliationAction[] = [];
|
|
506
496
|
for (const action of plan.actions) {
|
|
507
|
-
if (action.kind === "remove-reservation") {
|
|
508
|
-
shouldPersist =
|
|
509
|
-
ports.removeReservationByTarget?.(action.target) || shouldPersist;
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
497
|
if (action.kind === "mark-topic-active") {
|
|
513
498
|
shouldPersist =
|
|
514
499
|
ports.markActiveByTarget?.(action.target) || shouldPersist;
|
|
@@ -521,7 +506,10 @@ export async function applyThreadReconciliationPlan(
|
|
|
521
506
|
continue;
|
|
522
507
|
}
|
|
523
508
|
if (action.kind === "close-stale-replaced-topic") {
|
|
524
|
-
if (shouldSkipForStaleLeaderEpoch(action, ports))
|
|
509
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
510
|
+
incompleteActions.push(action);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
525
513
|
if (!ports.callApi) {
|
|
526
514
|
ports.recordRuntimeEvent?.(
|
|
527
515
|
"telegram",
|
|
@@ -533,40 +521,49 @@ export async function applyThreadReconciliationPlan(
|
|
|
533
521
|
threadId: action.target.threadId,
|
|
534
522
|
},
|
|
535
523
|
);
|
|
524
|
+
incompleteActions.push(action);
|
|
536
525
|
continue;
|
|
537
526
|
}
|
|
527
|
+
let closeConfirmed = false;
|
|
538
528
|
try {
|
|
539
|
-
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
540
529
|
await ports.callApi("closeForumTopic", {
|
|
541
530
|
chat_id: action.target.chatId,
|
|
542
531
|
message_thread_id: action.target.threadId,
|
|
543
532
|
});
|
|
544
|
-
|
|
545
|
-
const changed =
|
|
546
|
-
ports.markStaleByTarget?.(action.target, "closed") ?? false;
|
|
547
|
-
if (changed) persistFences.push(action);
|
|
548
|
-
shouldPersist = changed || shouldPersist;
|
|
549
|
-
}
|
|
533
|
+
closeConfirmed = true;
|
|
550
534
|
} catch (error) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
535
|
+
closeConfirmed = isTelegramTopicTargetGoneError(error);
|
|
536
|
+
if (!closeConfirmed) {
|
|
537
|
+
ports.recordRuntimeEvent?.("telegram", error, {
|
|
538
|
+
phase: `thread-reconciler-${action.reason}-closeForumTopic`,
|
|
539
|
+
instanceId: action.instanceId,
|
|
540
|
+
chatId: action.target.chatId,
|
|
541
|
+
threadId: action.target.threadId,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
557
544
|
}
|
|
545
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports) || !closeConfirmed) {
|
|
546
|
+
incompleteActions.push(action);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const changed =
|
|
550
|
+
ports.markStaleByTarget?.(action.target, "closed") ?? false;
|
|
551
|
+
if (changed) persistFences.push(action);
|
|
552
|
+
shouldPersist = changed || shouldPersist;
|
|
558
553
|
continue;
|
|
559
554
|
}
|
|
560
555
|
if (
|
|
561
556
|
action.kind === "close-delete-unbound-topic" ||
|
|
562
557
|
action.kind === "close-delete-reserved-topic" ||
|
|
563
|
-
action.kind === "close-delete-pruned-follower-topic" ||
|
|
564
558
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
565
559
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
566
560
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
567
561
|
action.kind === "close-delete-expired-pending-provision-topic"
|
|
568
562
|
) {
|
|
569
|
-
if (shouldSkipForStaleLeaderEpoch(action, ports))
|
|
563
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
564
|
+
incompleteActions.push(action);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
570
567
|
if (!ports.callApi) {
|
|
571
568
|
ports.recordRuntimeEvent?.(
|
|
572
569
|
"telegram",
|
|
@@ -578,6 +575,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
578
575
|
threadId: action.target.threadId,
|
|
579
576
|
},
|
|
580
577
|
);
|
|
578
|
+
incompleteActions.push(action);
|
|
581
579
|
continue;
|
|
582
580
|
}
|
|
583
581
|
let deleteConfirmed = false;
|
|
@@ -590,7 +588,11 @@ export async function applyThreadReconciliationPlan(
|
|
|
590
588
|
});
|
|
591
589
|
if (method === "deleteForumTopic") deleteConfirmed = true;
|
|
592
590
|
} catch (error) {
|
|
593
|
-
|
|
591
|
+
const confirmsMethodOutcome =
|
|
592
|
+
method === "deleteForumTopic"
|
|
593
|
+
? isTelegramTopicTargetDeletedOrMissingError(error)
|
|
594
|
+
: isTelegramTopicTargetGoneError(error);
|
|
595
|
+
if (confirmsMethodOutcome) {
|
|
594
596
|
if (method === "deleteForumTopic") deleteConfirmed = true;
|
|
595
597
|
} else {
|
|
596
598
|
ports.recordRuntimeEvent?.("telegram", error, {
|
|
@@ -601,7 +603,10 @@ export async function applyThreadReconciliationPlan(
|
|
|
601
603
|
}
|
|
602
604
|
}
|
|
603
605
|
}
|
|
604
|
-
if (shouldSkipForStaleLeaderEpoch(action, ports))
|
|
606
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
607
|
+
incompleteActions.push(action);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
605
610
|
if (!deleteConfirmed) {
|
|
606
611
|
ports.recordRuntimeEvent?.(
|
|
607
612
|
"telegram",
|
|
@@ -615,12 +620,12 @@ export async function applyThreadReconciliationPlan(
|
|
|
615
620
|
...("messageId" in action ? { messageId: action.messageId } : {}),
|
|
616
621
|
},
|
|
617
622
|
);
|
|
623
|
+
incompleteActions.push(action);
|
|
618
624
|
continue;
|
|
619
625
|
}
|
|
620
626
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
621
627
|
if (
|
|
622
628
|
action.kind !== "close-delete-previous-leader-topic" &&
|
|
623
|
-
action.kind !== "close-delete-pruned-follower-topic" &&
|
|
624
629
|
action.kind !== "close-delete-replaced-follower-topic" &&
|
|
625
630
|
action.kind !== "close-delete-disconnected-instance-topic" &&
|
|
626
631
|
action.kind !== "close-delete-expired-pending-provision-topic"
|
|
@@ -636,9 +641,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
636
641
|
? "Unbound Telegram topic deleted"
|
|
637
642
|
: action.kind === "close-delete-reserved-topic"
|
|
638
643
|
? "Reserved Telegram topic deleted"
|
|
639
|
-
: action.kind === "close-delete-
|
|
640
|
-
? "Pruned follower Telegram topic deleted"
|
|
641
|
-
: action.kind === "close-delete-replaced-follower-topic"
|
|
644
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
642
645
|
? "Replaced follower Telegram topic deleted"
|
|
643
646
|
: action.kind === "close-delete-previous-leader-topic"
|
|
644
647
|
? "Previous leader Telegram topic deleted"
|
|
@@ -651,9 +654,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
651
654
|
? "thread-reconciler-unbound-topic-delete"
|
|
652
655
|
: action.kind === "close-delete-reserved-topic"
|
|
653
656
|
? "thread-reconciler-reserved-topic-delete"
|
|
654
|
-
: action.kind === "close-delete-
|
|
655
|
-
? "thread-reconciler-pruned-follower-topic-delete"
|
|
656
|
-
: action.kind === "close-delete-replaced-follower-topic"
|
|
657
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
657
658
|
? "thread-reconciler-replaced-follower-topic-delete"
|
|
658
659
|
: action.kind === "close-delete-previous-leader-topic"
|
|
659
660
|
? "thread-reconciler-previous-leader-topic-delete"
|
|
@@ -682,12 +683,18 @@ export async function applyThreadReconciliationPlan(
|
|
|
682
683
|
if (shouldPersist) {
|
|
683
684
|
for (const action of persistFences) {
|
|
684
685
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
685
|
-
return {
|
|
686
|
+
return {
|
|
687
|
+
changed: true,
|
|
688
|
+
...(incompleteActions.length > 0 ? { incompleteActions } : {}),
|
|
689
|
+
};
|
|
686
690
|
}
|
|
687
691
|
}
|
|
688
692
|
await ports.persist?.();
|
|
689
693
|
}
|
|
690
|
-
return {
|
|
694
|
+
return {
|
|
695
|
+
changed: shouldPersist,
|
|
696
|
+
...(incompleteActions.length > 0 ? { incompleteActions } : {}),
|
|
697
|
+
};
|
|
691
698
|
}
|
|
692
699
|
|
|
693
700
|
export function planThreadReconciliation(
|
|
@@ -702,11 +709,6 @@ export function planThreadReconciliation(
|
|
|
702
709
|
.filter(isCurrentRecord)
|
|
703
710
|
.map((record) => targetKey(record.target)),
|
|
704
711
|
);
|
|
705
|
-
const allReservationTargets = new Set(
|
|
706
|
-
(input.reservations ?? []).map((reservation) =>
|
|
707
|
-
targetKey(reservation.target),
|
|
708
|
-
),
|
|
709
|
-
);
|
|
710
712
|
const reservedTargets = new Set(
|
|
711
713
|
(input.reservations ?? [])
|
|
712
714
|
.filter((reservation) => isReservationAlive(reservation, input.nowMs))
|
|
@@ -782,22 +784,6 @@ export function planThreadReconciliation(
|
|
|
782
784
|
}
|
|
783
785
|
}
|
|
784
786
|
|
|
785
|
-
for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
|
|
786
|
-
for (const record of input.records) {
|
|
787
|
-
if (record.instanceId !== prunedInstanceId) continue;
|
|
788
|
-
if (!isActiveOrStartingRecord(record)) continue;
|
|
789
|
-
actions.push({
|
|
790
|
-
kind: "close-delete-pruned-follower-topic",
|
|
791
|
-
target: record.target,
|
|
792
|
-
reason: "pruned-follower",
|
|
793
|
-
instanceId: prunedInstanceId,
|
|
794
|
-
...(input.currentLeaderEpoch !== undefined
|
|
795
|
-
? { leaderEpoch: input.currentLeaderEpoch }
|
|
796
|
-
: {}),
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
787
|
for (const replacement of input.replacedBindings ?? []) {
|
|
802
788
|
for (const record of input.records) {
|
|
803
789
|
if (record.instanceId !== replacement.instanceId) continue;
|
|
@@ -856,16 +842,6 @@ export function planThreadReconciliation(
|
|
|
856
842
|
}
|
|
857
843
|
}
|
|
858
844
|
|
|
859
|
-
for (const probe of input.reservationProbeResults ?? []) {
|
|
860
|
-
if (!probe.stale) continue;
|
|
861
|
-
if (!allReservationTargets.has(targetKey(probe.target))) continue;
|
|
862
|
-
actions.push({
|
|
863
|
-
kind: "remove-reservation",
|
|
864
|
-
target: probe.target,
|
|
865
|
-
reason: "reservation-probe-stale",
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
|
|
869
845
|
for (const message of input.reservedMessages ?? []) {
|
|
870
846
|
const key = targetKey(message.target);
|
|
871
847
|
if (!reservedTargets.has(key)) continue;
|