@llblab/pi-telegram 0.22.1 → 0.23.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.
@@ -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: number; is_bot?: boolean };
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 typeof message.text === "string" ? message.text : "";
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 (typeof message.text !== "string") return undefined;
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
- const scheduleDispatch = (): void => {
145
- if (state.suspended) return;
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 dispatchedMessages = [...queued.messages];
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
- }, options.debounceMs);
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
- setTimeout(callback, ms));
201
- const clearTimer = options.clearTimer ?? clearTimeout;
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
- queueMessage: ({ message, context, dispatchMessages }) =>
204
- queueTelegramTextGroupMessage({
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
  }
@@ -10,7 +10,13 @@ import type { TelegramTarget } from "./target.ts";
10
10
  type ThreadTarget = TelegramTarget & { threadId: number };
11
11
 
12
12
  type ThreadRecordStatus =
13
- "active" | "offline" | "stale" | "pending" | "starting" | "failed";
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;
@@ -182,6 +188,7 @@ export interface ThreadReconciliationPlan {
182
188
 
183
189
  export interface ThreadReconciliationApplyResult {
184
190
  changed: boolean;
191
+ incompleteActions?: ThreadReconciliationAction[];
185
192
  }
186
193
 
187
194
  export interface ThreadReconciliationApplyPorts {
@@ -376,7 +383,7 @@ function getActionLeaderEpoch(
376
383
  return "leaderEpoch" in action ? action.leaderEpoch : undefined;
377
384
  }
378
385
 
379
- function isTelegramTopicTargetGoneError(error: unknown): boolean {
386
+ function isTelegramTopicTargetDeletedOrMissingError(error: unknown): boolean {
380
387
  if (!(error instanceof Error)) return false;
381
388
  const message = error.message.toLowerCase();
382
389
  return (
@@ -384,7 +391,15 @@ function isTelegramTopicTargetGoneError(error: unknown): boolean {
384
391
  message.includes("message thread not found") ||
385
392
  message.includes("thread not found") ||
386
393
  message.includes("topic not found") ||
387
- message.includes("topic deleted") ||
394
+ message.includes("topic deleted")
395
+ );
396
+ }
397
+
398
+ function isTelegramTopicTargetGoneError(error: unknown): boolean {
399
+ if (isTelegramTopicTargetDeletedOrMissingError(error)) return true;
400
+ if (!(error instanceof Error)) return false;
401
+ const message = error.message.toLowerCase();
402
+ return (
388
403
  message.includes("topic closed") ||
389
404
  message.includes("thread closed") ||
390
405
  message.includes("forum topic closed") ||
@@ -503,6 +518,7 @@ export async function applyThreadReconciliationPlan(
503
518
  ): Promise<ThreadReconciliationApplyResult> {
504
519
  let shouldPersist = false;
505
520
  const persistFences: ThreadReconciliationAction[] = [];
521
+ const incompleteActions: ThreadReconciliationAction[] = [];
506
522
  for (const action of plan.actions) {
507
523
  if (action.kind === "remove-reservation") {
508
524
  shouldPersist =
@@ -521,7 +537,10 @@ export async function applyThreadReconciliationPlan(
521
537
  continue;
522
538
  }
523
539
  if (action.kind === "close-stale-replaced-topic") {
524
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
540
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
541
+ incompleteActions.push(action);
542
+ continue;
543
+ }
525
544
  if (!ports.callApi) {
526
545
  ports.recordRuntimeEvent?.(
527
546
  "telegram",
@@ -533,28 +552,35 @@ export async function applyThreadReconciliationPlan(
533
552
  threadId: action.target.threadId,
534
553
  },
535
554
  );
555
+ incompleteActions.push(action);
536
556
  continue;
537
557
  }
558
+ let closeConfirmed = false;
538
559
  try {
539
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
540
560
  await ports.callApi("closeForumTopic", {
541
561
  chat_id: action.target.chatId,
542
562
  message_thread_id: action.target.threadId,
543
563
  });
544
- if (!shouldSkipForStaleLeaderEpoch(action, ports)) {
545
- const changed =
546
- ports.markStaleByTarget?.(action.target, "closed") ?? false;
547
- if (changed) persistFences.push(action);
548
- shouldPersist = changed || shouldPersist;
549
- }
564
+ closeConfirmed = true;
550
565
  } catch (error) {
551
- ports.recordRuntimeEvent?.("telegram", error, {
552
- phase: `thread-reconciler-${action.reason}-closeForumTopic`,
553
- instanceId: action.instanceId,
554
- chatId: action.target.chatId,
555
- threadId: action.target.threadId,
556
- });
566
+ closeConfirmed = isTelegramTopicTargetGoneError(error);
567
+ if (!closeConfirmed) {
568
+ ports.recordRuntimeEvent?.("telegram", error, {
569
+ phase: `thread-reconciler-${action.reason}-closeForumTopic`,
570
+ instanceId: action.instanceId,
571
+ chatId: action.target.chatId,
572
+ threadId: action.target.threadId,
573
+ });
574
+ }
557
575
  }
576
+ if (shouldSkipForStaleLeaderEpoch(action, ports) || !closeConfirmed) {
577
+ incompleteActions.push(action);
578
+ continue;
579
+ }
580
+ const changed =
581
+ ports.markStaleByTarget?.(action.target, "closed") ?? false;
582
+ if (changed) persistFences.push(action);
583
+ shouldPersist = changed || shouldPersist;
558
584
  continue;
559
585
  }
560
586
  if (
@@ -566,7 +592,10 @@ export async function applyThreadReconciliationPlan(
566
592
  action.kind === "close-delete-disconnected-instance-topic" ||
567
593
  action.kind === "close-delete-expired-pending-provision-topic"
568
594
  ) {
569
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
595
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
596
+ incompleteActions.push(action);
597
+ continue;
598
+ }
570
599
  if (!ports.callApi) {
571
600
  ports.recordRuntimeEvent?.(
572
601
  "telegram",
@@ -578,6 +607,7 @@ export async function applyThreadReconciliationPlan(
578
607
  threadId: action.target.threadId,
579
608
  },
580
609
  );
610
+ incompleteActions.push(action);
581
611
  continue;
582
612
  }
583
613
  let deleteConfirmed = false;
@@ -590,7 +620,11 @@ export async function applyThreadReconciliationPlan(
590
620
  });
591
621
  if (method === "deleteForumTopic") deleteConfirmed = true;
592
622
  } catch (error) {
593
- if (isTelegramTopicTargetGoneError(error)) {
623
+ const confirmsMethodOutcome =
624
+ method === "deleteForumTopic"
625
+ ? isTelegramTopicTargetDeletedOrMissingError(error)
626
+ : isTelegramTopicTargetGoneError(error);
627
+ if (confirmsMethodOutcome) {
594
628
  if (method === "deleteForumTopic") deleteConfirmed = true;
595
629
  } else {
596
630
  ports.recordRuntimeEvent?.("telegram", error, {
@@ -601,7 +635,10 @@ export async function applyThreadReconciliationPlan(
601
635
  }
602
636
  }
603
637
  }
604
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
638
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
639
+ incompleteActions.push(action);
640
+ continue;
641
+ }
605
642
  if (!deleteConfirmed) {
606
643
  ports.recordRuntimeEvent?.(
607
644
  "telegram",
@@ -615,6 +652,7 @@ export async function applyThreadReconciliationPlan(
615
652
  ...("messageId" in action ? { messageId: action.messageId } : {}),
616
653
  },
617
654
  );
655
+ incompleteActions.push(action);
618
656
  continue;
619
657
  }
620
658
  if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
@@ -682,12 +720,18 @@ export async function applyThreadReconciliationPlan(
682
720
  if (shouldPersist) {
683
721
  for (const action of persistFences) {
684
722
  if (shouldSkipForStaleLeaderEpoch(action, ports)) {
685
- return { changed: true };
723
+ return {
724
+ changed: true,
725
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
726
+ };
686
727
  }
687
728
  }
688
729
  await ports.persist?.();
689
730
  }
690
- return { changed: shouldPersist };
731
+ return {
732
+ changed: shouldPersist,
733
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
734
+ };
691
735
  }
692
736
 
693
737
  export function planThreadReconciliation(