@llblab/pi-telegram 0.17.2 → 0.17.3
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 +1 -1
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +5 -1
- package/README.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/public-api.md +1 -1
- package/index.ts +0 -5
- package/lib/preview.ts +237 -180
- package/lib/replies.ts +34 -1
- package/package.json +1 -1
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
|
|
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,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.17.3: Native Draft Preview Hotfix
|
|
4
|
+
|
|
5
|
+
- `[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.
|
|
6
|
+
- `[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.
|
|
7
|
+
- `[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
8
|
|
|
5
9
|
## 0.17.2: Indented List Rich Markdown Hotfix
|
|
6
10
|
|
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
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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,7 +191,7 @@ 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
|
|
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
|
|
|
@@ -199,7 +199,7 @@ Assistant delivery guarantees:
|
|
|
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
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
|
|
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:
|
package/docs/public-api.md
CHANGED
|
@@ -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
|
|
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. 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
|
|
4
|
-
* Owns preview
|
|
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
|
|
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
|
|
13
|
+
const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
|
|
23
14
|
|
|
24
|
-
export type TelegramDraftSupport = "unknown" | "supported"
|
|
15
|
+
export type TelegramDraftSupport = "unknown" | "supported";
|
|
25
16
|
|
|
26
17
|
export interface TelegramPreviewState {
|
|
27
|
-
mode: "draft"
|
|
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
|
-
> =
|
|
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 ??
|
|
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,
|
|
461
|
-
finalizeTelegramPreview(chatId, getRuntimeDeps(
|
|
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:
|
|
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(
|
|
431
|
+
export function shouldUseTelegramDraftPreview(_options: {
|
|
566
432
|
draftSupport: TelegramDraftSupport;
|
|
567
433
|
snapshot?: TelegramNativeMarkdownPreviewSnapshot;
|
|
568
434
|
}): boolean {
|
|
569
|
-
return
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 };
|