@llblab/pi-telegram 0.23.0 → 0.23.2

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,10 +4,6 @@
4
4
  * Owns hidden settings-menu rendering, settings callbacks, and persisted toggle wiring
5
5
  */
6
6
 
7
- import {
8
- getTelegramExtensionSettingsRows,
9
- type TelegramSectionRegistry,
10
- } from "./sections.ts";
11
7
  import type {
12
8
  TelegramAssistantRenderingMode,
13
9
  TelegramTimeMode,
@@ -15,6 +11,10 @@ import type {
15
11
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
16
12
  import type { TelegramModelMenuState } from "./menu-model.ts";
17
13
  import type { MenuModel } from "./model.ts";
14
+ import {
15
+ getTelegramExtensionSettingsRows,
16
+ type TelegramSectionRegistry,
17
+ } from "./sections.ts";
18
18
  import type { TelegramVoiceReplyMode } from "./voice.ts";
19
19
 
20
20
  export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
@@ -201,10 +201,9 @@ export function buildVoiceReplyModeSettingsText(
201
201
  "",
202
202
  "Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
203
203
  "",
204
- "<code>-</code> <code>hidden</code> (default): same behavior as 'manual', but no voice policy is added to prompt context.",
205
- "<code>-</code> <code>manual</code>: agent decides; explicit 'telegram_voice' markup still works and reply mode is visible in prompt context.",
206
- "<code>-</code> <code>mirror</code>: voice input prefers a voice reply; text input gracefully follows 'manual' behavior.",
207
- "<code>-</code> <code>always</code>: every reply is converted to voice when delivery succeeds.",
204
+ "<code>-</code> <code>hidden</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
205
+ "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'hidden' behavior.",
206
+ "<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
208
207
  ].join("\n");
209
208
  }
210
209
 
@@ -225,18 +224,19 @@ export function buildTimeInjectionModeSettingsText(
225
224
  export function buildTelegramSettingsMenuReplyMarkup(
226
225
  proactivePushEnabled: boolean,
227
226
  draftPreviewsEnabled: boolean,
228
- assistantRenderingModeOrVoiceReplyMode: TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
227
+ assistantRenderingModeOrVoiceReplyMode:
228
+ TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
229
229
  voiceReplyModeOrTimeInjectionMode: TelegramVoiceReplyMode | TelegramTimeMode,
230
- timeInjectionModeOrSectionRegistry?: TelegramTimeMode | TelegramSectionRegistry,
230
+ timeInjectionModeOrSectionRegistry?:
231
+ TelegramTimeMode | TelegramSectionRegistry,
231
232
  sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
232
233
  voiceReplyModeConfigured = true,
233
234
  ): TelegramSettingsMenuReplyMarkup {
234
235
  const hasRenderingMode =
235
236
  assistantRenderingModeOrVoiceReplyMode === "rich" ||
236
237
  assistantRenderingModeOrVoiceReplyMode === "html";
237
- const assistantRenderingMode: TelegramAssistantRenderingMode = hasRenderingMode
238
- ? assistantRenderingModeOrVoiceReplyMode
239
- : "rich";
238
+ const assistantRenderingMode: TelegramAssistantRenderingMode =
239
+ hasRenderingMode ? assistantRenderingModeOrVoiceReplyMode : "rich";
240
240
  const voiceReplyMode = hasRenderingMode
241
241
  ? (voiceReplyModeOrTimeInjectionMode as TelegramVoiceReplyMode)
242
242
  : (assistantRenderingModeOrVoiceReplyMode as TelegramVoiceReplyMode);
@@ -245,9 +245,9 @@ export function buildTelegramSettingsMenuReplyMarkup(
245
245
  : (voiceReplyModeOrTimeInjectionMode as TelegramTimeMode);
246
246
  const sectionRegistry = hasRenderingMode
247
247
  ? (sectionRegistryOrVoiceReplyModeConfigured as
248
- | TelegramSectionRegistry
249
- | undefined)
250
- : (timeInjectionModeOrSectionRegistry as TelegramSectionRegistry | undefined);
248
+ TelegramSectionRegistry | undefined)
249
+ : (timeInjectionModeOrSectionRegistry as
250
+ TelegramSectionRegistry | undefined);
251
251
  const effectiveVoiceReplyModeConfigured = hasRenderingMode
252
252
  ? voiceReplyModeConfigured
253
253
  : typeof sectionRegistryOrVoiceReplyModeConfigured === "boolean"
@@ -268,7 +268,10 @@ export function buildTelegramSettingsMenuReplyMarkup(
268
268
  {
269
269
  text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
270
270
  getVoiceReplyModeLabel(
271
- getVoiceReplyModeSetting(voiceReplyMode, effectiveVoiceReplyModeConfigured),
271
+ getVoiceReplyModeSetting(
272
+ voiceReplyMode,
273
+ effectiveVoiceReplyModeConfigured,
274
+ ),
272
275
  ),
273
276
  )}`,
274
277
  callback_data: "settings:open:voice-reply",
@@ -361,7 +364,7 @@ export function buildDraftPreviewsSettingsReplyMarkup(
361
364
  },
362
365
  {
363
366
  text: enabled ? "⚫️ Off" : "🟡 Off",
364
- callback_data: "settings:set:draft-previews:off"
367
+ callback_data: "settings:set:draft-previews:off",
365
368
  },
366
369
  ],
367
370
  ],
@@ -407,12 +410,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
407
410
  configured = true,
408
411
  ): TelegramSettingsMenuReplyMarkup {
409
412
  const activeMode = getVoiceReplyModeSetting(mode, configured);
410
- const modes: TelegramVoiceReplyModeSetting[] = [
411
- "hidden",
412
- "manual",
413
- "mirror",
414
- "always",
415
- ];
413
+ const modes: TelegramVoiceReplyModeSetting[] = ["hidden", "mirror", "always"];
416
414
  return {
417
415
  inline_keyboard: [
418
416
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
@@ -511,7 +509,10 @@ export async function handleTelegramSettingsMenuCallbackAction(
511
509
  await deps.answerCallbackQuery(callbackQueryId);
512
510
  return true;
513
511
  }
514
- if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
512
+ if (
513
+ data === "settings:open:draft-previews" ||
514
+ data === "settings:open:rich-drafts"
515
+ ) {
515
516
  await updateDraftPreviewsSettingsMessage(deps);
516
517
  await deps.answerCallbackQuery(callbackQueryId);
517
518
  return true;
@@ -536,12 +537,7 @@ export async function handleTelegramSettingsMenuCallbackAction(
536
537
  }
537
538
  if (data.startsWith("settings:set:voice-reply:")) {
538
539
  const mode = data.slice("settings:set:voice-reply:".length);
539
- if (
540
- mode === "hidden" ||
541
- mode === "manual" ||
542
- mode === "mirror" ||
543
- mode === "always"
544
- ) {
540
+ if (mode === "hidden" || mode === "mirror" || mode === "always") {
545
541
  await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
546
542
  await updateVoiceReplyModeSettingsMessage(deps);
547
543
  await deps.answerCallbackQuery(
@@ -663,79 +659,23 @@ export function createTelegramSettingsMenuRuntime<
663
659
  },
664
660
  sectionRegistry,
665
661
  ),
666
- handleCallbackQuery: async (query) => {
662
+ handleCallbackQuery: async (query, ctx) => {
667
663
  if (!query.data?.startsWith("settings:")) return false;
668
- const state = deps.getStoredModelMenuState(
669
- query.message?.message_id,
670
- query.message?.chat?.id,
671
- );
664
+ const messageId = query.message?.message_id;
665
+ const chatId = query.message?.chat?.id;
666
+ let state = deps.getStoredModelMenuState(messageId, chatId);
672
667
  if (!state) {
673
- const voiceMode = query.data.slice("settings:set:voice-reply:".length);
674
- if (
675
- query.data.startsWith("settings:set:voice-reply:") &&
676
- (voiceMode === "hidden" ||
677
- voiceMode === "manual" ||
678
- voiceMode === "mirror" ||
679
- voiceMode === "always")
680
- ) {
681
- await deps.setVoiceReplyMode(
682
- voiceMode === "hidden" ? undefined : voiceMode,
683
- );
684
- await deps.answerCallbackQuery(
685
- query.id,
686
- `Voice reply mode: ${voiceMode}`,
687
- );
688
- return true;
689
- }
690
- if (
691
- query.data === "settings:set:draft-previews:on" ||
692
- query.data === "settings:set:draft-previews:off" ||
693
- query.data === "settings:set:rich-drafts:on" ||
694
- query.data === "settings:set:rich-drafts:off"
695
- ) {
696
- const enabled = query.data.endsWith(":on");
697
- await deps.setDraftPreviewsEnabled(enabled);
698
- await deps.answerCallbackQuery(
699
- query.id,
700
- `Draft previews ${enabled ? "enabled" : "disabled"}`,
701
- );
702
- return true;
703
- }
704
- if (query.data.startsWith("settings:set:assistant-rendering:")) {
705
- const mode = query.data.slice("settings:set:assistant-rendering:".length);
706
- if (mode === "rich" || mode === "html") {
707
- await deps.setAssistantRenderingMode(mode);
708
- await deps.answerCallbackQuery(query.id, `Rendering: ${mode}`);
709
- return true;
710
- }
711
- }
712
- const hasTimeInjectionPrefix = query.data.startsWith(
713
- "settings:set:time-injection:",
714
- );
715
- const timeMode = hasTimeInjectionPrefix
716
- ? query.data.slice("settings:set:time-injection:".length)
717
- : query.data.slice("settings:set:time:".length);
718
- if (
719
- (hasTimeInjectionPrefix ||
720
- query.data.startsWith("settings:set:time:")) &&
721
- (timeMode === "off" ||
722
- timeMode === "hidden" ||
723
- timeMode === "always" ||
724
- timeMode === "interval")
725
- ) {
726
- const normalizedMode = timeMode === "off" ? "hidden" : timeMode;
727
- await deps.setTimeInjectionMode(normalizedMode);
668
+ if (typeof messageId !== "number" || typeof chatId !== "number") {
728
669
  await deps.answerCallbackQuery(
729
670
  query.id,
730
- `Time injection: ${normalizedMode}`,
671
+ "Interactive message expired.",
731
672
  );
732
673
  return true;
733
674
  }
734
- await deps.answerCallbackQuery(
735
- query.id,
736
- "Interactive message expired.",
737
- );
738
- return true;
675
+ state = await deps.getModelMenuState(chatId, ctx);
676
+ state.messageId = messageId;
677
+ state.mode = "settings";
678
+ deps.storeModelMenuState(state);
739
679
  }
740
680
  return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
741
681
  isProactivePushEnabled: deps.isProactivePushEnabled,
package/lib/prompts.ts CHANGED
@@ -16,7 +16,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
16
16
 
17
17
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
18
18
 
19
- Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload. For voice use a top-level HTML action: \`<!-- telegram_voice: Speak this. -->\`, multiline \`<!-- telegram_voice lang=ru\nSpeak this.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nSpeak this.\n<!-- /telegram_voice -->\`.`;
19
+ Telegram turn note: Call \`telegram_help\` if you need the pi-telegram bridge action contract.`;
20
20
 
21
21
  function buildTelegramHelpText(profileName?: string): string {
22
22
  const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
@@ -26,7 +26,8 @@ How to understand Telegram turns:
26
26
  - \`[telegram|thread:name|from:user|guest:group]\` marks Telegram origin and attributes.
27
27
  - \`thread\` is the visible Thread identity in Threaded Mode; it is not a bus role.
28
28
  - \`[reply]\` is quoted context only; act on the user's current instruction.
29
- - \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context; \`[voice]\` gives reply-mode policy.
29
+ - \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context.
30
+ - \`[voice] delivery: automatic voice\` means pi-telegram will synthesize ordinary assistant text for this turn; no \`[voice]\` line means no automatic voice policy.
30
31
 
31
32
  How to answer Telegram turns:
32
33
  - Reply in concise, scannable mobile Telegram Rich Markdown.
@@ -41,6 +42,7 @@ Assistant-authored Telegram actions:
41
42
  - Keep the complete action at top level and include a non-empty voice payload.
42
43
  - Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
43
44
  - Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
45
+ - Voice reply modes are compact: \`hidden\` emits no automatic context, \`mirror\` emits it for voice/audio input, and \`always\` emits it for every Telegram turn. Explicit \`telegram_voice\` remains available for an intentionally distinct spoken payload.
44
46
  - Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
45
47
  - If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
46
48
 
package/lib/queue.ts CHANGED
@@ -850,14 +850,55 @@ export function createTelegramAgentLifecycleHooks<
850
850
  TReplyMarkup
851
851
  >,
852
852
  ) {
853
+ const onAgentStart = createTelegramAgentStartHook<TTurn, TContext>(deps);
854
+ const deliverAgentEnd = createTelegramAgentEndHook<
855
+ TTurn,
856
+ TContext,
857
+ TMessage,
858
+ TReplyMarkup
859
+ >(deps);
860
+ let retainedErrorEvent: TelegramAgentEndHookEvent<TMessage> | undefined;
853
861
  return {
854
- onAgentStart: createTelegramAgentStartHook<TTurn, TContext>(deps),
855
- onAgentEnd: createTelegramAgentEndHook<
856
- TTurn,
857
- TContext,
858
- TMessage,
859
- TReplyMarkup
860
- >(deps),
862
+ onAgentStart,
863
+ async onAgentEnd(
864
+ event: TelegramAgentEndHookEvent<TMessage>,
865
+ ctx: TContext,
866
+ ): Promise<void> {
867
+ const turn = deps.getActiveTurn();
868
+ const assistant = turn ? deps.extractAssistant(event.messages) : {};
869
+ if (turn && assistant.stopReason === "error") {
870
+ retainedErrorEvent = event;
871
+ deps.recordRuntimeEvent?.(
872
+ "provider-retry",
873
+ new Error("Retained Telegram turn after retryable agent error"),
874
+ { phase: "retained", hasFinalText: !!assistant.text?.trim() },
875
+ );
876
+ return;
877
+ }
878
+ if (retainedErrorEvent) {
879
+ retainedErrorEvent = undefined;
880
+ deps.recordRuntimeEvent?.(
881
+ "provider-retry",
882
+ new Error("Recovered retained Telegram turn after agent retry"),
883
+ { phase: "recovered" },
884
+ );
885
+ }
886
+ await deliverAgentEnd(event, ctx, assistant);
887
+ },
888
+ async onAgentSettled(_event: unknown, ctx: TContext): Promise<void> {
889
+ const event = retainedErrorEvent;
890
+ if (!event) return;
891
+ retainedErrorEvent = undefined;
892
+ deps.recordRuntimeEvent?.(
893
+ "provider-retry",
894
+ new Error("Finalized retained Telegram turn after agent settled"),
895
+ { phase: "settled-failure" },
896
+ );
897
+ await deliverAgentEnd(event, ctx);
898
+ },
899
+ clearRetainedAgentEnd(): void {
900
+ retainedErrorEvent = undefined;
901
+ },
861
902
  ...createTelegramToolExecutionHooks<TContext>(deps),
862
903
  };
863
904
  }
@@ -1132,13 +1173,16 @@ export function createTelegramAgentEndHook<
1132
1173
  return async (
1133
1174
  event: TelegramAgentEndHookEvent<TMessage>,
1134
1175
  ctx: TContext,
1176
+ assistantOverride?: TelegramAgentEndAssistantResult,
1135
1177
  ): Promise<void> => {
1136
1178
  await deps.loadConfig?.();
1137
1179
  if (deps.isSessionActive && !deps.isSessionActive(ctx)) return;
1138
1180
  const turn = deps.getActiveTurn();
1139
1181
  await handleTelegramAgentEndRuntime({
1140
1182
  turn,
1141
- assistant: turn ? deps.extractAssistant(event.messages) : {},
1183
+ assistant:
1184
+ assistantOverride ??
1185
+ (turn ? deps.extractAssistant(event.messages) : {}),
1142
1186
  foldQueuedPromptsIntoHistory: deps.getFoldQueuedPromptsIntoHistory(),
1143
1187
  resetRuntimeState: deps.resetRuntimeState,
1144
1188
  isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
package/lib/routing.ts CHANGED
@@ -9,7 +9,6 @@ import { basename, dirname } from "node:path";
9
9
  import * as Bus from "./bus.ts";
10
10
  import * as Commands from "./commands.ts";
11
11
  import type { TelegramConfigStore } from "./config.ts";
12
- import type { TelegramSectionRegistry } from "./sections.ts";
13
12
  import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
14
13
  import * as Media from "./media.ts";
15
14
  import * as Menu from "./menu.ts";
@@ -18,12 +17,13 @@ import * as OutboundHandlers from "./outbound.ts";
18
17
  import * as PromptTemplates from "./prompt-templates.ts";
19
18
  import * as Queue from "./queue.ts";
20
19
  import type { TelegramBridgeRuntime } from "./runtime.ts";
20
+ import type { TelegramSectionRegistry } from "./sections.ts";
21
21
  import * as TextGroups from "./text-groups.ts";
22
+ import * as ThreadReconciler from "./thread-reconciler.ts";
22
23
  import type {
23
24
  TelegramInstanceThreadIdentityCandidate,
24
25
  TelegramTopicTargetRecord,
25
26
  } from "./threads.ts";
26
- import * as ThreadReconciler from "./thread-reconciler.ts";
27
27
  import * as Turns from "./turns.ts";
28
28
 
29
29
  interface TelegramPromptPeerView {
@@ -408,10 +408,10 @@ function formatTelegramUnboundRerouteChooserText(
408
408
  : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
409
409
  }
410
410
 
411
- import { getTelegramVoiceReplyMode } from "./voice.ts";
412
- import type { TelegramUser } from "./updates.ts";
413
411
  import * as Threads from "./threads.ts";
412
+ import type { TelegramUser } from "./updates.ts";
414
413
  import * as Updates from "./updates.ts";
414
+ import { getTelegramVoiceReplyMode } from "./voice.ts";
415
415
 
416
416
  async function deleteReservedTelegramTopicThroughReconciler(
417
417
  deps: {
@@ -1524,13 +1524,8 @@ export function createTelegramInboundRouteRuntime<
1524
1524
  resolveTimeLine: deps.resolveTimeLine,
1525
1525
  getAllowedUserId: deps.configStore.getAllowedUserId,
1526
1526
 
1527
- // Voice policy for the current turn. Missing config still behaves as manual,
1528
- // but only explicit telegram.json voice.replyMode is shown in prompt context.
1527
+ // Voice policy resolves missing, invalid, and legacy manual config to hidden.
1529
1528
  getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
1530
- isVoiceReplyModeConfigured: () => {
1531
- const mode = deps.configStore.get().voice?.replyMode;
1532
- return mode === "manual" || mode === "mirror" || mode === "always";
1533
- },
1534
1529
  getTelegramThreadLabel(message) {
1535
1530
  if (!deps.threadStore) return undefined;
1536
1531
  const chatId = message.chat.id;
@@ -2490,7 +2485,9 @@ export interface TelegramAssistantOutputAuthorityRuntime<TTransportStamp> {
2490
2485
  canDeliver: () => boolean;
2491
2486
  }
2492
2487
 
2493
- export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(deps: {
2488
+ export function createTelegramAssistantOutputAuthorityRuntime<
2489
+ TTransportStamp,
2490
+ >(deps: {
2494
2491
  getPreferredTarget: () => Queue.TelegramQueueTarget | undefined;
2495
2492
  getFallbackChatId: () => number | undefined;
2496
2493
  getTransportStamp: () => TTransportStamp;
@@ -2509,9 +2506,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
2509
2506
  return {
2510
2507
  captureAuthority() {
2511
2508
  const target = getCurrentTarget();
2512
- const directEpoch = deps.ownsDirect()
2513
- ? deps.getDirectEpoch()
2514
- : undefined;
2509
+ const directEpoch = deps.ownsDirect() ? deps.getDirectEpoch() : undefined;
2515
2510
  const followerGeneration = deps.isFollowerRegistered()
2516
2511
  ? deps.getFollowerGeneration()
2517
2512
  : undefined;
@@ -2540,8 +2535,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
2540
2535
  }
2541
2536
  if (authority.route === "direct") {
2542
2537
  return (
2543
- deps.ownsDirect() &&
2544
- deps.getDirectEpoch() === authority.directEpoch
2538
+ deps.ownsDirect() && deps.getDirectEpoch() === authority.directEpoch
2545
2539
  );
2546
2540
  }
2547
2541
  if (authority.route === "follower") {
package/lib/status.ts CHANGED
@@ -767,7 +767,7 @@ export function buildTelegramStatusBarText(
767
767
  ): string {
768
768
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
769
769
  if (state.error) {
770
- return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
770
+ return `${label} ${theme.fg("error", "error")}`;
771
771
  }
772
772
  const queued = state.queuedStatus
773
773
  ? theme.fg("success", state.queuedStatus)
@@ -39,7 +39,7 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
39
39
  suspended?: boolean;
40
40
  reschedule?: (delayMs?: number) => void;
41
41
  dispatchLimit?: number;
42
- forwardCommentCandidate?: boolean;
42
+ forwardPairCandidate?: TelegramForwardCommentBatchPosition;
43
43
  }
44
44
 
45
45
  export type TelegramForwardCommentBatchPosition = "comment" | "forward";
@@ -131,7 +131,12 @@ function getTelegramTextGroupKey(
131
131
  ): string | undefined {
132
132
  if (message.media_group_id) return undefined;
133
133
  if (!message.from || message.from.is_bot) return undefined;
134
- if (!extractTelegramTextGroupText(message)) return undefined;
134
+ if (
135
+ !extractTelegramTextGroupText(message) &&
136
+ !isTelegramForwardedMessage(message)
137
+ ) {
138
+ return undefined;
139
+ }
135
140
  const threadKey =
136
141
  typeof message.message_thread_id === "number"
137
142
  ? `thread:${message.message_thread_id}`
@@ -160,7 +165,7 @@ function canAppendTelegramTextGroupMessage<
160
165
  message.message_id > previous.message_id &&
161
166
  message.message_id <=
162
167
  previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
163
- text.length > 0 &&
168
+ (text.length > 0 || isTelegramForwardedMessage(message)) &&
164
169
  !isTelegramTextGroupCommand(text)
165
170
  );
166
171
  }
@@ -182,7 +187,7 @@ export function queueTelegramTextGroupMessage<
182
187
  ) => unknown | Promise<unknown>;
183
188
  forceStart?: boolean;
184
189
  dispatchImmediately?: boolean;
185
- forwardCommentCandidate?: boolean;
190
+ forwardPairCandidate?: TelegramForwardCommentBatchPosition;
186
191
  delayMs?: number;
187
192
  }): boolean {
188
193
  const key = getTelegramTextGroupKey(options.message);
@@ -202,7 +207,7 @@ export function queueTelegramTextGroupMessage<
202
207
  const state = existing ?? { messages: [] };
203
208
  state.messages.push(options.message);
204
209
  state.context = options.context;
205
- state.forwardCommentCandidate = options.forwardCommentCandidate;
210
+ state.forwardPairCandidate = options.forwardPairCandidate;
206
211
  const dispatchQueued = (): void => {
207
212
  state.flushTimer = undefined;
208
213
  const queued = options.groups.get(key);
@@ -335,21 +340,30 @@ export function createTelegramTextGroupController<
335
340
  if (existing.flushTimer) clearTimer(existing.flushTimer);
336
341
  groups.delete(key!);
337
342
  }
343
+ const candidatePosition: TelegramForwardCommentBatchPosition = forwarded
344
+ ? "forward"
345
+ : "comment";
346
+ const existingCandidate = existing?.forwardPairCandidate;
347
+ const pairCompleted =
348
+ existingCandidate !== undefined &&
349
+ existingCandidate !== candidatePosition;
338
350
  const separateFromCandidate =
339
- !!existing?.forwardCommentCandidate &&
340
- !forwarded &&
351
+ existingCandidate === candidatePosition &&
341
352
  !isTelegramTextGroupCommand(text);
342
- if (separateFromCandidate) {
353
+ if (separateFromCandidate && existing) {
343
354
  existing.dispatchLimit = existing.messages.length;
344
355
  }
345
356
  const forceStart =
346
357
  plannedStart ||
347
358
  (forwardCommentWaitMs !== false &&
348
- !forwarded &&
349
359
  !!key &&
350
- typeof message.text === "string" &&
351
- !isTelegramTextGroupCommand(extractTelegramTextGroupText(message)));
360
+ (forwarded ||
361
+ (typeof message.text === "string" &&
362
+ !isTelegramTextGroupCommand(
363
+ extractTelegramTextGroupText(message),
364
+ ))));
352
365
  const dispatchImmediately =
366
+ pairCompleted ||
353
367
  separateFromCandidate ||
354
368
  plannedForwardCommentEnds.delete(identity) ||
355
369
  (forwarded && !!key && groups.has(key));
@@ -364,10 +378,10 @@ export function createTelegramTextGroupController<
364
378
  dispatchMessages,
365
379
  forceStart,
366
380
  dispatchImmediately,
367
- forwardCommentCandidate:
368
- forceStart &&
369
- !forwarded &&
370
- !canStartTelegramTextGroup(message, minSplitLength),
381
+ forwardPairCandidate:
382
+ forceStart && !canStartTelegramTextGroup(message, minSplitLength)
383
+ ? candidatePosition
384
+ : undefined,
371
385
  delayMs:
372
386
  forceStart && !canStartTelegramTextGroup(message, minSplitLength)
373
387
  ? forwardCommentWaitMs === false