@llblab/pi-telegram 0.19.0 → 0.19.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.2: Draft And Rendering Isolation Hotfix
4
+
5
+ - `[Config]` Grouped assistant answer output under `assistant: { rendering, draftPreviews }`, while still reading and cleaning up legacy `assistantRendering`, `draftPreviews`, and `richDraftPreviews`. Impact: config vocabulary now matches the feature boundary; draft visibility and final rendering live together without implying that previews are inherently Rich Markdown.
6
+ - `[Preview]` Hard-gated preview state creation behind the Draft previews setting. Impact: when Draft previews are off, `message_start` / `message_update` cannot create or flush draft frames; Telegram should show only native active status until the final answer.
7
+ - `[Preview]` Aligned enabled draft previews with the selected final renderer: `assistant.rendering: "rich"` uses `sendRichMessageDraft`, while `assistant.rendering: "html"` uses legacy `sendMessageDraft` with HTML. Impact: the visible draft no longer morphs from Native Rich Markdown into legacy HTML at finalization.
8
+ - `[Rendering]` Removed the thread-reply special case that forced anchored thread assistant replies through legacy Markdown-to-HTML. Impact: `assistant.rendering: "rich"` now uses native Rich Markdown for final assistant replies in Threaded Mode too, while `assistant.rendering: "html"` remains the only path that selects legacy HTML rendering.
9
+ - `[Tests]` Updated reply regressions to assert native Rich Markdown delivery for anchored thread replies.
10
+
11
+ ## 0.19.1: Settings Layer Hotfix
12
+
13
+ - `[Settings]` Split the overloaded Rich Draft setting into two independent controls: `Draft previews` for live `sendRichMessageDraft` streaming and `Assistant rendering` for final-answer delivery mode. Impact: operators can hide/show in-progress drafts without changing how final Markdown is rendered.
14
+ - `[Rendering]` Added persisted `assistantRendering: "rich" | "html"`, defaulting to Native Rich Markdown and allowing legacy Markdown-to-HTML final assistant replies when selected. Impact: renderer compatibility is explicit instead of being conflated with preview visibility.
15
+ - `[Preview]` Kept `richDraftPreviews` as the stored draft-preview flag for compatibility, but renamed the Settings UI to `Draft previews`. Impact: existing configs keep working while the product vocabulary moves toward the preview feature boundary.
16
+ - `[Validation]` Updated menu/settings/reply regressions for the two-axis configuration model.
17
+
3
18
  ## 0.19.0: Telegram Companion Hub
4
19
 
5
20
  - `[Context]` Defined Rich Markdown as the model-answer membrane: complete assistant/guest model replies use native Rich Message delivery, while tool rows, reasoning/thinking blocks, menus, status, queue controls, settings, diagnostics, and other harness-owned surfaces stay on explicit Telegram HTML/plain rendering.
package/README.md CHANGED
@@ -72,41 +72,41 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
72
72
 
73
73
  ## Product Model
74
74
 
75
- | Lens | What `pi-telegram` owns |
76
- | ----------------------- | -------------------------------------------------------------------------------------------------- |
77
- | Operator companion | A phone-width control surface for a live Pi session |
78
- | Runtime adapter | Telegram turns mapped into Pi lifecycle, queueing, previews, final replies, and artifacts |
79
- | Telegram UI harness | Menus, settings, callbacks, Rich Markdown, drafts, active status, buttons, voice, and files |
80
- | Multi-instance organism | One leader plus explicit visible followers routed through Telegram private-chat threads |
81
- | Extension platform | Commands, sections, status rows, update handlers, inbound/outbound handlers, and voice providers |
82
- | Safety boundary | No hidden Pi processes, no fake terminal, no PTY tricks, no arbitrary TUI slash-command forwarding |
75
+ | Lens | What `pi-telegram` owns |
76
+ | --- | --- |
77
+ | Operator companion | A phone-width control surface for a live Pi session |
78
+ | Runtime adapter | Telegram turns mapped into Pi lifecycle, queueing, previews, final replies, and artifacts |
79
+ | Telegram UI harness | Menus, settings, callbacks, Rich Markdown, drafts, active status, buttons, voice, and files |
80
+ | Multi-instance organism | One leader plus explicit visible followers routed through Telegram private-chat threads |
81
+ | Extension platform | Commands, sections, status rows, update handlers, inbound/outbound handlers, and voice providers |
82
+ | Safety boundary | No hidden Pi processes, no fake terminal, no PTY tricks, no arbitrary TUI slash-command forwarding |
83
83
 
84
84
  ## Feature Showcase
85
85
 
86
86
  `pi-telegram` is intentionally broad: it is a Telegram-shaped runtime surface, not only a message relay. This catalogue keeps the practical feature surface visible while detailed contracts stay in `/docs`.
87
87
 
88
- | Surface | What you can do | Why it matters |
89
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
90
- | Prompt intake | Send text, replies, edits, images, files, albums, voice notes, and handler output into Pi. | Telegram becomes a real mobile input surface with file/context references, not just a text tunnel. |
91
- | Queue control | Inspect waiting turns, delete stale work, promote important prompts, continue, abort, stop, or force the next queued item. | Long Pi tasks keep running while new mobile prompts stay visible and controllable instead of interrupting or disappearing. |
92
- | Operator menu | Use `/start` for status, prompt templates, model, thinking, settings, queue, extension sections, and diagnostics. | The bot is an operator panel, not a command cheat sheet. |
93
- | Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
94
- | Model and thinking | Switch model or reasoning level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
95
- | Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
96
- | Streaming previews | Show native active status and, when enabled, Rich Markdown drafts while the agent is still working. | The operator can see progress without making draft rendering the baseline. |
97
- | Final Rich Markdown | Deliver complete assistant/guest model replies through Telegram Rich Message APIs with safe splitting. | Meaningful model answers stay readable on narrow mobile screens without forcing generic HTML conversion. |
98
- | Bridge UI rendering | Render tool rows, reasoning/technical steps, menus, queue controls, status, settings, diagnostics, and sections through explicit Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
99
- | Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
100
- | Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
101
- | Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
102
- | Voice output | Use `telegram_voice`, reply modes, configured voice handlers, and TTS providers. | Replies can become Telegram voice messages when the workflow calls for it. |
103
- | Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
104
- | Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
105
- | Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
106
- | Reroute and restore | Preserve unknown threads and offer explicit target choices. | Telegram client state can be repaired without silently deleting or hijacking prompts. |
107
- | Extension sections | Add menu sections, commands, status rows, settings, callbacks, and delivery helpers from companion extensions. | `pi-telegram` becomes a platform surface for other Pi extensions. |
108
- | Runtime diagnostics | Use `/telegram-status` and recent runtime events for connection, role, queue, transport, and failure evidence. | Debugging lives in the operator surface instead of hidden logs only. |
109
- | Safety and ownership | Pair one owner, lock transport, scope targets, and reject fake terminal behavior. | Remote access remains explicit, bounded, and understandable. |
88
+ | Surface | What you can do | Why it matters |
89
+ | --- | --- | --- |
90
+ | Prompt intake | Send text, replies, edits, images, files, albums, voice notes, and handler output into Pi. | Telegram becomes a real mobile input surface with file/context references, not just a text tunnel. |
91
+ | Queue control | Inspect waiting turns, delete stale work, promote important prompts, continue, abort, stop, or force the next queued item. | Long Pi tasks keep running while new mobile prompts stay visible and controllable instead of interrupting or disappearing. |
92
+ | Operator menu | Use `/start` for status, prompt templates, model, thinking, settings, queue, extension sections, and diagnostics. | The bot is an operator panel, not a command cheat sheet. |
93
+ | Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
94
+ | Model and thinking | Switch model or reasoning level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
95
+ | Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
96
+ | Draft previews | Show native active status by default, or enable Telegram Rich Draft previews while the agent is still working. | The operator can hide/show in-progress text independently from final rendering. |
97
+ | Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
98
+ | Bridge UI rendering | Render tool rows, reasoning/technical steps, menus, queue controls, status, settings, diagnostics, and sections through explicit Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
99
+ | Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
100
+ | Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
101
+ | Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
102
+ | Voice output | Use `telegram_voice`, reply modes, configured voice handlers, and TTS providers. | Replies can become Telegram voice messages when the workflow calls for it. |
103
+ | Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
104
+ | Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
105
+ | Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
106
+ | Reroute and restore | Preserve unknown threads and offer explicit target choices. | Telegram client state can be repaired without silently deleting or hijacking prompts. |
107
+ | Extension sections | Add menu sections, commands, status rows, settings, callbacks, and delivery helpers from companion extensions. | `pi-telegram` becomes a platform surface for other Pi extensions. |
108
+ | Runtime diagnostics | Use `/telegram-status` and recent runtime events for connection, role, queue, transport, and failure evidence. | Debugging lives in the operator surface instead of hidden logs only. |
109
+ | Safety and ownership | Pair one owner, lock transport, scope targets, and reject fake terminal behavior. | Remote access remains explicit, bounded, and understandable. |
110
110
 
111
111
  ## Core Loop
112
112
 
@@ -126,14 +126,14 @@ The bridge keeps Telegram responsive without stealing Pi's runtime model. Queuei
126
126
 
127
127
  Use these in the bot DM.
128
128
 
129
- | Command | Purpose |
130
- | ----------- | ------------------------------------------------------- |
131
- | `/start` | Pair when needed and open the main operator menu |
132
- | `/compact` | Confirm and run session compaction when safe |
133
- | `/next` | Dispatch the next queued turn, aborting first if needed |
134
- | `/continue` | Enqueue a priority continuation prompt |
135
- | `/abort` | Abort the active run while preserving the queue |
136
- | `/stop` | Abort the active run and clear waiting Telegram turns |
129
+ | Command | Purpose |
130
+ | --- | --- |
131
+ | `/start` | Pair when needed and open the main operator menu |
132
+ | `/compact` | Confirm and run session compaction when safe |
133
+ | `/next` | Dispatch the next queued turn, aborting first if needed |
134
+ | `/continue` | Enqueue a priority continuation prompt |
135
+ | `/abort` | Abort the active run while preserving the queue |
136
+ | `/stop` | Abort the active run and clear waiting Telegram turns |
137
137
 
138
138
  Hidden compatibility shortcuts: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings` jump into the same menu system.
139
139
 
@@ -141,12 +141,12 @@ Hidden compatibility shortcuts: `/help`, `/status`, `/model`, `/thinking`, `/que
141
141
 
142
142
  Run these inside Pi.
143
143
 
144
- | Command | Purpose |
145
- | ---------------------- | ------------------------------------------------------------------ |
146
- | `/telegram-setup` | Save or update the bot token |
147
- | `/telegram-connect` | Start polling and acquire Telegram transport ownership |
148
- | `/telegram-disconnect` | Stop polling and release ownership |
149
- | `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
144
+ | Command | Purpose |
145
+ | --- | --- |
146
+ | `/telegram-setup` | Save or update the bot token |
147
+ | `/telegram-connect` | Start polling and acquire Telegram transport ownership |
148
+ | `/telegram-disconnect` | Stop polling and release ownership |
149
+ | `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
150
150
 
151
151
  ## Main Surfaces
152
152
 
@@ -160,7 +160,7 @@ Messages sent while Pi is busy become queued turns. Priority lanes support contr
160
160
 
161
161
  ### Native Rich Markdown
162
162
 
163
- Rich Markdown is the model-answer membrane. Complete assistant and guest model replies use Telegram's native Rich Message APIs, while tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces use explicit Telegram HTML/plain rendering. This keeps meaningful model-authored answers visually distinct from bridge-owned operational UI. Streaming previews may use rich-message drafts when enabled; final replies persist through `sendRichMessage`.
163
+ Rich Markdown is the default model-answer membrane. Complete assistant and guest model replies use Telegram's native Rich Message APIs, while tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces use explicit Telegram HTML/plain rendering. This keeps meaningful model-authored answers visually distinct from bridge-owned operational UI. Two Settings controls keep the layers separate: `Draft previews` toggles live `sendRichMessageDraft` frames, while `Assistant rendering` chooses final-answer delivery (`rich` Native Rich Markdown or `html` legacy Markdown-to-HTML).
164
164
 
165
165
  ### Files And Artifacts
166
166
 
@@ -184,25 +184,25 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
184
184
  - Unknown threads are preserved and offered explicit reroute/restore choices.
185
185
  - Telegram never launches hidden Pi processes.
186
186
 
187
- | Mode | Best for | Runtime shape |
188
- | ------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- |
189
- | Classic DM | One live Pi session controlled from one private bot chat | One polling owner, one queue/runtime surface |
190
- | Threaded Mode | Several visible Pi terminals sharing one bot | One leader owns transport; followers route through named private-chat threads |
187
+ | Mode | Best for | Runtime shape |
188
+ | --- | --- | --- |
189
+ | Classic DM | One live Pi session controlled from one private bot chat | One polling owner, one queue/runtime surface |
190
+ | Threaded Mode | Several visible Pi terminals sharing one bot | One leader owns transport; followers route through named private-chat threads |
191
191
 
192
192
  ## Environment Configuration
193
193
 
194
194
  Most controls live in Pi commands or the Telegram menu. Environment variables remain for bootstrap and transport boundaries:
195
195
 
196
- | Area | Variables |
197
- | ------------------------- | ---------------------------------------------------------------------------------------------- |
198
- | Bot token bootstrap | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
199
- | HTTP proxy | `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, plus `NODE_USE_ENV_PROXY=1` or Node `--use-env-proxy` |
200
- | Telegram network family | `PI_TELEGRAM_NETWORK_FAMILY`=auto | ipv4 | ipv6 | ipv4-fallback` |
201
- | Agent data root | `PI_CODING_AGENT_DIR` |
202
- | Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` |
203
- | Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` |
196
+ | Area | Variables |
197
+ | --- | --- |
198
+ | Bot token bootstrap | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
199
+ | HTTP proxy | `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, plus `NODE_USE_ENV_PROXY=1` or Node `--use-env-proxy` |
200
+ | Telegram network family | `PI_TELEGRAM_NETWORK_FAMILY=auto`, `ipv4`, `ipv6`, or `ipv4-fallback` |
201
+ | Agent data root | `PI_CODING_AGENT_DIR` |
202
+ | Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` |
203
+ | Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` |
204
204
 
205
- Defaults are chosen for ordinary private-bot use: saved config in `~/.pi/agent`, inbound temp files in `~/.pi/agent/tmp/telegram`, native Rich Markdown replies, and native Telegram active status for long-running turns.
205
+ Defaults are chosen for ordinary private-bot use: saved config in `~/.pi/agent`, inbound temp files in `~/.pi/agent/tmp/telegram`, `assistant: { rendering: "rich", draftPreviews: false }` for assistant answer output, and native Telegram active status for long-running turns.
206
206
 
207
207
  ## Extension Platform
208
208
 
@@ -223,15 +223,15 @@ During active Telegram-owned turns, assistant message start/update hooks re-arm
223
223
 
224
224
  ### Rendering And Delivery
225
225
 
226
- Rich Markdown is the model-answer membrane. Complete assistant replies send final Markdown directly as `InputRichMessage.markdown` through `sendRichMessage`, and guest replies use native Rich Markdown through `InputRichMessageContent` in `answerGuestQuery` results. Tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces stay on explicit Telegram HTML/plain rendering. Streaming previews may use `sendRichMessageDraft` when enabled and when draft delivery succeeds. The bridge still strips top-level assistant action comments before delivery and may split output only for Telegram transport limits.
226
+ Rich Markdown is the default model-answer membrane. Complete assistant replies send final Markdown directly as `InputRichMessage.markdown` through `sendRichMessage` when `assistant.rendering` is `rich`, and through the legacy Markdown-to-HTML renderer when `assistant.rendering` is `html`; guest replies use native Rich Markdown through `InputRichMessageContent` in `answerGuestQuery` results. Tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces stay on explicit Telegram HTML/plain rendering. Streaming previews may use `sendRichMessageDraft` only when `assistant.draftPreviews` is enabled and draft delivery succeeds. The bridge still strips top-level assistant action comments before delivery and may split output only for Telegram transport limits.
227
227
 
228
228
  Assistant delivery guarantees:
229
229
 
230
- - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML.
230
+ - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
231
231
  - 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.
232
232
  - 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.
233
233
  - 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.
234
- - When enabled, 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.
234
+ - When Draft previews are enabled, 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.
235
235
  - 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.
236
236
 
237
237
  UI/compat rendering guarantees:
package/index.ts CHANGED
@@ -443,6 +443,7 @@ export default function (pi: Pi.ExtensionAPI) {
443
443
  },
444
444
  sendMessage,
445
445
  sendRichMessage,
446
+ getAssistantRenderingMode: configControls.getAssistantRenderingMode,
446
447
  editMessage: editTelegramMessageText,
447
448
  });
448
449
  const { replyTransport, editInteractiveMessage, sendInteractiveMessage } =
@@ -477,7 +478,9 @@ export default function (pi: Pi.ExtensionAPI) {
477
478
  recordRuntimeEvent,
478
479
  });
479
480
  const nativeMarkdownDraftSender =
480
- TelegramApi.createTelegramNativeMarkdownDraftSender({
481
+ TelegramApi.createTelegramAssistantDraftSender({
482
+ getAssistantRenderingMode: configControls.getAssistantRenderingMode,
483
+ renderMarkdownToHtmlDraft: Replies.renderTelegramMarkdownToHtmlDraft,
481
484
  sendMessageDraft,
482
485
  sendRichMessageDraft,
483
486
  });
@@ -487,7 +490,7 @@ export default function (pi: Pi.ExtensionAPI) {
487
490
  getMessageText: Replies.getAgentMessageText,
488
491
  getDefaultReplyToMessageId: activeTurnRuntime.getReplyToMessageId,
489
492
  sendDraft: nativeMarkdownDraftSender,
490
- canSend: configControls.areRichDraftPreviewsEnabled,
493
+ canSend: configControls.areDraftPreviewsEnabled,
491
494
  sendMarkdownReply,
492
495
  recordRuntimeEvent,
493
496
  ...replyTransport,
package/lib/config.ts CHANGED
@@ -47,6 +47,8 @@ export interface ResolvedTelegramTimeConfig {
47
47
  timezone: string;
48
48
  }
49
49
 
50
+ export type TelegramAssistantRenderingMode = "rich" | "html";
51
+
50
52
  export interface TelegramConfig {
51
53
  botToken?: string;
52
54
  botUsername?: string;
@@ -57,7 +59,16 @@ export interface TelegramConfig {
57
59
  attachmentHandlers?: TelegramInboundHandlerConfig[];
58
60
  outboundHandlers?: TelegramOutboundHandlerConfig[];
59
61
  proactivePush?: boolean;
62
+ assistant?: {
63
+ draftPreviews?: boolean;
64
+ rendering?: TelegramAssistantRenderingMode;
65
+ };
66
+ /** @deprecated use assistant.draftPreviews */
67
+ draftPreviews?: boolean;
68
+ /** @deprecated use assistant.draftPreviews */
60
69
  richDraftPreviews?: boolean;
70
+ /** @deprecated use assistant.rendering */
71
+ assistantRendering?: TelegramAssistantRenderingMode;
61
72
  voice?: {
62
73
  replyMode?: "manual" | "mirror" | "always";
63
74
  /** Whether to attach the provider's transcriptText as caption on voice messages */
@@ -260,18 +271,60 @@ export function createTelegramProactivePushSetter(
260
271
  };
261
272
  }
262
273
 
263
- export function createTelegramRichDraftPreviewsChecker(
274
+ export function createTelegramDraftPreviewsChecker(
264
275
  configStore: Pick<TelegramConfigStore, "get">,
265
276
  ): () => boolean {
266
- return () => configStore.get().richDraftPreviews ?? false;
277
+ return () => {
278
+ const config = configStore.get();
279
+ return (
280
+ config.assistant?.draftPreviews ??
281
+ config.draftPreviews ??
282
+ config.richDraftPreviews ??
283
+ false
284
+ );
285
+ };
267
286
  }
268
287
 
269
- export function createTelegramRichDraftPreviewsSetter(
288
+ export function createTelegramDraftPreviewsSetter(
270
289
  configStore: TelegramMutableConfigStore,
271
290
  ): (enabled: boolean) => Promise<void> {
272
291
  return async (enabled) => {
273
292
  await loadLatestTelegramConfig(configStore);
274
- const config = { ...configStore.get(), richDraftPreviews: enabled };
293
+ const {
294
+ draftPreviews: _legacyDraftPreviews,
295
+ richDraftPreviews: _legacyRichDraftPreviews,
296
+ ...current
297
+ } = configStore.get();
298
+ const config = {
299
+ ...current,
300
+ assistant: { ...current.assistant, draftPreviews: enabled },
301
+ };
302
+ configStore.set(config);
303
+ await configStore.persist(config);
304
+ };
305
+ }
306
+
307
+ export function createTelegramAssistantRenderingModeGetter(
308
+ configStore: Pick<TelegramConfigStore, "get">,
309
+ ): () => TelegramAssistantRenderingMode {
310
+ return () => {
311
+ const config = configStore.get();
312
+ const mode = config.assistant?.rendering ?? config.assistantRendering;
313
+ return mode === "html" ? "html" : "rich";
314
+ };
315
+ }
316
+
317
+ export function createTelegramAssistantRenderingModeSetter(
318
+ configStore: TelegramMutableConfigStore,
319
+ ): (mode: TelegramAssistantRenderingMode) => Promise<void> {
320
+ return async (mode) => {
321
+ await loadLatestTelegramConfig(configStore);
322
+ const { assistantRendering: _legacyAssistantRendering, ...current } =
323
+ configStore.get();
324
+ const config = {
325
+ ...current,
326
+ assistant: { ...current.assistant, rendering: mode },
327
+ };
275
328
  configStore.set(config);
276
329
  await configStore.persist(config);
277
330
  };
@@ -412,10 +465,12 @@ export function createTelegramConfigControls(
412
465
  return {
413
466
  isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
414
467
  setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
415
- areRichDraftPreviewsEnabled:
416
- createTelegramRichDraftPreviewsChecker(configStore),
417
- setRichDraftPreviewsEnabled:
418
- createTelegramRichDraftPreviewsSetter(configStore),
468
+ areDraftPreviewsEnabled: createTelegramDraftPreviewsChecker(configStore),
469
+ setDraftPreviewsEnabled: createTelegramDraftPreviewsSetter(configStore),
470
+ getAssistantRenderingMode:
471
+ createTelegramAssistantRenderingModeGetter(configStore),
472
+ setAssistantRenderingMode:
473
+ createTelegramAssistantRenderingModeSetter(configStore),
419
474
  getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
420
475
  isVoiceReplyModeConfigured:
421
476
  createTelegramVoiceReplyModeConfiguredChecker(configStore),
@@ -8,7 +8,10 @@ import {
8
8
  getTelegramExtensionSettingsRows,
9
9
  type TelegramSectionRegistry,
10
10
  } from "./sections.ts";
11
- import type { TelegramTimeMode } from "./config.ts";
11
+ import type {
12
+ TelegramAssistantRenderingMode,
13
+ TelegramTimeMode,
14
+ } from "./config.ts";
12
15
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
16
  import type { TelegramModelMenuState } from "./menu-model.ts";
14
17
  import type { MenuModel } from "./model.ts";
@@ -18,7 +21,8 @@ export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
18
21
 
19
22
  export interface TelegramSettingsStateDeps {
20
23
  isProactivePushEnabled: () => boolean;
21
- areRichDraftPreviewsEnabled: () => boolean;
24
+ areDraftPreviewsEnabled: () => boolean;
25
+ getAssistantRenderingMode: () => TelegramAssistantRenderingMode;
22
26
  getTimeInjectionMode: () => TelegramTimeMode;
23
27
  getVoiceReplyMode: () => TelegramVoiceReplyMode;
24
28
  isVoiceReplyModeConfigured: () => boolean;
@@ -26,7 +30,10 @@ export interface TelegramSettingsStateDeps {
26
30
 
27
31
  export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
28
32
  setProactivePushEnabled: (enabled: boolean) => Promise<void>;
29
- setRichDraftPreviewsEnabled: (enabled: boolean) => Promise<void>;
33
+ setDraftPreviewsEnabled: (enabled: boolean) => Promise<void>;
34
+ setAssistantRenderingMode: (
35
+ mode: TelegramAssistantRenderingMode,
36
+ ) => Promise<void>;
30
37
  setVoiceReplyMode: (
31
38
  mode: TelegramVoiceReplyMode | undefined,
32
39
  ) => Promise<void>;
@@ -118,8 +125,9 @@ export interface TelegramSettingsMenuRuntimeDeps<
118
125
 
119
126
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
120
127
  export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
121
- export const RICH_DRAFT_PREVIEWS_SETTINGS_TITLE =
122
- "<b>📝 Rich draft previews:</b>";
128
+ export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
129
+ export const ASSISTANT_RENDERING_SETTINGS_TITLE =
130
+ "<b>🧾 Assistant rendering:</b>";
123
131
  export const TIME_INJECTION_MODE_SETTINGS_TITLE =
124
132
  "<b>🕒 Time injection mode:</b>";
125
133
  export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
@@ -155,14 +163,27 @@ export function buildProactivePushSettingsText(
155
163
  ].join("\n");
156
164
  }
157
165
 
158
- export function buildRichDraftPreviewsSettingsText(enabled: boolean): string {
166
+ export function buildDraftPreviewsSettingsText(enabled: boolean): string {
167
+ return [
168
+ `${DRAFT_PREVIEWS_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
169
+ "",
170
+ "Show live answer drafts while the model is answering.",
171
+ "",
172
+ "<code>-</code> <code>off</code> (default): show native active status, then send one final answer.",
173
+ "<code>-</code> <code>on</code>: stream safe Telegram Rich Draft frames before the final answer.",
174
+ ].join("\n");
175
+ }
176
+
177
+ export function buildAssistantRenderingSettingsText(
178
+ mode: TelegramAssistantRenderingMode,
179
+ ): string {
159
180
  return [
160
- `${RICH_DRAFT_PREVIEWS_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
181
+ `${ASSISTANT_RENDERING_SETTINGS_TITLE} <code>${mode}</code>`,
161
182
  "",
162
- "Show progressive Rich Markdown draft previews while the model is answering.",
183
+ "Choose how final assistant Markdown answers are delivered.",
163
184
  "",
164
- "<code>-</code> <code>off</code> (default): show native active status, then send the final Rich Markdown answer.",
165
- "<code>-</code> <code>on</code>: also stream safe Rich Markdown draft previews before the final answer.",
185
+ "<code>-</code> <code>rich</code> (default): use Telegram Native Rich Markdown.",
186
+ "<code>-</code> <code>html</code>: use the legacy Markdown-to-HTML renderer.",
166
187
  ].join("\n");
167
188
  }
168
189
 
@@ -200,12 +221,35 @@ export function buildTimeInjectionModeSettingsText(
200
221
 
201
222
  export function buildTelegramSettingsMenuReplyMarkup(
202
223
  proactivePushEnabled: boolean,
203
- richDraftPreviewsEnabled: boolean,
204
- voiceReplyMode: TelegramVoiceReplyMode,
205
- timeInjectionMode: TelegramTimeMode,
206
- sectionRegistry?: TelegramSectionRegistry,
224
+ draftPreviewsEnabled: boolean,
225
+ assistantRenderingModeOrVoiceReplyMode: TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
226
+ voiceReplyModeOrTimeInjectionMode: TelegramVoiceReplyMode | TelegramTimeMode,
227
+ timeInjectionModeOrSectionRegistry?: TelegramTimeMode | TelegramSectionRegistry,
228
+ sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
207
229
  voiceReplyModeConfigured = true,
208
230
  ): TelegramSettingsMenuReplyMarkup {
231
+ const hasRenderingMode =
232
+ assistantRenderingModeOrVoiceReplyMode === "rich" ||
233
+ assistantRenderingModeOrVoiceReplyMode === "html";
234
+ const assistantRenderingMode: TelegramAssistantRenderingMode = hasRenderingMode
235
+ ? assistantRenderingModeOrVoiceReplyMode
236
+ : "rich";
237
+ const voiceReplyMode = hasRenderingMode
238
+ ? (voiceReplyModeOrTimeInjectionMode as TelegramVoiceReplyMode)
239
+ : (assistantRenderingModeOrVoiceReplyMode as TelegramVoiceReplyMode);
240
+ const timeInjectionMode = hasRenderingMode
241
+ ? (timeInjectionModeOrSectionRegistry as TelegramTimeMode)
242
+ : (voiceReplyModeOrTimeInjectionMode as TelegramTimeMode);
243
+ const sectionRegistry = hasRenderingMode
244
+ ? (sectionRegistryOrVoiceReplyModeConfigured as
245
+ | TelegramSectionRegistry
246
+ | undefined)
247
+ : (timeInjectionModeOrSectionRegistry as TelegramSectionRegistry | undefined);
248
+ const effectiveVoiceReplyModeConfigured = hasRenderingMode
249
+ ? voiceReplyModeConfigured
250
+ : typeof sectionRegistryOrVoiceReplyModeConfigured === "boolean"
251
+ ? sectionRegistryOrVoiceReplyModeConfigured
252
+ : true;
209
253
  const rows: Array<Array<{ text: string; callback_data: string }>> = [
210
254
  [{ text: "⬆️ Main menu", callback_data: "menu:back" }],
211
255
  ];
@@ -221,7 +265,7 @@ export function buildTelegramSettingsMenuReplyMarkup(
221
265
  {
222
266
  text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
223
267
  getVoiceReplyModeLabel(
224
- getVoiceReplyModeSetting(voiceReplyMode, voiceReplyModeConfigured),
268
+ getVoiceReplyModeSetting(voiceReplyMode, effectiveVoiceReplyModeConfigured),
225
269
  ),
226
270
  )}`,
227
271
  callback_data: "settings:open:voice-reply",
@@ -235,8 +279,14 @@ export function buildTelegramSettingsMenuReplyMarkup(
235
279
  ],
236
280
  [
237
281
  {
238
- text: `📝 Rich drafts: ${richDraftPreviewsEnabled ? "on" : "off"}`,
239
- callback_data: "settings:open:rich-drafts",
282
+ text: `📝 Draft previews: ${draftPreviewsEnabled ? "on" : "off"}`,
283
+ callback_data: "settings:open:draft-previews",
284
+ },
285
+ ],
286
+ [
287
+ {
288
+ text: `🧾 Rendering: ${assistantRenderingMode}`,
289
+ callback_data: "settings:open:assistant-rendering",
240
290
  },
241
291
  ],
242
292
  [
@@ -261,7 +311,8 @@ export async function openTelegramSettingsMenu<
261
311
  buildTelegramSettingsMenuText(),
262
312
  buildTelegramSettingsMenuReplyMarkup(
263
313
  deps.isProactivePushEnabled(),
264
- deps.areRichDraftPreviewsEnabled(),
314
+ deps.areDraftPreviewsEnabled(),
315
+ deps.getAssistantRenderingMode(),
265
316
  deps.getVoiceReplyMode(),
266
317
  deps.getTimeInjectionMode(),
267
318
  sectionRegistry,
@@ -294,7 +345,7 @@ export function buildProactivePushSettingsReplyMarkup(
294
345
  };
295
346
  }
296
347
 
297
- export function buildRichDraftPreviewsSettingsReplyMarkup(
348
+ export function buildDraftPreviewsSettingsReplyMarkup(
298
349
  enabled: boolean,
299
350
  ): TelegramSettingsMenuReplyMarkup {
300
351
  return {
@@ -303,17 +354,34 @@ export function buildRichDraftPreviewsSettingsReplyMarkup(
303
354
  [
304
355
  {
305
356
  text: enabled ? "🟢 On" : "⚫️ On",
306
- callback_data: "settings:set:rich-drafts:on",
357
+ callback_data: "settings:set:draft-previews:on",
307
358
  },
308
359
  {
309
360
  text: enabled ? "⚫️ Off" : "🟡 Off",
310
- callback_data: "settings:set:rich-drafts:off",
361
+ callback_data: "settings:set:draft-previews:off"
311
362
  },
312
363
  ],
313
364
  ],
314
365
  };
315
366
  }
316
367
 
368
+ export function buildAssistantRenderingSettingsReplyMarkup(
369
+ mode: TelegramAssistantRenderingMode,
370
+ ): TelegramSettingsMenuReplyMarkup {
371
+ const modes: TelegramAssistantRenderingMode[] = ["rich", "html"];
372
+ return {
373
+ inline_keyboard: [
374
+ [{ text: "⬆️ Back", callback_data: "settings:list" }],
375
+ ...modes.map((value) => [
376
+ {
377
+ text: `${value === mode ? "🟢 " : ""}${value}`,
378
+ callback_data: `settings:set:assistant-rendering:${value}`,
379
+ },
380
+ ]),
381
+ ],
382
+ };
383
+ }
384
+
317
385
  export function buildTimeInjectionModeSettingsReplyMarkup(
318
386
  mode: TelegramTimeMode,
319
387
  ): TelegramSettingsMenuReplyMarkup {
@@ -363,7 +431,8 @@ export async function updateTelegramSettingsMenuMessage(
363
431
  buildTelegramSettingsMenuText(),
364
432
  buildTelegramSettingsMenuReplyMarkup(
365
433
  deps.isProactivePushEnabled(),
366
- deps.areRichDraftPreviewsEnabled(),
434
+ deps.areDraftPreviewsEnabled(),
435
+ deps.getAssistantRenderingMode(),
367
436
  deps.getVoiceReplyMode(),
368
437
  deps.getTimeInjectionMode(),
369
438
  sectionRegistry,
@@ -382,13 +451,23 @@ export async function updateProactivePushSettingsMessage(
382
451
  );
383
452
  }
384
453
 
385
- export async function updateRichDraftPreviewsSettingsMessage(
454
+ export async function updateDraftPreviewsSettingsMessage(
386
455
  deps: TelegramSettingsMenuCallbackDeps,
387
456
  ): Promise<void> {
388
- const enabled = deps.areRichDraftPreviewsEnabled();
457
+ const enabled = deps.areDraftPreviewsEnabled();
389
458
  await deps.updateSettingsMessage(
390
- buildRichDraftPreviewsSettingsText(enabled),
391
- buildRichDraftPreviewsSettingsReplyMarkup(enabled),
459
+ buildDraftPreviewsSettingsText(enabled),
460
+ buildDraftPreviewsSettingsReplyMarkup(enabled),
461
+ );
462
+ }
463
+
464
+ export async function updateAssistantRenderingSettingsMessage(
465
+ deps: TelegramSettingsMenuCallbackDeps,
466
+ ): Promise<void> {
467
+ const mode = deps.getAssistantRenderingMode();
468
+ await deps.updateSettingsMessage(
469
+ buildAssistantRenderingSettingsText(mode),
470
+ buildAssistantRenderingSettingsReplyMarkup(mode),
392
471
  );
393
472
  }
394
473
 
@@ -429,8 +508,13 @@ export async function handleTelegramSettingsMenuCallbackAction(
429
508
  await deps.answerCallbackQuery(callbackQueryId);
430
509
  return true;
431
510
  }
432
- if (data === "settings:open:rich-drafts") {
433
- await updateRichDraftPreviewsSettingsMessage(deps);
511
+ if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
512
+ await updateDraftPreviewsSettingsMessage(deps);
513
+ await deps.answerCallbackQuery(callbackQueryId);
514
+ return true;
515
+ }
516
+ if (data === "settings:open:assistant-rendering") {
517
+ await updateAssistantRenderingSettingsMessage(deps);
434
518
  await deps.answerCallbackQuery(callbackQueryId);
435
519
  return true;
436
520
  }
@@ -487,18 +571,29 @@ export async function handleTelegramSettingsMenuCallbackAction(
487
571
  }
488
572
  }
489
573
  if (
574
+ data === "settings:set:draft-previews:on" ||
575
+ data === "settings:set:draft-previews:off" ||
490
576
  data === "settings:set:rich-drafts:on" ||
491
577
  data === "settings:set:rich-drafts:off"
492
578
  ) {
493
579
  const enabled = data.endsWith(":on");
494
- await deps.setRichDraftPreviewsEnabled(enabled);
495
- await updateRichDraftPreviewsSettingsMessage(deps);
580
+ await deps.setDraftPreviewsEnabled(enabled);
581
+ await updateDraftPreviewsSettingsMessage(deps);
496
582
  await deps.answerCallbackQuery(
497
583
  callbackQueryId,
498
- `Rich draft previews ${enabled ? "enabled" : "disabled"}`,
584
+ `Draft previews ${enabled ? "enabled" : "disabled"}`,
499
585
  );
500
586
  return true;
501
587
  }
588
+ if (data.startsWith("settings:set:assistant-rendering:")) {
589
+ const mode = data.slice("settings:set:assistant-rendering:".length);
590
+ if (mode === "rich" || mode === "html") {
591
+ await deps.setAssistantRenderingMode(mode);
592
+ await updateAssistantRenderingSettingsMessage(deps);
593
+ await deps.answerCallbackQuery(callbackQueryId, `Rendering: ${mode}`);
594
+ return true;
595
+ }
596
+ }
502
597
  if (
503
598
  data === "settings:set:proactive:on" ||
504
599
  data === "settings:set:proactive:off"
@@ -529,7 +624,8 @@ export function createTelegramSettingsMenuRuntime<
529
624
  {
530
625
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
531
626
  isProactivePushEnabled: deps.isProactivePushEnabled,
532
- areRichDraftPreviewsEnabled: deps.areRichDraftPreviewsEnabled,
627
+ areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
628
+ getAssistantRenderingMode: deps.getAssistantRenderingMode,
533
629
  getVoiceReplyMode: deps.getVoiceReplyMode,
534
630
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
535
631
  getTimeInjectionMode: deps.getTimeInjectionMode,
@@ -548,7 +644,8 @@ export function createTelegramSettingsMenuRuntime<
548
644
  updateTelegramSettingsMenuMessage(
549
645
  {
550
646
  isProactivePushEnabled: deps.isProactivePushEnabled,
551
- areRichDraftPreviewsEnabled: deps.areRichDraftPreviewsEnabled,
647
+ areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
648
+ getAssistantRenderingMode: deps.getAssistantRenderingMode,
552
649
  getVoiceReplyMode: deps.getVoiceReplyMode,
553
650
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
554
651
  getTimeInjectionMode: deps.getTimeInjectionMode,
@@ -588,17 +685,27 @@ export function createTelegramSettingsMenuRuntime<
588
685
  return true;
589
686
  }
590
687
  if (
688
+ query.data === "settings:set:draft-previews:on" ||
689
+ query.data === "settings:set:draft-previews:off" ||
591
690
  query.data === "settings:set:rich-drafts:on" ||
592
691
  query.data === "settings:set:rich-drafts:off"
593
692
  ) {
594
693
  const enabled = query.data.endsWith(":on");
595
- await deps.setRichDraftPreviewsEnabled(enabled);
694
+ await deps.setDraftPreviewsEnabled(enabled);
596
695
  await deps.answerCallbackQuery(
597
696
  query.id,
598
- `Rich draft previews ${enabled ? "enabled" : "disabled"}`,
697
+ `Draft previews ${enabled ? "enabled" : "disabled"}`,
599
698
  );
600
699
  return true;
601
700
  }
701
+ if (query.data.startsWith("settings:set:assistant-rendering:")) {
702
+ const mode = query.data.slice("settings:set:assistant-rendering:".length);
703
+ if (mode === "rich" || mode === "html") {
704
+ await deps.setAssistantRenderingMode(mode);
705
+ await deps.answerCallbackQuery(query.id, `Rendering: ${mode}`);
706
+ return true;
707
+ }
708
+ }
602
709
  const hasTimeInjectionPrefix = query.data.startsWith(
603
710
  "settings:set:time-injection:",
604
711
  );
@@ -629,12 +736,14 @@ export function createTelegramSettingsMenuRuntime<
629
736
  }
630
737
  return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
631
738
  isProactivePushEnabled: deps.isProactivePushEnabled,
632
- areRichDraftPreviewsEnabled: deps.areRichDraftPreviewsEnabled,
739
+ areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
740
+ getAssistantRenderingMode: deps.getAssistantRenderingMode,
633
741
  getVoiceReplyMode: deps.getVoiceReplyMode,
634
742
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
635
743
  getTimeInjectionMode: deps.getTimeInjectionMode,
636
744
  setProactivePushEnabled: deps.setProactivePushEnabled,
637
- setRichDraftPreviewsEnabled: deps.setRichDraftPreviewsEnabled,
745
+ setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
746
+ setAssistantRenderingMode: deps.setAssistantRenderingMode,
638
747
  setVoiceReplyMode: deps.setVoiceReplyMode,
639
748
  setTimeInjectionMode: deps.setTimeInjectionMode,
640
749
  updateSettingsMessage: (text, replyMarkup) =>
package/lib/preview.ts CHANGED
@@ -73,6 +73,7 @@ export interface TelegramAssistantMessagePreviewStartDeps<
73
73
  getState: () => TelegramPreviewRuntimeState | undefined;
74
74
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
75
75
  createPreviewState: () => TelegramPreviewRuntimeState;
76
+ canSend?: () => boolean;
76
77
  finalizePreview: (chatId: number) => Promise<boolean>;
77
78
  finalizeMarkdownPreview: (
78
79
  chatId: number,
@@ -88,6 +89,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
88
89
  getState: () => TelegramPreviewRuntimeState | undefined;
89
90
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
90
91
  createPreviewState: () => TelegramPreviewRuntimeState;
92
+ canSend?: () => boolean;
91
93
  getMessageText: (message: TMessage) => string;
92
94
  schedulePreviewFlush: (
93
95
  chatId: number,
@@ -260,6 +262,7 @@ export function createTelegramAssistantPreviewRuntime<
260
262
  getState: controller.getState,
261
263
  setState: controller.setState,
262
264
  createPreviewState: controller.createState,
265
+ canSend: deps.canSend,
263
266
  finalizePreview: controller.finalize,
264
267
  finalizeMarkdownPreview,
265
268
  getMessageText: deps.getMessageText,
@@ -349,6 +352,10 @@ export async function handleTelegramAssistantMessagePreviewStart<
349
352
  ): Promise<void> {
350
353
  const turn = deps.getActiveTurn();
351
354
  if (!turn || !deps.isAssistantMessage(message)) return;
355
+ if (deps.canSend && !deps.canSend()) {
356
+ deps.setState(undefined);
357
+ return;
358
+ }
352
359
  if (shouldSuppressPreviewForVoice(turn)) {
353
360
  deps.setState(undefined);
354
361
  return;
@@ -382,6 +389,10 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
382
389
  ): Promise<void> {
383
390
  const turn = deps.getActiveTurn();
384
391
  if (!turn || !deps.isAssistantMessage(message)) return;
392
+ if (deps.canSend && !deps.canSend()) {
393
+ deps.setState(undefined);
394
+ return;
395
+ }
385
396
  if (shouldSuppressPreviewForVoice(turn)) return;
386
397
  let state = deps.getState();
387
398
  if (!state) {
package/lib/replies.ts CHANGED
@@ -27,6 +27,12 @@ export {
27
27
  type TelegramRenderMode,
28
28
  };
29
29
 
30
+ export function renderTelegramMarkdownToHtmlDraft(markdown: string): string {
31
+ return renderTelegramMessage(markdown, { mode: "markdown" })
32
+ .map((chunk) => chunk.text)
33
+ .join("\n");
34
+ }
35
+
30
36
  export const TELEGRAM_RICH_MESSAGE_MAX_CHARS = 32768;
31
37
  export const TELEGRAM_RICH_MESSAGE_MAX_BLOCKS = 500;
32
38
 
@@ -678,6 +684,8 @@ export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
678
684
  // UI/compat regular-message runtime for bridge-owned text and interactive
679
685
  // surfaces. Assistant and guest Markdown delivery bypass this path and use
680
686
  // native Rich Message helpers above.
687
+ export type TelegramAssistantRenderingMode = "rich" | "html";
688
+
681
689
  export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
682
690
  renderTelegramMessage: (
683
691
  text: string,
@@ -685,6 +693,7 @@ export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
685
693
  ) => TelegramRenderedChunk[];
686
694
  replyTransport: TelegramReplyTransport<TReplyMarkup>;
687
695
  recordOwnership?: TelegramReplyOwnershipRecorder["record"];
696
+ getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
688
697
  sendRichMessage: (
689
698
  body: TelegramSendRichMessageBody,
690
699
  ) => Promise<TelegramSentMessage>;
@@ -732,6 +741,7 @@ export interface TelegramRenderedMessageDeliveryRuntimeDeps<
732
741
  text: string,
733
742
  options?: { mode?: TelegramRenderMode },
734
743
  ) => TelegramRenderedChunk[];
744
+ getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
735
745
  sendRichMessage: (
736
746
  body: TelegramSendRichMessageBody,
737
747
  ) => Promise<TelegramSentMessage>;
@@ -752,6 +762,7 @@ export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
752
762
  deps.renderTelegramMessage ?? renderTelegramMessage,
753
763
  replyTransport,
754
764
  recordOwnership: deps.recordOwnership,
765
+ getAssistantRenderingMode: deps.getAssistantRenderingMode,
755
766
  sendRichMessage: deps.sendRichMessage,
756
767
  }),
757
768
  };
@@ -777,13 +788,14 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
777
788
  );
778
789
  },
779
790
  sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
780
- if (typeof options?.target?.threadId === "number" && replyToMessageId && replyToMessageId > 0) {
791
+ const renderingMode = deps.getAssistantRenderingMode?.() ?? "rich";
792
+ if (renderingMode === "html") {
781
793
  return deps.replyTransport.sendRenderedChunks(
782
794
  chatId,
783
795
  deps.renderTelegramMessage(markdown, { mode: "markdown" }),
784
796
  {
785
- replyMarkup: options.replyMarkup,
786
- target: options.target,
797
+ replyMarkup: options?.replyMarkup,
798
+ target: options?.target,
787
799
  replyToMessageId,
788
800
  },
789
801
  );
@@ -1112,6 +1112,24 @@ export function createTelegramNativeMarkdownDraftSender(deps: {
1112
1112
  };
1113
1113
  }
1114
1114
 
1115
+ export function createTelegramAssistantDraftSender(deps: {
1116
+ getAssistantRenderingMode: () => "rich" | "html";
1117
+ renderMarkdownToHtmlDraft: (markdown: string) => string;
1118
+ sendMessageDraft: TelegramBridgeApiRuntime["sendMessageDraft"];
1119
+ sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
1120
+ }): TelegramBridgeApiRuntime["sendMessageDraft"] {
1121
+ const sendNativeDraft = createTelegramNativeMarkdownDraftSender(deps);
1122
+ return (chatId, draftId, text, options) => {
1123
+ if (text === undefined || deps.getAssistantRenderingMode() === "rich") {
1124
+ return sendNativeDraft(chatId, draftId, text, options);
1125
+ }
1126
+ return deps.sendMessageDraft(chatId, draftId, deps.renderMarkdownToHtmlDraft(text), {
1127
+ ...options,
1128
+ parse_mode: "HTML",
1129
+ });
1130
+ };
1131
+ }
1132
+
1115
1133
  export function createDefaultTelegramBridgeApiRuntime(deps: {
1116
1134
  getBotToken: () => string | undefined;
1117
1135
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"