From 25d5ead45d84aa7fd4375bdc7af66e101816200c Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Mon, 20 Jul 2026 20:47:30 +0200 Subject: [PATCH] feat(channels): adopt Telegram Bot API 10.1 rich-text formatting and fix guest-mode escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest-mode responses (TelegramChannel::flush_chunks) now format through the same markdown_to_telegram renderer used by regular messages and send with parse_mode="MarkdownV2" instead of an unconverted "HTML" call, which previously either broke Telegram's HTML parser or rendered literal Markdown to guest users. TelegramRenderer now prefixes every line of a multi-line blockquote with ">" (previously only the first line was quoted), and nested blockquotes flatten to a single level instead of accumulating an unescaped ">>", which Telegram's MarkdownV2 grammar rejects outright (400 can't parse entities, dropping the whole message). A nesting-depth cap (MAX_BLOCKQUOTE_NESTING_DEPTH = 512, same pattern as MAX_CHUNK_DEPTH from #6595) bounds tracked-mark memory for adversarially deep input. Adds TelegramConfig.expandable_blockquote_min_lines (default 10): blockquotes at or above the threshold render as Bot API 10.1's expandable (collapsed-by-default) form; 0 disables it unconditionally. Wired into --init and --migrate-config. Also wires TelegramConfig.guest_mode/.bot_to_bot into create_channel — these fields were previously accepted but never threaded into the TelegramChannel builder, so Guest Mode and Bot-to-Bot were unreachable in a running agent even with both enabled. Fixes a latent crash this exposed: the Guest Mode proxy's TcpListener was never set non-blocking before handing it to tokio, which panics on recent tokio versions. Closes #6541 --- CHANGELOG.md | 38 +++ book/src/guides/telegram.md | 18 +- config/default.toml | 3 + crates/zeph-channels/src/markdown.rs | 306 +++++++++++++++++- crates/zeph-channels/src/telegram.rs | 186 ++++++++++- crates/zeph-channels/src/telegram_api_ext.rs | 2 +- crates/zeph-config/src/channels.rs | 17 + crates/zeph-config/src/migrate/features.rs | 99 ++++++ crates/zeph-config/src/migrate/mod.rs | 16 +- crates/zeph-config/src/migrate/steps.rs | 22 +- crates/zeph-config/src/migrate/tests.rs | 7 +- crates/zeph-core/tests/vault_integration.rs | 1 + .../007-channels/007-1-telegram-guest-mode.md | 10 +- .../007-channels/007-3-telegram-rich-text.md | 2 +- src/channel.rs | 10 +- src/init/mod.rs | 1 + src/startup_checks.rs | 1 + src/tests.rs | 32 ++ 18 files changed, 737 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e429626a9..72a6492eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- `zeph-channels`: Telegram guest-mode responses (`TelegramChannel::flush_chunks`) are now + formatted through the same `markdown_to_telegram` renderer used by regular messages, and + sent with `parse_mode = "MarkdownV2"` instead of an unconverted `"HTML"` call — raw LLM + Markdown could previously break Telegram's HTML parser or render literal markup to guest + users (issue #6541, spec 007-3-telegram-rich-text). `TelegramRenderer` also now prefixes + every line of a multi-line blockquote with `>` (`markdown.rs`); previously only the first + line of a multi-line quote was actually quoted by Telegram's client. Nested blockquotes now + flatten to a single `>`-per-line level instead of accumulating an unescaped `>>` — Telegram + MarkdownV2 has no nested-blockquote grammar and rejects a doubled `>` outright (`400 can't + parse entities`, dropping the whole message); a nesting-depth cap + (`MAX_BLOCKQUOTE_NESTING_DEPTH = 512`, same defence-in-depth pattern as `MAX_CHUNK_DEPTH`, + #6595) bounds the tracked-mark memory for adversarially deep nested input. +- `src/channel.rs`: `create_channel` now wires `TelegramConfig.guest_mode` and `.bot_to_bot` + (plus `allowed_bots`/`max_bot_chain_depth`) into `TelegramChannel` — previously these config + fields were accepted but never threaded into the builder, so Guest Mode and Bot-to-Bot were + unreachable in a real running agent even with both enabled in config (pre-existing gap, + found during review of #6541; not previously filed as its own issue). +- `zeph-channels`: `TelegramChannel::start`'s Guest Mode proxy (`spawn_guest_proxy`) now calls + `set_nonblocking(true)` on the bound `std::net::TcpListener` before handing it to + `tokio::net::TcpListener::from_std` — tokio requires a non-blocking socket for that + conversion, and a recent tokio release turned the previous silent misbehavior into a hard + panic ("Registering a blocking socket with the tokio runtime is unsupported", see + tokio-rs/tokio#7172). Latent since Guest Mode's original implementation; unreachable in + tests or production until the `guest_mode` wiring fix above made this code path actually + run — without this fix, enabling `guest_mode = true` would have crashed the process. + +### Added + +- `zeph-channels`/`zeph-config`: adopt Telegram Bot API 10.1 expandable blockquotes + (issue #6541, spec 007-3-telegram-rich-text). New `TelegramConfig.expandable_blockquote_min_lines` + (default `10`) renders blockquotes at or above the configured line count as the expandable + (collapsed-by-default) form (`**>` … `\|\|`); `0` disables the expandable form + unconditionally. New `markdown::markdown_to_telegram_with_config` accepts an explicit + threshold; `markdown_to_telegram` is unchanged and delegates to it with the default of 10. + Wired into `--init` and `--migrate-config`. + ### Removed - `zeph-agent-tools`: removed the unused `AgentChannel` sealed trait and its supporting diff --git a/book/src/guides/telegram.md b/book/src/guides/telegram.md index 3251669f4..6b69178e4 100644 --- a/book/src/guides/telegram.md +++ b/book/src/guides/telegram.md @@ -44,7 +44,8 @@ Telegram has API rate limits, so streaming works differently from CLI. Zeph batc - Subsequent chunks accumulate and edit the existing message in-place - Edit interval is configurable via `stream_interval_ms` (default 3000ms, minimum 500ms) - Long messages (>4096 chars) are automatically split -- MarkdownV2 formatting is applied automatically +- MarkdownV2 formatting is applied automatically, including multi-line blockquotes — every + quoted line renders inside Telegram's blockquote UI, not just the first ### Configuring Stream Interval @@ -61,6 +62,21 @@ stream_interval_ms = 3000 # Edit every 3 seconds (default) Lower values provide more responsive feedback but consume more API quota. Higher values reduce API calls but responses appear less fluid. Start with the default and adjust based on your network speed and API rate limit tolerance. +### Configuring Expandable Blockquotes + +Long blockquotes can render collapsed by default, using Telegram's expandable blockquote UI (Bot API 10.1): + +```toml +[telegram] +expandable_blockquote_min_lines = 10 # default +# Collapse quotes of 5+ lines instead: +# expandable_blockquote_min_lines = 5 +# Disable the expandable form entirely — always render fully expanded: +# expandable_blockquote_min_lines = 0 +``` + +Blockquotes with at least `expandable_blockquote_min_lines` lines render collapsed, with a "Show more" affordance in the Telegram client; shorter quotes render as a regular (always-expanded) blockquote. Setting this to `0` disables the expandable form unconditionally, regardless of quote length. + ## Guest Mode and Bot-to-Bot Communication Zeph supports advanced Telegram modes for integration with other bots and guest users. diff --git a/config/default.toml b/config/default.toml index c43afc4cd..a92afc69a 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1062,6 +1062,9 @@ circuit_breaker_cooldown_secs = 30 # token = "your-bot-token" # Allowed usernames (empty = allow all except for /start command) # allowed_users = ["username1", "username2"] +# Blockquotes with this many lines or more render as an expandable (collapsed-by-default) +# blockquote (Bot API 10.1 expandable_blockquote). 0 disables the expandable form entirely. +# expandable_blockquote_min_lines = 10 [timeouts] # LLM chat completion timeout in seconds diff --git a/crates/zeph-channels/src/markdown.rs b/crates/zeph-channels/src/markdown.rs index ccbe4fcb2..7a14cca3d 100644 --- a/crates/zeph-channels/src/markdown.rs +++ b/crates/zeph-channels/src/markdown.rs @@ -10,7 +10,10 @@ //! //! # Public API //! -//! * [`markdown_to_telegram`] — convert `CommonMark` to Telegram `MarkdownV2`. +//! * [`markdown_to_telegram`] — convert `CommonMark` to Telegram `MarkdownV2` using the +//! default expandable-blockquote threshold. +//! * [`markdown_to_telegram_with_config`] — same conversion with an explicit +//! expandable-blockquote line-count threshold (Bot API 10.1 `expandable_blockquote`). //! * [`utf8_chunks`] — split long strings at UTF-8 / newline boundaries. use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; @@ -19,6 +22,10 @@ const SPECIAL_CHARS: &[char] = &[ '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\', ]; +/// Default blockquote line-count threshold for the expandable form, matching +/// `TelegramConfig::expandable_blockquote_min_lines`'s default (Bot API 10.1). +pub(crate) const DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES: u32 = 10; + /// Convert standard Markdown to Telegram `MarkdownV2` format. /// /// Uses `pulldown-cmark` to parse the input into an event stream, then walks @@ -53,9 +60,42 @@ const SPECIAL_CHARS: &[char] = &[ /// ``` #[must_use] pub fn markdown_to_telegram(input: &str) -> String { + markdown_to_telegram_with_config(input, DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES) +} + +/// Convert standard Markdown to Telegram `MarkdownV2`, with an explicit expandable-blockquote +/// line-count threshold (Bot API 10.1 `expandable_blockquote`). +/// +/// Identical to [`markdown_to_telegram`] except a blockquote spanning +/// `expandable_blockquote_min_lines` lines or more renders as the expandable +/// (collapsed-by-default) form — `**>` on the first line, `\|\|` appended to the +/// last line, `>` on every line in between. `expandable_blockquote_min_lines = 0` +/// disables the expandable form unconditionally, regardless of quote length. +/// +/// # Examples +/// +/// ```rust +/// use zeph_channels::markdown::markdown_to_telegram_with_config; +/// +/// let quote = "> line 1\n> line 2\n> line 3"; +/// +/// // Threshold of 3 makes a 3-line quote expandable. +/// let expandable = markdown_to_telegram_with_config(quote, 3); +/// assert!(expandable.starts_with("**>line 1")); +/// assert!(expandable.trim_end().ends_with("||")); +/// +/// // A threshold of 0 always disables the expandable form. +/// let never_expandable = markdown_to_telegram_with_config(quote, 0); +/// assert!(!never_expandable.contains("**>")); +/// ``` +#[must_use] +pub fn markdown_to_telegram_with_config( + input: &str, + expandable_blockquote_min_lines: u32, +) -> String { let options = Options::ENABLE_STRIKETHROUGH; let parser = Parser::new_ext(input, options); - let mut renderer = TelegramRenderer::new(input.len()); + let mut renderer = TelegramRenderer::new(input.len(), expandable_blockquote_min_lines); for event in parser { renderer.push_event(event); } @@ -132,18 +172,45 @@ pub fn utf8_chunks(text: &str, max_bytes: usize) -> Vec<&str> { chunks } +/// Maximum tracked blockquote nesting depth. Levels beyond this are still parsed +/// (pulldown-cmark emits balanced `Start`/`End` events regardless) but no `output` +/// mark is recorded for them, so [`TelegramRenderer::blockquote_marks`] cannot grow +/// past this bound even under adversarially deep nested input. Same defence-in-depth +/// pattern as `MAX_CHUNK_DEPTH` (`crates/zeph-index/src/chunker.rs`, #6595) — this +/// path has no native-recursion stack-overflow risk (pulldown-cmark's event stream +/// and this renderer are both flat iterators), but the cap still bounds memory and +/// gives a documented, tested ceiling instead of an unbounded one. +const MAX_BLOCKQUOTE_NESTING_DEPTH: usize = 512; + struct TelegramRenderer { output: String, in_code_block: bool, link_url: Option, + /// Blockquote line-count threshold for the expandable form (0 = always disabled). + expandable_blockquote_min_lines: u32, + /// Stack of `output` byte offsets recorded at each `BlockQuote` start, up to + /// [`MAX_BLOCKQUOTE_NESTING_DEPTH`] entries. Only the outermost mark (stack empty + /// after popping) triggers the `>`-per-line prefix rewrite in + /// [`Self::end_blockquote`] — nested blockquotes are flattened to that single + /// level, since Telegram `MarkdownV2` has no nested-blockquote grammar. + blockquote_marks: Vec, + /// True `BlockQuote` nesting depth, incremented/decremented on every `Start`/`End` + /// regardless of the [`MAX_BLOCKQUOTE_NESTING_DEPTH`] cap — kept separate from + /// `blockquote_marks.len()` so `end_blockquote` can tell whether the level it is + /// closing had a mark recorded (within the cap) or not (beyond it), keeping + /// push/pop balanced either way. + blockquote_depth: usize, } impl TelegramRenderer { - fn new(capacity: usize) -> Self { + fn new(capacity: usize, expandable_blockquote_min_lines: u32) -> Self { Self { output: String::with_capacity(capacity), in_code_block: false, link_url: None, + expandable_blockquote_min_lines, + blockquote_marks: Vec::new(), + blockquote_depth: 0, } } @@ -197,17 +264,94 @@ impl TelegramRenderer { self.output.push_str("• "); } Event::Start(Tag::BlockQuote(_)) => { - self.output.push('>'); + self.blockquote_depth += 1; + if self.blockquote_marks.len() < MAX_BLOCKQUOTE_NESTING_DEPTH { + self.blockquote_marks.push(self.output.len()); + } } - Event::End(TagEnd::Paragraph | TagEnd::Item | TagEnd::BlockQuote(_)) - | Event::SoftBreak - | Event::HardBreak => { + Event::End(TagEnd::BlockQuote(_)) => { + self.end_blockquote(); + } + Event::End(TagEnd::Paragraph | TagEnd::Item) | Event::SoftBreak | Event::HardBreak => { self.output.push('\n'); } _ => {} } } + /// Close the innermost open blockquote: take everything emitted since its `Start` + /// event, split it into lines, and re-emit with a `>` prefix on every line (FR-005), + /// switching to the expandable form (`**>` … `\|\|`) when the line count meets + /// `expandable_blockquote_min_lines` (FR-006/FR-007). + /// + /// Always appends two trailing newlines after the prefixed block, mirroring the + /// pre-fix behaviour of the `TagEnd::Paragraph`/`TagEnd::BlockQuote` shared arm + /// (one newline from the inner paragraph's end, one from the blockquote's own end) + /// so that block-level spacing is unchanged for callers (NFR-002/NFR-003). + /// + /// Telegram `MarkdownV2` has no nested-blockquote grammar: a blockquote is exactly + /// one leading `>` per line, and a second `>` on the same line is an unescaped + /// reserved character that Telegram's parser rejects outright (`400 Bad Request`, + /// dropping the whole message). A `BlockQuote` end that is still nested inside + /// another open blockquote is therefore a no-op here: its content is left exactly + /// as emitted, so it stays part of the enclosing blockquote's captured span and + /// gets the single `>` prefix exactly once, when the outermost call below runs — + /// nested quotes are flattened to one level, never accumulated as `>>`. This also + /// keeps cost linear in the total content size regardless of nesting depth: only + /// the outermost call ever splits/rescans, instead of once per nesting level. + fn end_blockquote(&mut self) { + let Some(closing_level) = self.blockquote_depth.checked_sub(1) else { + // Unbalanced `BlockQuote` end with no matching start — pulldown-cmark never + // emits this for well-formed input, but fail safe rather than panic. + return; + }; + self.blockquote_depth = closing_level; + + if closing_level >= MAX_BLOCKQUOTE_NESTING_DEPTH { + // Beyond MAX_BLOCKQUOTE_NESTING_DEPTH: no mark was recorded for this level + // (see the `Start` handler), so there is nothing to pop or process — its + // content already flows straight into the nearest tracked ancestor. + return; + } + + let Some(mark) = self.blockquote_marks.pop() else { + // Unreachable given the invariant above (marks.len() == min(depth, cap) + // at all times), but fail safe rather than panic. + return; + }; + if !self.blockquote_marks.is_empty() { + // Still inside an enclosing blockquote — flatten (see doc comment above). + return; + } + let content = self.output.split_off(mark); + let trimmed = content.trim_end_matches('\n'); + let lines: Vec<&str> = if trimmed.is_empty() { + vec![""] + } else { + trimmed.split('\n').collect() + }; + let line_count = lines.len(); + let line_count_u32 = u32::try_from(line_count).unwrap_or(u32::MAX); + let expandable = self.expandable_blockquote_min_lines > 0 + && line_count_u32 >= self.expandable_blockquote_min_lines; + + for (i, line) in lines.iter().enumerate() { + if i > 0 { + self.output.push('\n'); + } + if expandable && i == 0 { + self.output.push_str("**>"); + } else { + self.output.push('>'); + } + self.output.push_str(line); + if expandable && i == line_count - 1 { + self.output.push_str("||"); + } + } + self.output.push_str("\n\n"); + } + fn escape_text(text: &str) -> String { let mut result = String::with_capacity(text.len() * 2); for c in text.chars() { @@ -322,6 +466,154 @@ mod tests { assert!(output.starts_with('>')); } + #[test] + fn test_multiline_blockquote_prefixes_every_line() { + // 3 lines — below the default 10-line expandable threshold. + let input = "> line 1\n> line 2\n> line 3"; + let output = markdown_to_telegram(input); + for line in ["line 1", "line 2", "line 3"] { + assert!( + output.contains(&format!(">{line}")), + "expected '>{line}' in output: {output:?}" + ); + } + assert!( + !output.contains("**>"), + "must not be expandable: {output:?}" + ); + assert!(!output.contains("||"), "must not be expandable: {output:?}"); + } + + #[test] + fn test_blockquote_line_counts_two_to_nine_below_default_threshold() { + for n in 2..=9 { + let lines: Vec = (1..=n).map(|i| format!("> line {i}")).collect(); + let input = lines.join("\n"); + let output = markdown_to_telegram(&input); + for i in 1..=n { + assert!( + output.contains(&format!(">line {i}")), + "n={n}: expected '>line {i}' in output: {output:?}" + ); + } + assert!(!output.contains("**>"), "n={n}: must not be expandable"); + } + } + + #[test] + fn test_blockquote_expandable_at_threshold_boundary() { + let lines: Vec = (1..=5).map(|i| format!("> line {i}")).collect(); + let input = lines.join("\n"); + + // Exactly at the threshold — must render expandable (>= comparison, FR-006). + let expandable = markdown_to_telegram_with_config(&input, 5); + assert!( + expandable.starts_with("**>line 1"), + "output: {expandable:?}" + ); + assert!( + expandable.trim_end().ends_with("||"), + "output: {expandable:?}" + ); + + // One line short of the threshold — must render the regular form (FR-006/FR-007). + let regular = markdown_to_telegram_with_config(&input, 6); + assert!(!regular.contains("**>"), "output: {regular:?}"); + assert!(!regular.ends_with("||"), "output: {regular:?}"); + assert!(regular.starts_with(">line 1"), "output: {regular:?}"); + } + + #[test] + fn test_expandable_blockquote_min_lines_zero_disables_expandable_unconditionally() { + let lines: Vec = (1..=50).map(|i| format!("> line {i}")).collect(); + let input = lines.join("\n"); + let output = markdown_to_telegram_with_config(&input, 0); + assert!(!output.contains("**>"), "output: {output:?}"); + assert!(!output.ends_with("||"), "output: {output:?}"); + for i in 1..=50 { + assert!(output.contains(&format!(">line {i}"))); + } + } + + #[test] + fn test_nested_blockquote_flattens_to_single_level() { + // Telegram MarkdownV2 has no nested-blockquote grammar: a blockquote is + // exactly one leading '>' per line. A second '>' is an unescaped reserved + // character that Telegram's parser rejects outright (400 Bad Request, + // dropping the whole message) — so nested quotes must flatten to one level, + // never accumulate as '>>'. + let input = "> outer\n> > inner"; + let output = markdown_to_telegram(input); + assert_eq!(output, ">outer\n>inner\n"); + assert!( + !output.contains(">>"), + "nested blockquote must never emit a doubled '>' prefix: {output:?}" + ); + } + + #[test] + fn test_blockquote_nesting_beyond_depth_cap_stays_bounded() { + // Regression for the depth-cap guard (same defence-in-depth pattern as + // MAX_CHUNK_DEPTH, #6595): nesting far past MAX_BLOCKQUOTE_NESTING_DEPTH must + // not panic, hang, or produce a doubled '>' — it flattens exactly like a + // shallow nested quote, just with all levels merged into the one tracked + // (outermost) blockquote. + let depth = 600; // comfortably past MAX_BLOCKQUOTE_NESTING_DEPTH (512) + let input = format!("{}deep", "> ".repeat(depth)); + let output = markdown_to_telegram(&input); + assert_eq!(output, ">deep\n"); + assert!(!output.contains(">>"), "output: {output:?}"); + } + + #[test] + fn test_blockquote_nesting_within_depth_cap_still_flattens() { + // A depth comfortably below the cap must behave identically to the + // beyond-cap case above — the cap must never change *correctness*, only bound + // the tracked-mark memory for pathological input. + let depth = 20; // far below MAX_BLOCKQUOTE_NESTING_DEPTH — guard must never engage + let input = format!("{}shallow", "> ".repeat(depth)); + let output = markdown_to_telegram(&input); + assert_eq!(output, ">shallow\n"); + assert!(!output.contains(">>"), "output: {output:?}"); + } + + #[test] + fn test_short_blockquote_unaffected_by_expandable_config() { + // A single-line blockquote must render identically regardless of the configured + // threshold — parity with pre-fix output for quotes below any reasonable threshold. + let input = "> quote"; + let default_output = markdown_to_telegram(input); + let custom_output = markdown_to_telegram_with_config(input, 1); + assert_eq!(default_output, ">quote\n"); + // threshold=1 with a single-line quote meets the expandable condition (1 >= 1). + assert!(custom_output.starts_with("**>quote")); + assert!(custom_output.trim_end().ends_with("||")); + } + + #[test] + fn test_blockquote_line_with_special_chars_escaped_exactly_as_outside_blockquote() { + // Spec 007-3 §10: a blockquote line containing MarkdownV2 special characters is + // escaped exactly as it would be outside a blockquote — the '>' prefix is + // prepended to the already-escaped content, not interleaved with escaping. + let input = "> Special: . ! - + = | { }"; + let output = markdown_to_telegram(input); + assert_eq!(output, ">Special: \\. \\! \\- \\+ \\= \\| \\{ \\}\n"); + } + + #[test] + fn test_fenced_code_block_inside_blockquote_pins_current_behavior() { + // Security/critic finding M1: whether Telegram's real MarkdownV2 parser wants + // the code-fence delimiters ("```") prefixed with '>' like every other quoted + // line, or wants code content excluded from per-line prefixing, is NOT + // verified here — that needs a live Telegram client (spec 007-3 SC-006/SC-007 + // are still unrun). This test pins the CURRENT behavior (every captured line, + // fences included, gets a single '>' prefix) so a future change to it is a + // deliberate, reviewed decision instead of a silent regression either way. + let input = "> ```\n> code line\n> ```"; + let output = markdown_to_telegram(input); + assert_eq!(output, ">```\n>code line\n>```\n"); + } + #[test] fn test_lists() { let input = "- item 1\n- item 2"; diff --git a/crates/zeph-channels/src/telegram.rs b/crates/zeph-channels/src/telegram.rs index 9fe393a60..97f18f044 100644 --- a/crates/zeph-channels/src/telegram.rs +++ b/crates/zeph-channels/src/telegram.rs @@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use crate::markdown::markdown_to_telegram; +use crate::markdown::markdown_to_telegram_with_config; use crate::streaming::StreamingBuffer; use crate::telegram_api_ext::{BotAccessSettings, GuestMessage, TelegramApiClient}; use axum::Router; @@ -119,6 +119,10 @@ pub struct TelegramChannel { allowed_bots: Vec, /// Maximum consecutive bot replies per chat before dropping. max_bot_chain_depth: u32, + /// Blockquotes with this many lines or more render as an expandable + /// (collapsed-by-default) blockquote (Bot API 10.1 `expandable_blockquote`). + /// `0` disables the expandable form unconditionally. + expandable_blockquote_min_lines: u32, /// Per-chat consecutive bot reply counters for loop prevention. bot_reply_counters: BotReplyCounters, /// Optional supervisor used to register the Telegram listener task in the @@ -144,6 +148,10 @@ impl std::fmt::Debug for TelegramChannel { ) .field("allowed_bots_count", &self.allowed_bots.len()) .field("max_bot_chain_depth", &self.max_bot_chain_depth) + .field( + "expandable_blockquote_min_lines", + &self.expandable_blockquote_min_lines, + ) .field("supervisor", &self.supervisor.is_some()) .finish_non_exhaustive() } @@ -196,6 +204,8 @@ impl TelegramChannel { bot_to_bot_active: Arc::new(AtomicBool::new(false)), allowed_bots: Vec::new(), max_bot_chain_depth: 1, + expandable_blockquote_min_lines: + crate::markdown::DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES, bot_reply_counters: Arc::new(Mutex::new(HashMap::new())), supervisor: None, guest_proxy_handle: None, @@ -265,6 +275,19 @@ impl TelegramChannel { self } + /// Set the blockquote line-count threshold for the expandable form (Bot API 10.1 + /// `expandable_blockquote`). + /// + /// Blockquotes with at least `min_lines` lines render as the expandable + /// (collapsed-by-default) form. `0` disables the expandable form unconditionally. + /// + /// Default: 10. + #[must_use] + pub fn with_expandable_blockquote_min_lines(mut self, min_lines: u32) -> Self { + self.expandable_blockquote_min_lines = min_lines; + self + } + /// Spawn a fire-and-forget task that registers bot commands in the Telegram menu. /// /// When a supervisor is provided the task is registered under `telegram_register_commands` @@ -466,6 +489,8 @@ impl TelegramChannel { bot_to_bot_active: Arc::new(AtomicBool::new(false)), allowed_bots: Vec::new(), max_bot_chain_depth: 1, + expandable_blockquote_min_lines: + crate::markdown::DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES, bot_reply_counters: Arc::new(Mutex::new(HashMap::new())), supervisor: None, guest_proxy_handle: None, @@ -530,7 +555,8 @@ impl TelegramChannel { &text_owned }; - let formatted_text = markdown_to_telegram(text); + let formatted_text = + markdown_to_telegram_with_config(text, self.expandable_blockquote_min_lines); if formatted_text.is_empty() { tracing::debug!("skipping send: formatted text is empty"); @@ -696,6 +722,17 @@ fn spawn_guest_proxy( .route("/{*path}", any(proxy_handler)) .with_state(state); + // `std::net::TcpListener` binds in blocking mode; tokio requires a non-blocking + // socket before `TcpListener::from_std` (undocumented UB pre-1.x, now a hard + // panic: "Registering a blocking socket with the tokio runtime is unsupported", + // see tokio-rs/tokio#7172). Was previously unreachable in the test suite because + // nothing exercised this path end-to-end (guest_mode was never wired into + // `create_channel`, see issue #6541 review) — found the moment that wiring + // landed and a test finally drove `TelegramChannel::start()` with + // `guest_mode = true`. + listener + .set_nonblocking(true) + .map_err(|e| ChannelError::Other(format!("guest proxy: set_nonblocking failed: {e}")))?; let listener = tokio::net::TcpListener::from_std(listener).map_err(|e| { ChannelError::Other(format!("guest proxy: TcpListener conversion failed: {e}")) })?; @@ -1175,7 +1212,7 @@ impl Channel for TelegramChannel { /// Send a complete message to the active Telegram chat. /// - /// The text is converted to `MarkdownV2` via [`markdown_to_telegram`] + /// The text is converted to `MarkdownV2` via [`markdown_to_telegram_with_config`] /// before sending. Messages longer than 4096 bytes are split into /// multiple messages at UTF-8 / newline boundaries. /// @@ -1199,7 +1236,8 @@ impl Channel for TelegramChannel { return Err(ChannelError::NoActiveSession); }; - let formatted_text = markdown_to_telegram(text); + let formatted_text = + markdown_to_telegram_with_config(text, self.expandable_blockquote_min_lines); if formatted_text.is_empty() { tracing::debug!("skipping send: formatted text is empty"); @@ -1286,21 +1324,27 @@ impl Channel for TelegramChannel { ); // Guest context: send accumulated text via answerGuestQuery (single call). + // Routed through the same markdown_to_telegram renderer as the regular send + // path — raw, unconverted LLM text must never reach answer_guest_query (spec + // 007-3 FR-001/FR-002, Key Invariants). if let Some(query_id) = self.guest_query_id.take() { let full_text = self.buffer.take(); - let text = full_text.trim().to_owned(); - if text.len() > MAX_MESSAGE_LEN { + let formatted = markdown_to_telegram_with_config( + full_text.trim(), + self.expandable_blockquote_min_lines, + ); + if formatted.len() > MAX_MESSAGE_LEN { tracing::warn!( query_id, - bytes = text.len(), + bytes = formatted.len(), max = MAX_MESSAGE_LEN, "guest response exceeds 4096 bytes; Telegram truncates answerGuestQuery — consider shorter responses" ); } - if !text.is_empty() + if !formatted.is_empty() && let Err(e) = self .api_ext - .answer_guest_query(&query_id, &text, Some("HTML")) + .answer_guest_query(&query_id, &formatted, Some("MarkdownV2")) .await { tracing::warn!(query_id, "answer_guest_query failed: {e}"); @@ -1619,6 +1663,8 @@ mod tests { bot_to_bot_active: Arc::new(AtomicBool::new(false)), allowed_bots: Vec::new(), max_bot_chain_depth: 1, + expandable_blockquote_min_lines: + crate::markdown::DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES, bot_reply_counters: Arc::new(Mutex::new(HashMap::new())), supervisor: None, guest_proxy_handle: None, @@ -2404,6 +2450,128 @@ mod tests { assert!(channel.buffer.is_empty()); } + /// Spec 007-3 FR-001/FR-002/SC-001/SC-002: guest-mode responses must be formatted + /// through `markdown_to_telegram` and sent with `parse_mode = "MarkdownV2"`, never raw + /// text with `"HTML"`. + #[tokio::test] + async fn flush_chunks_guest_mode_formats_markdown_and_uses_markdown_v2() { + use wiremock::matchers::{method, path}; + + let server = MockServer::start().await; + let answer_mock = Mock::given(method("POST")) + .and(path("/answerGuestQuery")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "result": {"message_id": 1, "chat_id": 2} + }))); + let answer_handle = server.register_as_scoped(answer_mock).await; + + let (mut channel, _tx) = make_mocked_channel(&server, vec![]).await; + channel.guest_query_id = Some("qid42".to_string()); + channel + .buffer + .push("**bold** and `code` and raw html & special . chars"); + + channel.flush_chunks().await.unwrap(); + + let requests = answer_handle.received_requests().await; + assert_eq!(requests.len(), 1); + let body: serde_json::Value = requests[0].body_json().unwrap(); + assert_eq!( + body["parse_mode"], "MarkdownV2", + "guest-mode send must never use HTML (SC-002): {body}" + ); + let sent_text = body["text"].as_str().unwrap(); + // Formatted through markdown_to_telegram: bold markers converted, special chars + // escaped — raw unconverted LLM text (with literal `**`/``) must never reach the + // wire (spec Key Invariants). + assert_eq!( + sent_text, + markdown_to_telegram_with_config( + "**bold** and `code` and raw html & special . chars", + channel.expandable_blockquote_min_lines, + ) + ); + assert!(sent_text.contains("*bold*"), "text: {sent_text:?}"); + assert!(!sent_text.contains("**bold**"), "text: {sent_text:?}"); + } + + /// Spec 007-3 NFR-001: guest-mode and regular-message formatting must produce + /// identical output for the same input, verified through both real call sites. + #[tokio::test] + async fn guest_and_regular_send_produce_identical_formatted_text() { + use wiremock::matchers::{method, path, path_regex}; + + let input = + "# Title\n\n**bold** _italic_ `code`\n\n> line 1\n> line 2\n\n- item 1\n- item 2"; + + // Guest path. + let guest_server = MockServer::start().await; + let guest_mock = Mock::given(method("POST")) + .and(path("/answerGuestQuery")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "result": {"message_id": 1, "chat_id": 2} + }))); + let guest_handle = guest_server.register_as_scoped(guest_mock).await; + let (mut guest_channel, _tx) = make_mocked_channel(&guest_server, vec![]).await; + guest_channel.guest_query_id = Some("qid".to_string()); + guest_channel.buffer.push(input); + guest_channel.flush_chunks().await.unwrap(); + let guest_requests = guest_handle.received_requests().await; + let guest_body: serde_json::Value = guest_requests[0].body_json().unwrap(); + let guest_text = guest_body["text"].as_str().unwrap().to_owned(); + + // Regular path. + let regular_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex("(?i).*/sendmessage$")) + .respond_with(ResponseTemplate::new(200).set_body_json(tg_ok_message())) + .mount(®ular_server) + .await; + let (mut regular_channel, _tx2) = make_mocked_channel(®ular_server, vec![]).await; + regular_channel.send(input).await.unwrap(); + let regular_requests = regular_server.received_requests().await.unwrap(); + let send_req = regular_requests + .iter() + .find(|r| r.url.path().to_lowercase().ends_with("/sendmessage")) + .unwrap(); + let regular_body: serde_json::Value = send_req.body_json().unwrap(); + let regular_text = regular_body["text"].as_str().unwrap(); + + assert_eq!( + guest_text, regular_text, + "guest-mode and regular formatting must be identical for the same input" + ); + assert_eq!(regular_body["parse_mode"], "MarkdownV2"); + assert_eq!(guest_body["parse_mode"], "MarkdownV2"); + } + + /// Spec 007-3 US-003/SC-007: a blockquote at/above the configured threshold renders + /// expandable end-to-end through the regular `send` path. + #[tokio::test] + async fn send_renders_expandable_blockquote_at_configured_threshold() { + use wiremock::matchers::{method, path_regex}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex("(?i).*/sendmessage$")) + .respond_with(ResponseTemplate::new(200).set_body_json(tg_ok_message())) + .mount(&server) + .await; + let (mut channel, _tx) = make_mocked_channel(&server, vec![]).await; + channel = channel.with_expandable_blockquote_min_lines(3); + + let quote = "> line 1\n> line 2\n> line 3"; + channel.send(quote).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: serde_json::Value = requests[0].body_json().unwrap(); + let text = body["text"].as_str().unwrap(); + assert!(text.starts_with("**>line 1"), "text: {text:?}"); + assert!(text.trim_end().ends_with("||"), "text: {text:?}"); + } + #[tokio::test] async fn bot_to_bot_false_drops_bot_messages() { // With bot_to_bot=false, messages from bots must be silently dropped. diff --git a/crates/zeph-channels/src/telegram_api_ext.rs b/crates/zeph-channels/src/telegram_api_ext.rs index cc32e6fd1..c66630349 100644 --- a/crates/zeph-channels/src/telegram_api_ext.rs +++ b/crates/zeph-channels/src/telegram_api_ext.rs @@ -352,7 +352,7 @@ impl TelegramApiClient { /// /// # async fn example() -> Result<(), Box> { /// let client = TelegramApiClient::new("TOKEN"); - /// let sent = client.answer_guest_query("qid_123", "Hello!", Some("HTML")).await?; + /// let sent = client.answer_guest_query("qid_123", "Hello\\!", Some("MarkdownV2")).await?; /// println!("message_id={}", sent.message_id); /// # Ok(()) /// # } diff --git a/crates/zeph-config/src/channels.rs b/crates/zeph-config/src/channels.rs index ade6fce04..3bdd9f445 100644 --- a/crates/zeph-config/src/channels.rs +++ b/crates/zeph-config/src/channels.rs @@ -396,6 +396,7 @@ max_bot_chain_depth = 5 bot_to_bot: false, allowed_bots: Vec::new(), max_bot_chain_depth: default_max_bot_chain_depth(), + expandable_blockquote_min_lines: default_expandable_blockquote_min_lines(), }; let json = serde_json::to_string(&cfg).unwrap(); assert!(!json.contains("real-secret-value")); @@ -530,6 +531,10 @@ fn default_max_bot_chain_depth() -> u32 { 1 } +fn default_expandable_blockquote_min_lines() -> u32 { + 10 +} + /// Telegram channel configuration, nested under `[telegram]` in TOML. /// /// When present, Zeph connects to Telegram as a bot using the provided token. @@ -545,6 +550,7 @@ fn default_max_bot_chain_depth() -> u32 { /// bot_to_bot = true /// allowed_bots = ["@my_bot"] /// max_bot_chain_depth = 1 +/// expandable_blockquote_min_lines = 10 /// ``` #[derive(Clone, Deserialize, Serialize)] pub struct TelegramConfig { @@ -606,6 +612,13 @@ pub struct TelegramConfig { /// prevention across multiple top-level exchanges. #[serde(default = "default_max_bot_chain_depth")] pub max_bot_chain_depth: u32, + /// Blockquotes with this many lines or more render as an expandable + /// (collapsed-by-default) blockquote (Bot API 10.1 `expandable_blockquote`). + /// + /// `0` disables the expandable form entirely — all blockquotes render as + /// regular (always-expanded) quotes regardless of length. Default: 10. + #[serde(default = "default_expandable_blockquote_min_lines")] + pub expandable_blockquote_min_lines: u32, } impl std::fmt::Debug for TelegramConfig { @@ -620,6 +633,10 @@ impl std::fmt::Debug for TelegramConfig { .field("bot_to_bot", &self.bot_to_bot) .field("allowed_bots_count", &self.allowed_bots.len()) .field("max_bot_chain_depth", &self.max_bot_chain_depth) + .field( + "expandable_blockquote_min_lines", + &self.expandable_blockquote_min_lines, + ) .finish() } } diff --git a/crates/zeph-config/src/migrate/features.rs b/crates/zeph-config/src/migrate/features.rs index 5dd4fc5c1..baa08df76 100644 --- a/crates/zeph-config/src/migrate/features.rs +++ b/crates/zeph-config/src/migrate/features.rs @@ -1201,3 +1201,102 @@ mod rate_limit_advisory_tests { assert_eq!(result.output, base); } } + +/// Step 104 — add an `expandable_blockquote_min_lines` advisory comment to an existing +/// active `[telegram]` table (spec 007-3-telegram-rich-text, issue #6541). +/// +/// Advisory only: `#[serde(default)]` already makes existing configs load with the field +/// defaulted to `10`, so this migration is purely informational — it surfaces the option +/// without changing behaviour. Skipped when the key is already present or `[telegram]` is +/// absent (an unconfigured channel needs no advisory). Mirrors +/// [`migrate_orchestration_idle_timeout`]'s shape exactly (same section-presence guard, +/// same advisory-comment idiom). +/// +/// # Errors +/// +/// Returns [`MigrateError`] if the TOML document cannot be parsed. +pub fn migrate_telegram_expandable_blockquote_config( + toml_src: &str, +) -> Result { + if toml_src.contains("expandable_blockquote_min_lines") { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + if !section_header_present(toml_src, "telegram") { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let comment = "\n# expandable_blockquote_min_lines = 10 \ + # blockquotes with this many lines or more render as an expandable \ + # (collapsed-by-default) blockquote (Bot API 10.1); 0 disables the expandable form \ + # entirely (spec 007-3-telegram-rich-text, issue #6541)\n"; + let output = insert_after_section(toml_src, "telegram", comment); + // Defensive: the guards above already guarantee `[telegram]` is present and + // `insert_after_section` always inserts non-empty content when reached, so this is + // currently unreachable — kept for the same reason as + // `migrate_orchestration_idle_timeout` (#5945: a future change to either guard must not + // silently regress into reporting a change that didn't happen). + if output == toml_src { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + Ok(MigrationResult { + output, + changed_count: 1, + sections_changed: vec!["telegram.expandable_blockquote_min_lines".to_owned()], + }) +} + +#[cfg(test)] +mod telegram_expandable_blockquote_tests { + use super::*; + + #[test] + fn migrate_telegram_expandable_blockquote_appends_advisory_comment() { + let base = "[telegram]\ntoken = \"tok\"\nallowed_users = [\"alice\"]\n"; + let result = migrate_telegram_expandable_blockquote_config(base).unwrap(); + assert_eq!(result.changed_count, 1); + assert!( + result + .output + .contains("# expandable_blockquote_min_lines = 10") + ); + } + + #[test] + fn migrate_telegram_expandable_blockquote_noop_without_telegram_section() { + let base = "[agent]\nname = \"zeph\"\n"; + let result = migrate_telegram_expandable_blockquote_config(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); + } + + #[test] + fn migrate_telegram_expandable_blockquote_noop_when_key_already_present() { + let base = "[telegram]\ntoken = \"tok\"\nexpandable_blockquote_min_lines = 5\n"; + let result = migrate_telegram_expandable_blockquote_config(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); + } + + #[test] + fn migrate_telegram_expandable_blockquote_idempotent() { + let base = "[telegram]\ntoken = \"tok\"\n"; + let first = migrate_telegram_expandable_blockquote_config(base).unwrap(); + let second = migrate_telegram_expandable_blockquote_config(&first.output).unwrap(); + assert_eq!(second.changed_count, 0, "second run must not double-append"); + assert_eq!(second.output, first.output); + } +} diff --git a/crates/zeph-config/src/migrate/mod.rs b/crates/zeph-config/src/migrate/mod.rs index 9ab06d2cd..b276fad96 100644 --- a/crates/zeph-config/src/migrate/mod.rs +++ b/crates/zeph-config/src/migrate/mod.rs @@ -31,7 +31,8 @@ pub use features::{ migrate_orchestration_ensemble, migrate_orchestration_idle_timeout, migrate_orchestration_persistence, migrate_orchestration_whole_plan_verifier_timeout, migrate_rate_limit_advisory, migrate_skill_trust_require_check, migrate_skills_registry, - migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults, + migrate_telegram_expandable_blockquote_config, migrate_tui_delights, migrate_tui_mouse, + migrate_tui_theme_config, migrate_tui_theme_defaults, }; pub use infra::*; pub use integrity::migrate_integrity_config; @@ -636,11 +637,11 @@ use steps::{ MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig, MigrateSessionResumeConfig, MigrateShadowSentinelConfig, MigrateShellCheckpointsConfig, MigrateShellTransactional, MigrateSkillTrustRequireCheck, MigrateSkillsRegistry, - MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig, - MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, - MigrateTuiThemeConfig, MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, - MigrateVigilConfig, MigrateWorktreeConfig, MigrateWorktreeGitTimeout, - MigrateWorktreeQuotaFields, + MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelegramExpandableBlockquoteConfig, + MigrateTelemetryConfig, MigrateToolsCompressionConfig, MigrateTraceMetadata, + MigrateTuiDelights, MigrateTuiMouse, MigrateTuiThemeConfig, MigrateTuiThemeDefaults, + MigrateUtilityHighGainTools, MigrateVigilConfig, MigrateWorktreeConfig, + MigrateWorktreeGitTimeout, MigrateWorktreeQuotaFields, }; /// Ordered registry of all sequential migration steps (steps 1–99). @@ -856,6 +857,9 @@ pub static MIGRATIONS: std::sync::LazyLock> // Step 103 — add [memory.consent_gate] advisory block for the write-time // memory-consent gate (issue #6490, MemGhost) Box::new(MigrateMemoryConsentGateConfig), + // Step 104 — add expandable_blockquote_min_lines advisory comment to an + // existing active [telegram] table (spec 007-3-telegram-rich-text, #6541) + Box::new(MigrateTelegramExpandableBlockquoteConfig), ] }); diff --git a/crates/zeph-config/src/migrate/steps.rs b/crates/zeph-config/src/migrate/steps.rs index 9de1ce05c..19224056d 100644 --- a/crates/zeph-config/src/migrate/steps.rs +++ b/crates/zeph-config/src/migrate/steps.rs @@ -123,10 +123,11 @@ use super::{ migrate_session_recap_config, migrate_session_resume_config, migrate_shadow_sentinel_config, migrate_shell_checkpoints_config, migrate_shell_transactional, migrate_skill_trust_require_check, migrate_skills_registry, migrate_stt_to_provider, - migrate_supervisor_config, migrate_telemetry_config, migrate_tools_compression_config, - migrate_trace_metadata, migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, - migrate_tui_theme_defaults, migrate_utility_high_gain_tools, migrate_vigil_config, - migrate_worktree_config, migrate_worktree_git_timeout, migrate_worktree_quota_fields, + migrate_supervisor_config, migrate_telegram_expandable_blockquote_config, + migrate_telemetry_config, migrate_tools_compression_config, migrate_trace_metadata, + migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults, + migrate_utility_high_gain_tools, migrate_vigil_config, migrate_worktree_config, + migrate_worktree_git_timeout, migrate_worktree_quota_fields, }; // ── Wrapper structs for all 73 sequential migration steps ─────────────────────────────────────── @@ -1327,3 +1328,16 @@ impl Migration for MigrateMemoryConsentGateConfig { migrate_memory_consent_gate_config(toml_src) } } + +/// Step 104 — adds an `expandable_blockquote_min_lines` advisory comment to an existing +/// active `[telegram]` table (spec 007-3-telegram-rich-text, issue #6541). +pub(super) struct MigrateTelegramExpandableBlockquoteConfig; +impl Migration for MigrateTelegramExpandableBlockquoteConfig { + fn name(&self) -> &'static str { + "migrate_telegram_expandable_blockquote_config" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_telegram_expandable_blockquote_config(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index 6fb6d1cbf..a8ec960ba 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 103, - "MIGRATIONS registry must contain all 103 sequential steps" + 104, + "MIGRATIONS registry must contain all 104 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -2124,7 +2124,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 103); + assert_eq!(MIGRATIONS.len(), 104); } #[test] @@ -2269,6 +2269,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_rate_limit_advisory", "migrate_agents_delegation_mode", "migrate_memory_consent_gate_config", + "migrate_telegram_expandable_blockquote_config", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); diff --git a/crates/zeph-core/tests/vault_integration.rs b/crates/zeph-core/tests/vault_integration.rs index 2ef595c37..b9716284a 100644 --- a/crates/zeph-core/tests/vault_integration.rs +++ b/crates/zeph-core/tests/vault_integration.rs @@ -84,6 +84,7 @@ async fn age_vault_injects_token_into_existing_telegram_config() { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, }); config.resolve_secrets(&vault).await.unwrap(); diff --git a/specs/007-channels/007-1-telegram-guest-mode.md b/specs/007-channels/007-1-telegram-guest-mode.md index 6fb7924a0..477348c1e 100644 --- a/specs/007-channels/007-1-telegram-guest-mode.md +++ b/specs/007-channels/007-1-telegram-guest-mode.md @@ -188,11 +188,19 @@ The routing decision happens in `TelegramChannel::send` / `send_chunk`: ``` if incoming.guest_query_id.is_some(): accumulate all chunks → full_text - TelegramApiClient::answer_guest_query(query_id, full_text, ParseMode::Html) + formatted = markdown_to_telegram(full_text.trim()) + TelegramApiClient::answer_guest_query(query_id, formatted, ParseMode::MarkdownV2) else: existing send_message / edit_message_text path ``` +> [!note] Corrected in spec 007-3 (Telegram Rich-Text Formatting, issue #6541) +> The guest-mode response is formatted through the same `markdown_to_telegram` renderer +> used by the regular `send` path before being handed to `answer_guest_query`, and sent +> with `parse_mode = "MarkdownV2"`. An earlier revision of this spec documented a raw, +> unconverted `ParseMode::Html` call here — that was the pre-fix defect +> [[007-channels/007-3-telegram-rich-text]] fixes, not the intended design. + > [!warning] > `answerGuestQuery` is a one-shot call. There is no equivalent of `editMessageText` > for guest responses. The implementation MUST buffer the complete response before diff --git a/specs/007-channels/007-3-telegram-rich-text.md b/specs/007-channels/007-3-telegram-rich-text.md index 45865f8ac..f3f002a56 100644 --- a/specs/007-channels/007-3-telegram-rich-text.md +++ b/specs/007-channels/007-3-telegram-rich-text.md @@ -421,7 +421,7 @@ Required integration points per project convention (mandatory for any new config | Guest-mode response contains characters that are special in both Markdown and HTML (e.g. `<`, `&`, `*`) | Escaped correctly by `markdown_to_telegram`'s existing MarkdownV2 escaping — no HTML-specific handling needed since HTML is no longer used on this path | | Guest-mode response is empty after `.trim()` and formatting | Skip sending (FR-003); no empty `answerGuestQuery` call | | Guest-mode response exceeds `MAX_MESSAGE_LEN` after formatting | Same truncation-warning behavior as the regular `send` path (FR-004); guest mode has no `editMessageText` fallback, so truncation (not chunking) applies, consistent with the one-shot `answerGuestQuery` constraint documented in [[007-channels/007-1-telegram-guest-mode]] §6 | -| Nested blockquote (blockquote containing another blockquote) | `pulldown-cmark` emits nested `BlockQuote` events; the per-line `>` prefix fix must apply the correct cumulative `>` depth per nesting level — verify against a nested-quote unit test fixture before merge | +| Nested blockquote (blockquote containing another blockquote) | Corrected during review (2026-07-20): Telegram MarkdownV2 has no nested-blockquote grammar — a blockquote is exactly one leading `>` per line, and a second `>` on the same line is an unescaped reserved character that Telegram's parser rejects outright (`400 Bad Request`, dropping the whole message). `pulldown-cmark` emits nested `BlockQuote` events; the fix MUST flatten them to a single `>`-per-line level (never accumulate `>>`) — verify against a nested-quote unit test fixture before merge. This supersedes this row's original "cumulative `>` depth" guidance, which was never live-verified and is disproven by Telegram's own formatting-options documentation | | Blockquote line count exactly equals `expandable_blockquote_min_lines` | Renders as expandable (`>=` comparison per FR-006, not `>`) | | `expandable_blockquote_min_lines = 0` with a 50-line blockquote | Renders as a regular (fully expanded) blockquote — never expandable (FR-007) | | A blockquote line itself contains MarkdownV2 special characters | Escaped exactly as it would be outside a blockquote — the per-line `>` prefix is prepended to the already-escaped line content, not interleaved with escaping logic | diff --git a/src/channel.rs b/src/channel.rs index 674591beb..4340475d7 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -236,7 +236,15 @@ pub(crate) async fn create_channel_inner( let tg_cfg = config.telegram.as_ref().unwrap(); let allowed = tg_cfg.allowed_users.clone(); let stream_interval = std::time::Duration::from_millis(tg_cfg.stream_interval_ms); - let mut tg = TelegramChannel::new(token, allowed).with_stream_interval(stream_interval); + let mut tg = TelegramChannel::new(token, allowed) + .with_stream_interval(stream_interval) + .with_expandable_blockquote_min_lines(tg_cfg.expandable_blockquote_min_lines) + .with_guest_mode(tg_cfg.guest_mode) + .with_bot_to_bot( + tg_cfg.bot_to_bot, + tg_cfg.allowed_bots.clone(), + tg_cfg.max_bot_chain_depth, + ); if let Some(sup) = supervisor { tg = tg.with_supervisor(sup); } diff --git a/src/init/mod.rs b/src/init/mod.rs index cb438a095..559c0026e 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -1163,6 +1163,7 @@ pub(crate) fn build_config(state: &WizardState) -> Config { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, }); } ChannelChoice::Discord => { diff --git a/src/startup_checks.rs b/src/startup_checks.rs index 3632016be..e5ef8f440 100644 --- a/src/startup_checks.rs +++ b/src/startup_checks.rs @@ -90,6 +90,7 @@ mod tests { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, allowed_tools: None, }), ..Default::default() diff --git a/src/tests.rs b/src/tests.rs index b8922bcbb..1843b7327 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -100,6 +100,7 @@ async fn create_channel_telegram_without_token() { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, allowed_tools: None, }); let channel = create_channel(&config).await.unwrap(); @@ -135,12 +136,42 @@ async fn create_channel_telegram_with_token() { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, allowed_tools: None, }); let channel = create_channel(&config).await.unwrap(); assert!(matches!(channel, AnyChannel::Telegram(_))); } +/// Regression for the `guest_mode`/`bot_to_bot` wiring gap found in review (issue #6541): +/// `create_channel` must actually thread these `TelegramConfig` fields into the +/// `TelegramChannel` builder, not just accept them in config. Asserts via `Debug` +/// output since the fields are private — `TelegramChannel`'s `Debug` impl exposes +/// `guest_mode`, `bot_to_bot`, `max_bot_chain_depth`, and `allowed_bots_count`. +#[tokio::test] +async fn create_channel_telegram_wires_guest_mode_and_bot_to_bot_from_config() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.telegram = Some(zeph_core::config::TelegramConfig { + token: Some("test_token".to_string()), + allowed_users: vec!["testuser".to_string()], + skills: zeph_core::config::ChannelSkillsConfig::default(), + stream_interval_ms: 3000, + guest_mode: true, + bot_to_bot: true, + allowed_bots: vec!["@friendlybot".to_string()], + max_bot_chain_depth: 5, + expandable_blockquote_min_lines: 10, + allowed_tools: None, + }); + let channel = create_channel(&config).await.unwrap(); + assert!(matches!(channel, AnyChannel::Telegram(_))); + let debug = format!("{channel:?}"); + assert!(debug.contains("guest_mode: true"), "debug: {debug}"); + assert!(debug.contains("bot_to_bot: true"), "debug: {debug}"); + assert!(debug.contains("max_bot_chain_depth: 5"), "debug: {debug}"); + assert!(debug.contains("allowed_bots_count: 1"), "debug: {debug}"); +} + #[cfg(feature = "discord")] #[tokio::test] async fn create_channel_discord_without_token_falls_through() { @@ -191,6 +222,7 @@ async fn create_channel_telegram_with_empty_allowed_users_errors() { bot_to_bot: false, allowed_bots: vec![], max_bot_chain_depth: 3, + expandable_blockquote_min_lines: 10, }); let result = create_channel(&config).await; assert!(result.is_err());