@llblab/pi-telegram 0.17.2 → 0.17.4

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 CHANGED
@@ -125,7 +125,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
125
125
 
126
126
  ## 6.4 Entrypoint And Import Boundaries
127
127
 
128
- - Keep the preview domain as a thin streaming lifecycle controller only: draft ids, `sendRichMessageDraft`, voice suppression, serialized flushes, diagnostics, and editable fallback-message state. Do not reintroduce assistant preview rendering there; keep `rendering.ts` scoped to bridge-owned UI/compat regular-message rendering rather than assistant or guest Markdown delivery
128
+ - Keep the preview domain as a thin streaming lifecycle controller only: draft ids, safe-prefix selection for `sendRichMessageDraft`, voice suppression, serialized flushes, diagnostics, and finalization state. Do not reintroduce assistant preview rendering there; keep `rendering.ts` scoped to bridge-owned UI/compat regular-message rendering rather than assistant or guest Markdown delivery. Drafts must only send structurally closed Markdown prefixes; draft failures are not proof that drafts are globally unsupported, so record the failure and skip that preview frame. Do not add raw plain-message fallback previews for assistant Markdown
129
129
  - Preview/final delivery ordering is release-critical: finalization must wait for active preview flushes, persisted final delivery should not be followed by a post-final draft-clear call that creates transient draft UI, and regressions should cover in-flight draft flush serialization plus final reply ordering
130
130
  - Live Rich Draft observation: `sendMessageDraft(..., undefined)` after a persisted final Rich Message can appear in Telegram clients as a separate animated three-dot draft block before dissolving. Do not use post-final draft-clear for assistant finalization; let the persisted final `sendRichMessage` replace/complete the user-visible lifecycle and reset local preview state only.
131
131
  - `RichBlockThinking` / `<tg-thinking>` is draft-only (`sendRichMessageDraft`) and may be useful for a future explicit pre-token preloader, but it is not a persisted final-message primitive and should not be mixed into release-critical finalization behavior without separate UX tests.
package/BACKLOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Project Backlog
2
2
 
3
+ - [ ] Track evidence-backed Telegram Rich Markdown normalization follow-ups.
4
+ - Done when: newly observed Telegram parser-breaking sequences are captured from live/client evidence or minimized fixtures.
5
+ - Done when: each confirmed sequence has a conservative normalization or safe-degradation rule covered by tests.
6
+ - Done when: unconfirmed speculative rewrites remain out of the delivery path.
7
+
3
8
  - [ ] Track a public Pi session-replacement hook before adding Telegram `/new`.
4
9
  - [ ] Wait for a safe public Pi API.
5
10
  - Done when: Pi exposes an API that invokes the same session-replacement path as terminal `/new`.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.17.4: Native Rich Markdown Splitter Hotfix
4
+
5
+ - `[Rich Markdown]` Rewrap oversized fenced code, display-math, and fully wrapped inline-formatting blocks when splitting native Rich Markdown at Telegram transport limits. Impact: very long structured Markdown blocks no longer produce invalid partial Rich Markdown chunks, so final assistant replies can stay native without losing long code/math/formatted output.
6
+ - `[Tests]` Added regressions for oversized fenced code, display math, and inline formatting split behavior. Impact: future native splitter changes must preserve structurally valid chunks beyond Telegram's single-message size limit.
7
+
8
+ ## 0.17.3: Native Draft Preview Hotfix
9
+
10
+ - `[Rich Markdown]` Normalize multiline display-math blocks written as `$$` / content / `$$` into Telegram-supported `math` code fences before native Rich Markdown delivery, while preserving literal delimiters inside code fences. Impact: assistant replies following the Telegram prompt guidance for block formulas no longer risk making the whole Rich Markdown message render as raw Markdown.
11
+ - `[Preview]` Removed assistant plain-message preview fallback paths; failed native draft frames are recorded and skipped because partial Markdown can be temporarily invalid while the final answer remains valid. Draft delivery now sends only structurally closed Markdown prefixes, holding back unclosed inline spans, links, fenced code, comments, and display-math blocks until a safe boundary exists. Impact: assistant previews stay on Telegram's native Rich Draft API and no longer create raw-Markdown fallback bubbles.
12
+ - `[Validation]` Live Telegram smoke-tested native draft/final Rich Markdown delivery with sections, lists, code fences, links, `$$` display math, and inline buttons after reload. Impact: the `0.17.3` hotfix behavior is verified in the target Telegram client path, not only by local tests.
4
13
 
5
14
  ## 0.17.2: Indented List Rich Markdown Hotfix
6
15
 
package/README.md CHANGED
@@ -134,7 +134,7 @@ The menu is the primary way to inspect and mutate the queue. Reactions are an ex
134
134
 
135
135
  ### Streaming and native Rich Markdown
136
136
 
137
- Assistant Markdown is sent to Telegram as native Rich Markdown. Streaming previews use Telegram rich-message drafts when available, and final replies persist the same Markdown through `sendRichMessage`. The bridge still strips top-level hidden action comments before delivery and splits only when Telegram transport limits require it.
137
+ Assistant Markdown is sent to Telegram as native Rich Markdown. Streaming previews use Telegram rich-message drafts when a structurally closed Markdown prefix is available, and final replies persist the complete Markdown through `sendRichMessage`. The bridge still strips top-level hidden action comments before delivery and splits only when Telegram transport limits require it.
138
138
 
139
139
  Telegram HTML rendering remains the default for bridge-owned UI surfaces such as commands, menus, status messages, queue controls, and extension sections, where explicit markup is clearer and easier to maintain. Native Rich Markdown is reserved for model-authored Markdown replies and guest replies that naturally arrive as Markdown.
140
140
 
@@ -58,7 +58,7 @@ The repository uses a **Flat Domain DAG**:
58
58
  - `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu-queue` / `menu-settings` / `menu` / `commands`: model identity, thinking levels, scoped model handling, menu render/callback behavior, slash commands, bot commands, and interactive controls.
59
59
  - `sections`: Telegram menu-section registry, opaque section callback tokens, render/callback dispatch, safe section ports, and diagnostics.
60
60
  - `keyboard`: shared inline-keyboard reply-markup shape only; feature domains own labels, callback data, and behavior.
61
- - `preview` / `replies` / `rendering`: throttled native Rich Markdown draft delivery and fallback preview transport, native final reply delivery, reply parameters, transport-limit chunking, and remaining Telegram HTML rendering for bridge-owned UI/compatibility surfaces.
61
+ - `preview` / `replies` / `rendering`: throttled native Rich Markdown draft delivery, native final reply delivery, reply parameters, transport-limit chunking, and remaining Telegram HTML rendering for bridge-owned UI/compatibility surfaces.
62
62
  - `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
63
63
  - `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
64
64
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, and photo/document delivery classification.
@@ -191,15 +191,15 @@ During active Telegram-owned turns, assistant message start/update hooks re-arm
191
191
 
192
192
  ### Rendering And Delivery
193
193
 
194
- Assistant replies use Telegram-native Rich Markdown. Final Markdown is sent directly as `InputRichMessage.markdown` through `sendRichMessage`, streaming previews use `sendRichMessageDraft` when draft delivery is available, and editable fallback previews are finalized through `editMessageText.rich_message`. Guest replies also use native Rich Markdown through `InputRichMessageContent` in `answerGuestQuery` results. The bridge still strips top-level assistant action comments before delivery and may split output only for Telegram transport limits.
194
+ Assistant replies use Telegram-native Rich Markdown. Final Markdown is sent directly as `InputRichMessage.markdown` through `sendRichMessage`, and streaming previews use `sendRichMessageDraft` when draft delivery succeeds. Guest replies also use native Rich Markdown through `InputRichMessageContent` in `answerGuestQuery` results. The bridge still strips top-level assistant action comments before delivery and may split output only for Telegram transport limits.
195
195
 
196
196
  Assistant delivery guarantees:
197
197
 
198
198
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML.
199
199
  - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
200
200
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
201
- - Long native Markdown replies are split only at Telegram Rich Message transport limits.
202
- - Streaming previews pass assistant Markdown through to `sendRichMessageDraft` with ownership checks, voice suppression, serialized flushes, and an editable plain-message fallback when draft delivery is unavailable.
201
+ - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
202
+ - Streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
203
203
  - Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
204
204
 
205
205
  UI/compat rendering guarantees:
@@ -99,7 +99,7 @@ Hidden/default semantics are represented by absence:
99
99
  - Voice Reply `hidden`: no `voice.replyMode` key is persisted.
100
100
  - Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
101
101
 
102
- Assistant Markdown delivery is native: final replies are sent as `InputRichMessage.markdown` via `sendRichMessage`, draft previews use `sendRichMessageDraft` when available, and fallback preview messages are finalized with `editMessageText.rich_message` when they can be edited in place. Guest replies use `InputRichMessageContent` in `answerGuestQuery` results. Bridge-owned UI surfaces such as menus, status, queue controls, commands, and sections keep explicit Telegram HTML/plain rendering by default because those texts are authored by the bridge or companion extensions for Telegram UI. Companion extension sections may explicitly request `"markdown"`, `"html"`, or `"plain"` per view. There is no `telegram.json` rendering toggle for assistant delivery. The bridge sets `skip_entity_detection: true` for assistant and guest Markdown so technical text such as `/commands`, hashtags, URLs, phone numbers, and card-like numbers does not gain unintended automatic entities; explicit Markdown links still belong in the Markdown source.
102
+ Assistant Markdown delivery is native: final replies are sent as `InputRichMessage.markdown` via `sendRichMessage`, and draft previews use `sendRichMessageDraft` when a structurally closed preview frame is available. Draft-frame failures are recorded and skipped rather than converted into raw plain preview messages, because partial Markdown can be temporarily invalid while the final answer remains valid. Long native replies are split at Telegram Rich Message transport limits, with oversized fenced code, display-math, and fully wrapped inline-formatting blocks rewrapped per chunk so persisted chunks remain structurally valid. Guest replies use `InputRichMessageContent` in `answerGuestQuery` results. Bridge-owned UI surfaces such as menus, status, queue controls, commands, and sections keep explicit Telegram HTML/plain rendering by default because those texts are authored by the bridge or companion extensions for Telegram UI. Companion extension sections may explicitly request `"markdown"`, `"html"`, or `"plain"` per view. There is no `telegram.json` rendering toggle for assistant delivery. The bridge sets `skip_entity_detection: true` for assistant and guest Markdown so technical text such as `/commands`, hashtags, URLs, phone numbers, and card-like numbers does not gain unintended automatic entities; explicit Markdown links still belong in the Markdown source.
103
103
 
104
104
  Environment variables are stable only where documented in the README: bot-token bootstrap, proxy behavior, agent root, and inbound/outbound file size limits.
105
105
 
package/index.ts CHANGED
@@ -227,12 +227,7 @@ export default function (pi: Pi.ExtensionAPI) {
227
227
  sendMessageDraft,
228
228
  sendRichMessageDraft,
229
229
  }),
230
- sendMessage,
231
- editMessageText: editTelegramMessageText,
232
230
  sendMarkdownReply,
233
- editMarkdownMessage: Preview.createTelegramNativeMarkdownMessageEditor({
234
- editMessageText: editTelegramMessageText,
235
- }),
236
231
  recordRuntimeEvent,
237
232
  ...replyTransport,
238
233
  });
package/lib/preview.ts CHANGED
@@ -1,35 +1,24 @@
1
1
  /**
2
2
  * Telegram preview streaming helpers
3
- * Zones: telegram outbound, native rich markdown drafts, fallback preview transport
4
- * Owns preview transport selection, runtime updates, and preview finalization
3
+ * Zones: telegram outbound, native rich markdown drafts
4
+ * Owns safe draft preview selection, runtime updates, and preview finalization
5
5
  */
6
6
 
7
- import type {
8
- TelegramEditMessageTextBody,
9
- TelegramReplyParameters,
10
- TelegramSendMessageBody,
11
- TelegramSentMessage,
12
- } from "./telegram-api.ts";
13
- import {
14
- buildTelegramReplyParameters,
15
- normalizeTelegramNativeMarkdown,
16
- } from "./replies.ts";
7
+ import { normalizeTelegramNativeMarkdown } from "./replies.ts";
17
8
  import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
18
9
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
19
10
 
20
11
  const TELEGRAM_PREVIEW_THROTTLE_MS = 0;
21
12
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
22
- const TELEGRAM_FALLBACK_PREVIEW_MESSAGE_MAX_CHARS = 4096;
13
+ const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
23
14
 
24
- export type TelegramDraftSupport = "unknown" | "supported" | "unsupported";
15
+ export type TelegramDraftSupport = "unknown" | "supported";
25
16
 
26
17
  export interface TelegramPreviewState {
27
- mode: "draft" | "message";
18
+ mode: "draft";
28
19
  draftId?: number;
29
- messageId?: number;
30
20
  pendingText: string;
31
21
  lastSentText: string;
32
- lastSentParseMode?: "HTML";
33
22
  }
34
23
 
35
24
  export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
@@ -38,7 +27,6 @@ export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
38
27
  flushRequested?: boolean;
39
28
  }
40
29
 
41
- export type TelegramSentPreviewMessage = TelegramSentMessage;
42
30
  export type TelegramPreviewReplyMarkup = unknown;
43
31
 
44
32
  export interface TelegramPreviewRuntimeDeps<
@@ -61,17 +49,6 @@ export interface TelegramPreviewRuntimeDeps<
61
49
  message_thread_id?: number;
62
50
  },
63
51
  ) => Promise<unknown>;
64
- sendMessage: (
65
- chatId: number,
66
- text: string,
67
- options?: { parseMode?: "HTML" },
68
- ) => Promise<TelegramSentPreviewMessage>;
69
- editMessageText: (
70
- chatId: number,
71
- messageId: number,
72
- text: string,
73
- options?: { parseMode?: "HTML" },
74
- ) => Promise<unknown>;
75
52
  canSend?: () => boolean;
76
53
  recordRuntimeEvent?: (
77
54
  category: string,
@@ -149,18 +126,6 @@ export interface TelegramPreviewControllerDeps<
149
126
  message_thread_id?: number;
150
127
  },
151
128
  ) => Promise<unknown>;
152
- sendMessage: (
153
- chatId: number,
154
- text: string,
155
- options: { parseMode?: "HTML" } | undefined,
156
- replyToMessageId: number | undefined,
157
- ) => Promise<TelegramSentPreviewMessage>;
158
- editMessageText: (
159
- chatId: number,
160
- messageId: number,
161
- text: string,
162
- options?: { parseMode?: "HTML" },
163
- ) => Promise<unknown>;
164
129
  canSend?: () => boolean;
165
130
  throttleMs?: number;
166
131
  maxDraftId?: number;
@@ -190,47 +155,9 @@ export interface TelegramPreviewController<
190
155
  finalize: (chatId: number, replyToMessageId?: number) => Promise<boolean>;
191
156
  }
192
157
 
193
- export interface TelegramPreviewMessageTransportDeps {
194
- sendMessage: (body: TelegramSendMessageBody) => Promise<TelegramSentMessage>;
195
- editMessageText: (body: TelegramEditMessageTextBody) => Promise<unknown>;
196
- buildReplyParameters?: (
197
- chatId: number,
198
- replyToMessageId: number | undefined,
199
- ) => TelegramReplyParameters | undefined;
200
- }
201
-
202
- export function createTelegramPreviewMessageTransport(
203
- deps: TelegramPreviewMessageTransportDeps,
204
- ): Pick<TelegramPreviewControllerDeps, "sendMessage" | "editMessageText"> {
205
- const getReplyParameters =
206
- deps.buildReplyParameters ?? buildTelegramReplyParameters;
207
- return {
208
- sendMessage: (chatId, text, options, replyToMessageId) => {
209
- const replyParameters = getReplyParameters(chatId, replyToMessageId);
210
- return deps.sendMessage({
211
- chat_id: chatId,
212
- text,
213
- parse_mode: options?.parseMode,
214
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
215
- });
216
- },
217
- editMessageText: (chatId, messageId, text, options) =>
218
- deps.editMessageText({
219
- chat_id: chatId,
220
- message_id: messageId,
221
- text,
222
- parse_mode: options?.parseMode,
223
- }),
224
- };
225
- }
226
-
227
158
  export type TelegramPreviewControllerRuntimeDeps<
228
159
  TReplyMarkup = TelegramPreviewReplyMarkup,
229
- > = Omit<
230
- TelegramPreviewControllerDeps<TReplyMarkup>,
231
- "sendMessage" | "editMessageText"
232
- > &
233
- TelegramPreviewMessageTransportDeps;
160
+ > = TelegramPreviewControllerDeps<TReplyMarkup>;
234
161
 
235
162
  export function createTelegramPreviewControllerRuntime<
236
163
  TReplyMarkup = TelegramPreviewReplyMarkup,
@@ -242,11 +169,6 @@ export function createTelegramPreviewControllerRuntime<
242
169
  maxMessageLength: deps.maxMessageLength,
243
170
  initialDraftSupport: deps.initialDraftSupport,
244
171
  sendDraft: deps.sendDraft,
245
- ...createTelegramPreviewMessageTransport({
246
- sendMessage: deps.sendMessage,
247
- editMessageText: deps.editMessageText,
248
- buildReplyParameters: deps.buildReplyParameters,
249
- }),
250
172
  throttleMs: deps.throttleMs,
251
173
  maxDraftId: deps.maxDraftId,
252
174
  setTimer: deps.setTimer,
@@ -268,12 +190,6 @@ export interface TelegramAssistantPreviewRuntimeDeps<
268
190
  markdown: string,
269
191
  options?: { replyMarkup?: TReplyMarkup },
270
192
  ) => Promise<number | undefined>;
271
- editMarkdownMessage?: (
272
- chatId: number,
273
- messageId: number,
274
- markdown: string,
275
- options?: { replyMarkup?: TReplyMarkup },
276
- ) => Promise<unknown>;
277
193
  }
278
194
 
279
195
  export type TelegramAssistantPreviewRuntime<
@@ -289,26 +205,6 @@ export type TelegramAssistantPreviewRuntime<
289
205
  ) => Promise<boolean>;
290
206
  };
291
207
 
292
- export function createTelegramNativeMarkdownMessageEditor<TReplyMarkup>(deps: {
293
- editMessageText: (body: TelegramEditMessageTextBody) => Promise<unknown>;
294
- }): (
295
- chatId: number,
296
- messageId: number,
297
- markdown: string,
298
- options?: { replyMarkup?: TReplyMarkup },
299
- ) => Promise<unknown> {
300
- return (chatId, messageId, markdown, options) =>
301
- deps.editMessageText({
302
- chat_id: chatId,
303
- message_id: messageId,
304
- rich_message: {
305
- markdown: normalizeTelegramNativeMarkdown(markdown),
306
- skip_entity_detection: true,
307
- },
308
- ...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
309
- });
310
- }
311
-
312
208
  export function createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(deps: {
313
209
  getState: () => TelegramPreviewRuntimeState | undefined;
314
210
  clear: (chatId: number) => Promise<void>;
@@ -319,12 +215,6 @@ export function createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(deps:
319
215
  markdown: string,
320
216
  options?: { replyMarkup?: TReplyMarkup },
321
217
  ) => Promise<number | undefined>;
322
- editMarkdownMessage?: (
323
- chatId: number,
324
- messageId: number,
325
- markdown: string,
326
- options?: { replyMarkup?: TReplyMarkup },
327
- ) => Promise<unknown>;
328
218
  }): (
329
219
  chatId: number,
330
220
  markdown: string,
@@ -333,15 +223,6 @@ export function createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(deps:
333
223
  ) => Promise<boolean> {
334
224
  return async (chatId, markdown, replyToMessageId, options) => {
335
225
  const state = deps.getState();
336
- if (state?.mode === "message" && state.messageId !== undefined) {
337
- try {
338
- await deps.editMarkdownMessage?.(chatId, state.messageId, markdown, options);
339
- await deps.clear(chatId);
340
- return true;
341
- } catch {
342
- // Fall through to a fresh native Rich Message so final text is not lost.
343
- }
344
- }
345
226
  if (state?.flushPromise) {
346
227
  await state.flushPromise.catch(() => {});
347
228
  }
@@ -363,7 +244,6 @@ export function createTelegramAssistantPreviewRuntime<
363
244
  clear: controller.clear,
364
245
  discard: () => controller.setState(undefined),
365
246
  sendMarkdownReply: deps.sendMarkdownReply,
366
- editMarkdownMessage: deps.editMarkdownMessage,
367
247
  });
368
248
  return {
369
249
  ...controller,
@@ -396,12 +276,10 @@ export function createTelegramPreviewController<
396
276
  const throttleMs = deps.throttleMs ?? TELEGRAM_PREVIEW_THROTTLE_MS;
397
277
  const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
398
278
  const maxMessageLength =
399
- deps.maxMessageLength ?? TELEGRAM_FALLBACK_PREVIEW_MESSAGE_MAX_CHARS;
279
+ deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
400
280
  let draftSupport = deps.initialDraftSupport ?? "unknown";
401
281
  let nextDraftId = 0;
402
- const getRuntimeDeps = (
403
- replyToMessageId?: number,
404
- ): TelegramPreviewRuntimeDeps<TReplyMarkup> => ({
282
+ const getRuntimeDeps = (): TelegramPreviewRuntimeDeps<TReplyMarkup> => ({
405
283
  getState: () => state,
406
284
  setState: (nextState) => {
407
285
  state = nextState;
@@ -421,14 +299,6 @@ export function createTelegramPreviewController<
421
299
  return nextDraftId;
422
300
  },
423
301
  sendDraft: deps.sendDraft,
424
- sendMessage: (chatId, text, options) =>
425
- deps.sendMessage(
426
- chatId,
427
- text,
428
- options,
429
- replyToMessageId ?? deps.getDefaultReplyToMessageId?.(),
430
- ),
431
- editMessageText: deps.editMessageText,
432
302
  canSend: deps.canSend,
433
303
  recordRuntimeEvent: deps.recordRuntimeEvent,
434
304
  });
@@ -457,8 +327,8 @@ export function createTelegramPreviewController<
457
327
  }, throttleMs);
458
328
  state.flushTimer.unref?.();
459
329
  },
460
- finalize: (chatId, replyToMessageId) =>
461
- finalizeTelegramPreview(chatId, getRuntimeDeps(replyToMessageId)),
330
+ finalize: (chatId, _replyToMessageId) =>
331
+ finalizeTelegramPreview(chatId, getRuntimeDeps()),
462
332
  };
463
333
  }
464
334
 
@@ -534,9 +404,6 @@ export function buildTelegramPreviewFinalText(
534
404
  ): string | undefined {
535
405
  const finalText = state.pendingText.trim();
536
406
  if (finalText) return finalText;
537
- if (state.lastSentParseMode === "HTML") {
538
- return undefined;
539
- }
540
407
  return state.lastSentText.trim() || undefined;
541
408
  }
542
409
 
@@ -544,7 +411,7 @@ export function createTelegramPreviewRuntimeState(
544
411
  draftSupport: TelegramDraftSupport,
545
412
  ): TelegramPreviewRuntimeState {
546
413
  return {
547
- mode: draftSupport === "unsupported" ? "message" : "draft",
414
+ mode: "draft",
548
415
  pendingText: "",
549
416
  lastSentText: "",
550
417
  };
@@ -559,14 +426,13 @@ export function allocateTelegramDraftId(
559
426
 
560
427
  interface TelegramNativeMarkdownPreviewSnapshot {
561
428
  text: string;
562
- sourceText: string;
563
429
  }
564
430
 
565
- export function shouldUseTelegramDraftPreview(options: {
431
+ export function shouldUseTelegramDraftPreview(_options: {
566
432
  draftSupport: TelegramDraftSupport;
567
433
  snapshot?: TelegramNativeMarkdownPreviewSnapshot;
568
434
  }): boolean {
569
- return options.draftSupport !== "unsupported";
435
+ return true;
570
436
  }
571
437
 
572
438
  export async function clearTelegramPreview<
@@ -598,19 +464,223 @@ export async function clearTelegramPreview<
598
464
  }
599
465
  }
600
466
 
467
+ interface TelegramDraftInlineState {
468
+ codeTicks: number;
469
+ htmlComment: boolean;
470
+ displayMath: boolean;
471
+ fence?: { marker: "`" | "~"; length: number };
472
+ strongAsterisk: boolean;
473
+ emphasisAsterisk: boolean;
474
+ strongUnderscore: boolean;
475
+ emphasisUnderscore: boolean;
476
+ strike: boolean;
477
+ linkText: boolean;
478
+ linkDestination: boolean;
479
+ }
480
+
481
+ function createTelegramDraftInlineState(): TelegramDraftInlineState {
482
+ return {
483
+ codeTicks: 0,
484
+ htmlComment: false,
485
+ displayMath: false,
486
+ strongAsterisk: false,
487
+ emphasisAsterisk: false,
488
+ strongUnderscore: false,
489
+ emphasisUnderscore: false,
490
+ strike: false,
491
+ linkText: false,
492
+ linkDestination: false,
493
+ };
494
+ }
495
+
496
+ function isTelegramDraftInlineStateClosed(state: TelegramDraftInlineState): boolean {
497
+ return state.codeTicks === 0 &&
498
+ !state.htmlComment &&
499
+ !state.displayMath &&
500
+ !state.fence &&
501
+ !state.strongAsterisk &&
502
+ !state.emphasisAsterisk &&
503
+ !state.strongUnderscore &&
504
+ !state.emphasisUnderscore &&
505
+ !state.strike &&
506
+ !state.linkText &&
507
+ !state.linkDestination;
508
+ }
509
+
510
+ function countRepeatedChars(text: string, index: number, char: string): number {
511
+ let count = 0;
512
+ while (text[index + count] === char) count += 1;
513
+ return count;
514
+ }
515
+
516
+ function isEscapedMarkdownChar(text: string, index: number): boolean {
517
+ let slashCount = 0;
518
+ for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor -= 1) {
519
+ slashCount += 1;
520
+ }
521
+ return slashCount % 2 === 1;
522
+ }
523
+
524
+ function isInlineDelimiterCandidate(text: string, index: number, length: number): boolean {
525
+ const previous = text[index - 1] ?? "";
526
+ const next = text[index + length] ?? "";
527
+ if (!next || /\s/.test(next)) return previous.length > 0 && !/\s/.test(previous);
528
+ if (!previous || /\s/.test(previous)) return true;
529
+ return /[\p{P}\p{S}]/u.test(previous) || /[\p{P}\p{S}]/u.test(next);
530
+ }
531
+
532
+ function updateTelegramDraftInlineStateForLine(
533
+ line: string,
534
+ state: TelegramDraftInlineState,
535
+ ): void {
536
+ if (state.fence || state.displayMath) return;
537
+ for (let index = 0; index < line.length; index += 1) {
538
+ if (state.htmlComment) {
539
+ const closeIndex = line.indexOf("-->", index);
540
+ if (closeIndex === -1) return;
541
+ state.htmlComment = false;
542
+ index = closeIndex + 2;
543
+ continue;
544
+ }
545
+ if (state.codeTicks > 0) {
546
+ const ticks = countRepeatedChars(line, index, "`");
547
+ if (ticks >= state.codeTicks) {
548
+ state.codeTicks = 0;
549
+ index += ticks - 1;
550
+ }
551
+ continue;
552
+ }
553
+ if (isEscapedMarkdownChar(line, index)) continue;
554
+ if (line.startsWith("<!--", index)) {
555
+ const closeIndex = line.indexOf("-->", index + 4);
556
+ if (closeIndex === -1) {
557
+ state.htmlComment = true;
558
+ return;
559
+ }
560
+ index = closeIndex + 2;
561
+ continue;
562
+ }
563
+ const ticks = countRepeatedChars(line, index, "`");
564
+ if (ticks > 0) {
565
+ state.codeTicks = ticks;
566
+ index += ticks - 1;
567
+ continue;
568
+ }
569
+ if (line.startsWith("][", index) || line.startsWith("](", index)) {
570
+ state.linkText = false;
571
+ state.linkDestination = true;
572
+ index += 1;
573
+ continue;
574
+ }
575
+ if (line[index] === "[" && !state.linkDestination) {
576
+ state.linkText = true;
577
+ continue;
578
+ }
579
+ if (line[index] === ")" && state.linkDestination) {
580
+ state.linkDestination = false;
581
+ continue;
582
+ }
583
+ if (line.startsWith("~~", index) && isInlineDelimiterCandidate(line, index, 2)) {
584
+ state.strike = !state.strike;
585
+ index += 1;
586
+ continue;
587
+ }
588
+ if (line.startsWith("**", index) && isInlineDelimiterCandidate(line, index, 2)) {
589
+ state.strongAsterisk = !state.strongAsterisk;
590
+ index += 1;
591
+ continue;
592
+ }
593
+ if (line[index] === "*" && isInlineDelimiterCandidate(line, index, 1)) {
594
+ state.emphasisAsterisk = !state.emphasisAsterisk;
595
+ continue;
596
+ }
597
+ if (line.startsWith("__", index) && isInlineDelimiterCandidate(line, index, 2)) {
598
+ state.strongUnderscore = !state.strongUnderscore;
599
+ index += 1;
600
+ continue;
601
+ }
602
+ if (line[index] === "_" && isInlineDelimiterCandidate(line, index, 1)) {
603
+ state.emphasisUnderscore = !state.emphasisUnderscore;
604
+ }
605
+ }
606
+ }
607
+
608
+ function updateTelegramDraftBlockStateForLine(
609
+ line: string,
610
+ state: TelegramDraftInlineState,
611
+ ): boolean {
612
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
613
+ if (state.fence) {
614
+ if (new RegExp(`^ {0,3}${state.fence.marker}{${state.fence.length},}\\s*$`).test(line)) {
615
+ state.fence = undefined;
616
+ }
617
+ return true;
618
+ }
619
+ if (state.displayMath) {
620
+ if (line.trim() === "$$") state.displayMath = false;
621
+ return true;
622
+ }
623
+ if (fenceMatch) {
624
+ const markerText = fenceMatch[1] ?? "```";
625
+ state.fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
626
+ return true;
627
+ }
628
+ if (line.trim() === "$$") {
629
+ state.displayMath = true;
630
+ return true;
631
+ }
632
+ return false;
633
+ }
634
+
635
+ function findSafeTelegramRichMarkdownDraftEnd(markdown: string): number {
636
+ const state = createTelegramDraftInlineState();
637
+ let offset = 0;
638
+ let safeEnd = 0;
639
+ for (const line of markdown.split("\n")) {
640
+ const lineEnd = offset + line.length;
641
+ const consumedAsBlock = updateTelegramDraftBlockStateForLine(line, state);
642
+ if (!consumedAsBlock) updateTelegramDraftInlineStateForLine(line, state);
643
+ const nextOffset = lineEnd + 1;
644
+ if (isTelegramDraftInlineStateClosed(state)) safeEnd = lineEnd;
645
+ offset = nextOffset;
646
+ }
647
+ if (isTelegramDraftInlineStateClosed(state)) return markdown.length;
648
+ return safeEnd;
649
+ }
650
+
651
+ export function getSafeTelegramRichMarkdownDraftPrefix(
652
+ markdown: string,
653
+ maxMessageLength: number,
654
+ ): string | undefined {
655
+ const source = markdown.trim();
656
+ if (!source) return undefined;
657
+ const limited = source.length > maxMessageLength
658
+ ? source.slice(0, maxMessageLength)
659
+ : source;
660
+ const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
661
+ if (safeEnd > 0) return limited.slice(0, safeEnd).trimEnd() || undefined;
662
+ let candidateEnd = limited.length;
663
+ while (candidateEnd > 0) {
664
+ candidateEnd = limited.lastIndexOf(" ", candidateEnd - 1);
665
+ if (candidateEnd <= 0) return undefined;
666
+ const candidate = limited.slice(0, candidateEnd).trimEnd();
667
+ if (findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length) {
668
+ return candidate || undefined;
669
+ }
670
+ }
671
+ return undefined;
672
+ }
673
+
601
674
  function buildTelegramNativeMarkdownPreviewSnapshot(
602
675
  state: TelegramPreviewState,
603
676
  maxMessageLength: number,
604
677
  ): TelegramNativeMarkdownPreviewSnapshot | undefined {
605
- const sourceText = state.pendingText.trim();
606
- if (!sourceText || sourceText === state.lastSentText) return undefined;
607
- return {
608
- text:
609
- sourceText.length > maxMessageLength
610
- ? sourceText.slice(0, maxMessageLength)
611
- : sourceText,
612
- sourceText,
613
- };
678
+ const safeText = getSafeTelegramRichMarkdownDraftPrefix(
679
+ state.pendingText,
680
+ maxMessageLength,
681
+ );
682
+ if (!safeText || safeText === state.lastSentText) return undefined;
683
+ return { text: safeText };
614
684
  }
615
685
 
616
686
  async function performTelegramPreviewFlush<
@@ -641,29 +711,21 @@ async function performTelegramPreviewFlush<
641
711
  await deps.sendDraft(
642
712
  chatId,
643
713
  draftId,
644
- normalizeTelegramNativeMarkdown(snapshot.sourceText),
714
+ normalizeTelegramNativeMarkdown(snapshot.text),
645
715
  );
646
716
  deps.setDraftSupport("supported");
647
717
  state.mode = "draft";
648
718
  state.lastSentText = snapshot.text;
649
- state.lastSentParseMode = undefined;
650
719
  return;
651
- } catch {
652
- deps.setDraftSupport("unsupported");
720
+ } catch (error) {
721
+ deps.recordRuntimeEvent?.("preview", error, {
722
+ phase: "draft",
723
+ chatId,
724
+ draftId,
725
+ });
726
+ return;
653
727
  }
654
728
  }
655
- if (state.messageId === undefined) {
656
- const sent = await deps.sendMessage(chatId, snapshot.text, undefined);
657
- state.messageId = sent.message_id;
658
- state.mode = "message";
659
- state.lastSentText = snapshot.text;
660
- state.lastSentParseMode = undefined;
661
- return;
662
- }
663
- await deps.editMessageText(chatId, state.messageId, snapshot.text, undefined);
664
- state.mode = "message";
665
- state.lastSentText = snapshot.text;
666
- state.lastSentParseMode = undefined;
667
729
  }
668
730
 
669
731
  export async function flushTelegramPreview<
@@ -689,7 +751,7 @@ export async function flushTelegramPreview<
689
751
  deps.recordRuntimeEvent?.("preview", error, {
690
752
  phase: "flush",
691
753
  chatId,
692
- messageId: state.messageId,
754
+ draftId: state.draftId,
693
755
  });
694
756
  break;
695
757
  }
@@ -722,11 +784,6 @@ export async function finalizeTelegramPreview<
722
784
  await clearTelegramPreview(chatId, deps);
723
785
  return false;
724
786
  }
725
- if (state.mode === "draft") {
726
- await deps.sendMessage(chatId, finalText);
727
- deps.setState(undefined);
728
- return true;
729
- }
730
787
  deps.setState(undefined);
731
- return state.messageId !== undefined;
788
+ return false;
732
789
  }
package/lib/replies.ts CHANGED
@@ -286,13 +286,46 @@ function normalizeTelegramNativeMarkdownLine(line: string): string {
286
286
  return result.replace(/\u0000(\d+)\u0000/g, (_match, index) => codeSpans[Number(index)] ?? "");
287
287
  }
288
288
 
289
+ function hasClosingDisplayMathDelimiter(lines: readonly string[], startIndex: number): boolean {
290
+ let fence: { marker: "`" | "~"; length: number } | undefined;
291
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
292
+ const line = lines[index] ?? "";
293
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
294
+ if (!fence && line.trim() === "$$") return true;
295
+ if (!fence && fenceMatch) {
296
+ const markerText = fenceMatch[1] ?? "```";
297
+ fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
298
+ continue;
299
+ }
300
+ if (
301
+ fence &&
302
+ new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
303
+ ) {
304
+ fence = undefined;
305
+ }
306
+ }
307
+ return false;
308
+ }
309
+
289
310
  export function normalizeTelegramNativeMarkdown(markdown: string): string {
290
311
  const lines = markdown.replace(/\r\n/g, "\n").split("\n");
291
312
  let fence: { marker: "`" | "~"; length: number } | undefined;
313
+ let displayMath = false;
292
314
  return lines
293
- .map((line) => {
315
+ .map((line, index) => {
294
316
  const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
295
317
  const inFence = fence !== undefined;
318
+ if (!inFence && line.trim() === "$$") {
319
+ if (displayMath) {
320
+ displayMath = false;
321
+ return "```";
322
+ }
323
+ if (hasClosingDisplayMathDelimiter(lines, index)) {
324
+ displayMath = true;
325
+ return "```math";
326
+ }
327
+ }
328
+ if (displayMath) return line;
296
329
  if (!inFence && fenceMatch) {
297
330
  const markerText = fenceMatch[1] ?? "```";
298
331
  fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
@@ -423,6 +456,12 @@ function countTelegramNativeMarkdownBlocks(block: string): number {
423
456
  }
424
457
 
425
458
  function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
459
+ return splitTelegramNativeMarkdownLongFenceBlock(block) ??
460
+ splitTelegramNativeMarkdownLongWrappedInlineBlock(block) ??
461
+ splitTelegramNativeMarkdownLongPlainBlock(block);
462
+ }
463
+
464
+ function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
426
465
  const chunks: string[] = [];
427
466
  let remaining = block;
428
467
  while (remaining.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS) {
@@ -435,8 +474,70 @@ function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
435
474
  return chunks;
436
475
  }
437
476
 
438
- function findTelegramNativeMarkdownSplitIndex(text: string): number {
439
- const hardLimit = TELEGRAM_RICH_MESSAGE_MAX_CHARS;
477
+ function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | undefined {
478
+ const lines = block.split("\n");
479
+ const opening = lines[0] ?? "";
480
+ const closing = lines[lines.length - 1] ?? "";
481
+ const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/);
482
+ if (!openingMatch || !closing || lines.length < 2) return undefined;
483
+ const markerText = openingMatch[1] ?? "```";
484
+ const marker = markerText[0] as "`" | "~";
485
+ if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
486
+ return undefined;
487
+ }
488
+ const maxContentLength = TELEGRAM_RICH_MESSAGE_MAX_CHARS -
489
+ opening.length -
490
+ closing.length -
491
+ 2;
492
+ if (maxContentLength <= 0) return undefined;
493
+ const content = lines.slice(1, -1).join("\n");
494
+ return splitTelegramNativeMarkdownWrappedContent(
495
+ content,
496
+ maxContentLength,
497
+ (chunk) => `${opening}\n${chunk}${chunk.endsWith("\n") ? "" : "\n"}${closing}`,
498
+ );
499
+ }
500
+
501
+ function splitTelegramNativeMarkdownLongWrappedInlineBlock(
502
+ block: string,
503
+ ): string[] | undefined {
504
+ const delimiter = ["**", "__", "~~", "`", "*", "_"]
505
+ .find((candidate) =>
506
+ block.startsWith(candidate) &&
507
+ block.endsWith(candidate) &&
508
+ block.length > candidate.length * 2
509
+ );
510
+ if (!delimiter) return undefined;
511
+ const maxContentLength = TELEGRAM_RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
512
+ if (maxContentLength <= 0) return undefined;
513
+ return splitTelegramNativeMarkdownWrappedContent(
514
+ block.slice(delimiter.length, -delimiter.length),
515
+ maxContentLength,
516
+ (chunk) => `${delimiter}${chunk}${delimiter}`,
517
+ );
518
+ }
519
+
520
+ function splitTelegramNativeMarkdownWrappedContent(
521
+ content: string,
522
+ maxContentLength: number,
523
+ wrap: (chunk: string) => string,
524
+ ): string[] {
525
+ const chunks: string[] = [];
526
+ let remaining = content;
527
+ while (remaining.length > maxContentLength) {
528
+ const window = remaining.slice(0, maxContentLength + 1);
529
+ const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength);
530
+ chunks.push(wrap(remaining.slice(0, splitIndex)));
531
+ remaining = remaining.slice(splitIndex);
532
+ }
533
+ if (remaining.length > 0) chunks.push(wrap(remaining));
534
+ return chunks;
535
+ }
536
+
537
+ function findTelegramNativeMarkdownSplitIndex(
538
+ text: string,
539
+ hardLimit = TELEGRAM_RICH_MESSAGE_MAX_CHARS,
540
+ ): number {
440
541
  const paragraphIndex = text.lastIndexOf("\n\n", hardLimit);
441
542
  if (paragraphIndex > 0) return paragraphIndex + 2;
442
543
  const lineIndex = text.lastIndexOf("\n", hardLimit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"