Skip to main content

localgpt_server/
telegram.rs

1//! Telegram bot interface for LocalGPT
2//!
3//! Provides a Telegram bot that allows interacting with LocalGPT remotely.
4//! Uses a one-time pairing code mechanism to restrict access to the owner.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Instant;
12use teloxide::prelude::*;
13use teloxide::types::{MessageId, ParseMode};
14use tokio::sync::Mutex;
15use tracing::{debug, error, info, warn};
16
17use localgpt_core::agent::{Agent, AgentConfig, StreamEvent, extract_tool_detail, tools::Tool};
18use localgpt_core::concurrency::TurnGate;
19use localgpt_core::config::Config;
20use localgpt_core::memory::MemoryManager;
21
22/// Agent ID for Telegram sessions
23const TELEGRAM_AGENT_ID: &str = "telegram";
24
25/// Maximum Telegram message length
26const MAX_MESSAGE_LENGTH: usize = 4096;
27
28/// Debounce interval for message edits (seconds)
29const EDIT_DEBOUNCE_SECS: u64 = 2;
30
31/// Factory function type for creating additional tools for the Telegram agent.
32/// This allows the caller (e.g., CLI daemon) to inject dangerous tools like bash, file I/O.
33pub type ToolFactory = Box<dyn Fn(&Config) -> Result<Vec<Box<dyn Tool>>> + Send + Sync>;
34
35#[derive(Debug, Serialize, Deserialize)]
36struct PairedUser {
37    user_id: u64,
38    username: Option<String>,
39    paired_at: String,
40}
41
42struct SessionEntry {
43    agent: Agent,
44    last_accessed: Instant,
45}
46
47struct BotState {
48    config: Config,
49    sessions: Mutex<HashMap<i64, SessionEntry>>,
50    memory: MemoryManager,
51    turn_gate: TurnGate,
52    paired_user: Mutex<Option<PairedUser>>,
53    pending_pairing_code: Mutex<Option<String>>,
54    tool_factory: Option<ToolFactory>,
55}
56
57fn pairing_file_path() -> Result<PathBuf> {
58    let paths = localgpt_core::paths::Paths::resolve()?;
59    Ok(paths.pairing_file())
60}
61
62fn load_paired_user() -> Option<PairedUser> {
63    let path = pairing_file_path().ok()?;
64    let content = std::fs::read_to_string(path).ok()?;
65    serde_json::from_str(&content).ok()
66}
67
68fn save_paired_user(user: &PairedUser) -> Result<()> {
69    let path = pairing_file_path()?;
70    let content = serde_json::to_string_pretty(user)?;
71    std::fs::write(path, content)?;
72    Ok(())
73}
74
75fn generate_pairing_code() -> String {
76    use std::time::{SystemTime, UNIX_EPOCH};
77    let seed = SystemTime::now()
78        .duration_since(UNIX_EPOCH)
79        .unwrap_or_default()
80        .as_nanos();
81    // Simple LCG-based 6-digit code (not cryptographic, but fine for pairing)
82    let code = ((seed.wrapping_mul(6364136223846793005).wrapping_add(1)) % 900000 + 100000) as u32;
83    format!("{:06}", code)
84}
85
86pub async fn run_telegram_bot(
87    config: &Config,
88    turn_gate: TurnGate,
89    tool_factory: Option<ToolFactory>,
90) -> Result<()> {
91    let telegram_config = config
92        .telegram
93        .as_ref()
94        .ok_or_else(|| anyhow::anyhow!("Telegram config not found"))?;
95
96    if !telegram_config.enabled {
97        return Ok(());
98    }
99
100    let token = &telegram_config.api_token;
101    if token.is_empty() || token.starts_with("${") {
102        anyhow::bail!("Telegram API token not configured or not expanded");
103    }
104
105    let bot = Bot::new(token);
106
107    let memory =
108        MemoryManager::new_with_full_config(&config.memory, Some(config), TELEGRAM_AGENT_ID)?;
109
110    let paired_user = load_paired_user();
111    if let Some(ref user) = paired_user {
112        info!(
113            "Telegram bot: paired with user {} (ID: {})",
114            user.username.as_deref().unwrap_or("unknown"),
115            user.user_id
116        );
117    } else {
118        info!("Telegram bot: no paired user. Send any message to start pairing.");
119    }
120
121    let state = Arc::new(BotState {
122        config: config.clone(),
123        sessions: Mutex::new(HashMap::new()),
124        memory,
125        turn_gate,
126        paired_user: Mutex::new(paired_user),
127        pending_pairing_code: Mutex::new(None),
128        tool_factory,
129    });
130
131    // Register bot commands so Telegram clients show the "/" menu
132    let commands: Vec<teloxide::types::BotCommand> = localgpt_core::commands::COMMANDS
133        .iter()
134        .filter(|c| c.supports(localgpt_core::commands::Interface::Telegram))
135        .map(|c| teloxide::types::BotCommand::new(c.name, c.description))
136        .collect();
137    if let Err(e) = bot.set_my_commands(commands).await {
138        warn!("Failed to set bot commands: {}", e);
139    }
140
141    info!("Starting Telegram bot...");
142
143    let handler = Update::filter_message().endpoint(handle_message);
144
145    Dispatcher::builder(bot, handler)
146        .default_handler(|_upd| async {})
147        .dependencies(dptree::deps![state])
148        .enable_ctrlc_handler()
149        .build()
150        .dispatch()
151        .await;
152
153    Ok(())
154}
155
156async fn handle_message(bot: Bot, msg: Message, state: Arc<BotState>) -> ResponseResult<()> {
157    let text = match msg.text() {
158        Some(t) => t.to_string(),
159        None => return Ok(()),
160    };
161
162    let user = match msg.from {
163        Some(ref u) => u,
164        None => return Ok(()),
165    };
166
167    let user_id = user.id.0;
168    let chat_id = msg.chat.id;
169
170    // Check pairing
171    {
172        let paired = state.paired_user.lock().await;
173        if let Some(ref pu) = *paired {
174            if pu.user_id != user_id {
175                bot.send_message(
176                    chat_id,
177                    "Not authorized. This bot is paired with another user.",
178                )
179                .await?;
180                return Ok(());
181            }
182        } else {
183            // Not paired yet - handle pairing flow
184            drop(paired);
185            return handle_pairing(bot, msg, &state, user_id, &text).await;
186        }
187    }
188
189    // Handle slash commands
190    if text.starts_with('/') {
191        return handle_command(&bot, chat_id, &state, &text).await;
192    }
193
194    // Regular chat message
195    handle_chat(&bot, chat_id, &state, &text).await
196}
197
198async fn handle_pairing(
199    bot: Bot,
200    msg: Message,
201    state: &Arc<BotState>,
202    user_id: u64,
203    text: &str,
204) -> ResponseResult<()> {
205    let chat_id = msg.chat.id;
206    let mut pending = state.pending_pairing_code.lock().await;
207
208    if let Some(ref code) = *pending {
209        // User is entering the pairing code
210        if text.trim() == code.as_str() {
211            // Pairing successful
212            let username = msg.from.as_ref().and_then(|u| u.username.clone());
213            let paired = PairedUser {
214                user_id,
215                username: username.clone(),
216                paired_at: chrono::Utc::now().to_rfc3339(),
217            };
218
219            if let Err(e) = save_paired_user(&paired) {
220                error!("Failed to save pairing: {}", e);
221                bot.send_message(chat_id, "Pairing failed (could not save). Check logs.")
222                    .await?;
223                return Ok(());
224            }
225
226            *state.paired_user.lock().await = Some(paired);
227            *pending = None;
228
229            info!(
230                "Telegram bot: paired with user {} (ID: {})",
231                username.as_deref().unwrap_or("unknown"),
232                user_id
233            );
234
235            bot.send_message(chat_id,
236                "Paired successfully! You can now chat with LocalGPT.\n\nUse /new to start a fresh session, /status to see session info.",
237            )
238            .await?;
239        } else {
240            bot.send_message(chat_id, "Invalid pairing code. Please try again.")
241                .await?;
242        }
243    } else {
244        // Generate new pairing code
245        let code = generate_pairing_code();
246        println!("\n========================================");
247        println!("  TELEGRAM PAIRING CODE: {}", code);
248        println!("========================================\n");
249        info!(
250            "Telegram pairing code generated for user {} (ID: {})",
251            msg.from
252                .as_ref()
253                .and_then(|u| u.username.as_deref())
254                .unwrap_or("unknown"),
255            user_id
256        );
257
258        *pending = Some(code);
259
260        bot.send_message(chat_id,
261            "Welcome! A pairing code has been printed to the daemon logs/stdout.\nPlease enter the code to pair this bot with your account.",
262        )
263        .await?;
264    }
265
266    Ok(())
267}
268
269async fn handle_command(
270    bot: &Bot,
271    chat_id: ChatId,
272    state: &Arc<BotState>,
273    text: &str,
274) -> ResponseResult<()> {
275    let parts: Vec<&str> = text.splitn(2, ' ').collect();
276    let cmd = parts[0];
277    let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
278
279    match cmd {
280        "/start" | "/help" => {
281            let help = format!(
282                "LocalGPT Telegram Bot\n\n{}",
283                localgpt_core::commands::format_help_text(
284                    localgpt_core::commands::Interface::Telegram
285                )
286            );
287            bot.send_message(chat_id, &help).await?;
288        }
289        "/new" => {
290            let mut sessions = state.sessions.lock().await;
291            sessions.remove(&chat_id.0);
292            bot.send_message(
293                chat_id,
294                "Session cleared. Send a message to start a new conversation.",
295            )
296            .await?;
297        }
298        "/status" => {
299            let sessions = state.sessions.lock().await;
300            let status_text = if let Some(entry) = sessions.get(&chat_id.0) {
301                let status = entry.agent.session_status();
302                let (used, usable, total) = entry.agent.context_usage();
303                let mut text = format!(
304                    "Session active\n\
305                     Model: {}\n\
306                     Messages: {}\n\
307                     Tokens: {} / {} (window: {})\n\
308                     Compactions: {}\n\
309                     Idle: {}s",
310                    entry.agent.model(),
311                    status.message_count,
312                    used,
313                    usable,
314                    total,
315                    status.compaction_count,
316                    entry.last_accessed.elapsed().as_secs()
317                );
318                if status.search_queries > 0 {
319                    let cache_pct =
320                        (status.search_cached_hits as f64 / status.search_queries as f64) * 100.0;
321                    text.push_str(&format!(
322                        "\nSearch: {} queries ({} cached, {:.0}%) ยท ${:.3}",
323                        status.search_queries,
324                        status.search_cached_hits,
325                        cache_pct,
326                        status.search_cost_usd
327                    ));
328                }
329                text
330            } else {
331                "No active session. Send a message to start one.".to_string()
332            };
333            bot.send_message(chat_id, &status_text).await?;
334        }
335        "/compact" => {
336            let mut sessions = state.sessions.lock().await;
337            match sessions.get_mut(&chat_id.0) {
338                Some(entry) => {
339                    entry.last_accessed = Instant::now();
340                    match entry.agent.compact_session().await {
341                        Ok((before, after)) => {
342                            bot.send_message(
343                                chat_id,
344                                format!("Compacted: {} -> {} tokens", before, after),
345                            )
346                            .await?;
347                        }
348                        Err(e) => {
349                            bot.send_message(chat_id, format!("Compact failed: {}", e))
350                                .await?;
351                        }
352                    }
353                }
354                None => {
355                    bot.send_message(chat_id, "No active session.").await?;
356                }
357            }
358        }
359        "/clear" => {
360            let mut sessions = state.sessions.lock().await;
361            if let Some(entry) = sessions.get_mut(&chat_id.0) {
362                entry.agent.clear_session();
363                entry.last_accessed = Instant::now();
364                bot.send_message(chat_id, "Session history cleared.")
365                    .await?;
366            } else {
367                bot.send_message(chat_id, "No active session.").await?;
368            }
369        }
370        "/memory" => {
371            if args.is_empty() {
372                bot.send_message(chat_id, "Usage: /memory <search query>")
373                    .await?;
374            } else {
375                match state.memory.search(args, 5) {
376                    Ok(results) => {
377                        if results.is_empty() {
378                            bot.send_message(chat_id, "No results found.").await?;
379                        } else {
380                            let mut text = format!("Memory search: \"{}\"\n\n", args);
381                            for (i, r) in results.iter().enumerate() {
382                                text.push_str(&format!(
383                                    "{}. {} (L{}-{})\n{}\n\n",
384                                    i + 1,
385                                    r.file,
386                                    r.line_start,
387                                    r.line_end,
388                                    truncate_str(&r.content, 300),
389                                ));
390                            }
391                            send_long_message(bot, chat_id, None, &text).await;
392                        }
393                    }
394                    Err(e) => {
395                        bot.send_message(chat_id, format!("Search error: {}", e))
396                            .await?;
397                    }
398                }
399            }
400        }
401        "/model" => {
402            if args.is_empty() {
403                let sessions = state.sessions.lock().await;
404                let current = sessions
405                    .get(&chat_id.0)
406                    .map(|e| e.agent.model().to_string())
407                    .unwrap_or_else(|| state.config.agent.default_model.clone());
408                bot.send_message(
409                    chat_id,
410                    format!("Current model: {}\n\nUsage: /model <name>", current),
411                )
412                .await?;
413            } else {
414                let mut sessions = state.sessions.lock().await;
415                if let Some(entry) = sessions.get_mut(&chat_id.0) {
416                    match entry.agent.set_model(args) {
417                        Ok(()) => {
418                            bot.send_message(chat_id, format!("Switched to model: {}", args))
419                                .await?;
420                        }
421                        Err(e) => {
422                            bot.send_message(chat_id, format!("Failed to switch model: {}", e))
423                                .await?;
424                        }
425                    }
426                } else {
427                    bot.send_message(
428                        chat_id,
429                        "No active session. Send a message first, then switch models.",
430                    )
431                    .await?;
432                }
433            }
434        }
435        "/skills" => {
436            let workspace_path = state.config.workspace_path();
437            match localgpt_core::agent::load_skills(&workspace_path) {
438                Ok(skills) => {
439                    if skills.is_empty() {
440                        bot.send_message(chat_id, "No skills installed.").await?;
441                    } else {
442                        let summary = localgpt_core::agent::get_skills_summary(&skills);
443                        bot.send_message(chat_id, &summary).await?;
444                    }
445                }
446                Err(e) => {
447                    bot.send_message(chat_id, format!("Failed to load skills: {}", e))
448                        .await?;
449                }
450            }
451        }
452        "/unpair" => {
453            *state.paired_user.lock().await = None;
454            if let Ok(path) = pairing_file_path() {
455                let _ = std::fs::remove_file(path);
456            }
457            let mut sessions = state.sessions.lock().await;
458            sessions.remove(&chat_id.0);
459            info!("Telegram bot: user unpaired");
460            bot.send_message(
461                chat_id,
462                "Unpaired. Send any message to start a new pairing.",
463            )
464            .await?;
465        }
466        _ => {
467            bot.send_message(
468                chat_id,
469                "Unknown command. Use /help for available commands.",
470            )
471            .await?;
472        }
473    }
474
475    Ok(())
476}
477
478fn truncate_str(s: &str, max: usize) -> &str {
479    if s.len() <= max {
480        s
481    } else {
482        // Find a char boundary
483        let mut end = max;
484        while end > 0 && !s.is_char_boundary(end) {
485            end -= 1;
486        }
487        &s[..end]
488    }
489}
490
491/// Escape text for Telegram HTML parse mode.
492fn escape_html(text: &str) -> String {
493    text.replace('&', "&amp;")
494        .replace('<', "&lt;")
495        .replace('>', "&gt;")
496}
497
498/// Convert markdown to Telegram-compatible HTML.
499/// Handles: code blocks, inline code, bold, italic, links, headers.
500/// Unrecognized markup passes through as escaped HTML.
501fn markdown_to_html(text: &str) -> String {
502    let mut result = String::with_capacity(text.len());
503    let mut in_code_block = false;
504    let mut code_block_lang = String::new();
505    let mut code_block_content = String::new();
506
507    for line in text.lines() {
508        if in_code_block {
509            if line.starts_with("```") {
510                // Close code block
511                let lang_attr = if code_block_lang.is_empty() {
512                    String::new()
513                } else {
514                    format!(" class=\"language-{}\"", escape_html(&code_block_lang))
515                };
516                result.push_str(&format!(
517                    "<pre><code{}>{}</code></pre>\n",
518                    lang_attr,
519                    escape_html(&code_block_content)
520                ));
521                code_block_content.clear();
522                code_block_lang.clear();
523                in_code_block = false;
524            } else {
525                if !code_block_content.is_empty() {
526                    code_block_content.push('\n');
527                }
528                code_block_content.push_str(line);
529            }
530            continue;
531        }
532
533        if let Some(rest) = line.strip_prefix("```") {
534            in_code_block = true;
535            code_block_lang = rest.trim().to_string();
536            continue;
537        }
538
539        // Headers โ†’ bold
540        let line = if let Some(rest) = line.strip_prefix("### ") {
541            format!("<b>{}</b>", escape_html(rest))
542        } else if let Some(rest) = line.strip_prefix("## ") {
543            format!("<b>{}</b>", escape_html(rest))
544        } else if let Some(rest) = line.strip_prefix("# ") {
545            format!("<b>{}</b>", escape_html(rest))
546        } else {
547            convert_inline_markdown(line)
548        };
549
550        result.push_str(&line);
551        result.push('\n');
552    }
553
554    // Handle unclosed code block
555    if in_code_block {
556        result.push_str(&format!(
557            "<pre><code>{}</code></pre>\n",
558            escape_html(&code_block_content)
559        ));
560    }
561
562    result
563}
564
565/// Convert inline markdown elements: `code`, **bold**, *italic*, [links](url)
566fn convert_inline_markdown(line: &str) -> String {
567    let mut result = String::new();
568    let chars: Vec<char> = line.chars().collect();
569    let len = chars.len();
570    let mut i = 0;
571
572    while i < len {
573        // Inline code: `...`
574        if chars[i] == '`'
575            && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
576        {
577            let code: String = chars[i + 1..i + 1 + end].iter().collect();
578            result.push_str(&format!("<code>{}</code>", escape_html(&code)));
579            i += end + 2;
580            continue;
581        }
582
583        // Bold: **...**
584        if i + 1 < len
585            && chars[i] == '*'
586            && chars[i + 1] == '*'
587            && let Some(end) = find_closing(&chars, i + 2, &['*', '*'])
588        {
589            let inner: String = chars[i + 2..end].iter().collect();
590            result.push_str(&format!("<b>{}</b>", escape_html(&inner)));
591            i = end + 2;
592            continue;
593        }
594
595        // Italic: *...*
596        if chars[i] == '*'
597            && let Some(end) = chars[i + 1..].iter().position(|&c| c == '*')
598        {
599            let inner: String = chars[i + 1..i + 1 + end].iter().collect();
600            result.push_str(&format!("<i>{}</i>", escape_html(&inner)));
601            i += end + 2;
602            continue;
603        }
604
605        // Link: [text](url)
606        if chars[i] == '['
607            && let Some(close_bracket) = chars[i + 1..].iter().position(|&c| c == ']')
608        {
609            let text_end = i + 1 + close_bracket;
610            if text_end + 1 < len
611                && chars[text_end + 1] == '('
612                && let Some(close_paren) = chars[text_end + 2..].iter().position(|&c| c == ')')
613            {
614                let text: String = chars[i + 1..text_end].iter().collect();
615                let url: String = chars[text_end + 2..text_end + 2 + close_paren]
616                    .iter()
617                    .collect();
618                result.push_str(&format!(
619                    "<a href=\"{}\">{}</a>",
620                    escape_html(&url),
621                    escape_html(&text)
622                ));
623                i = text_end + 2 + close_paren + 1;
624                continue;
625            }
626        }
627
628        // Regular character
629        match chars[i] {
630            '&' => result.push_str("&amp;"),
631            '<' => result.push_str("&lt;"),
632            '>' => result.push_str("&gt;"),
633            c => result.push(c),
634        }
635        i += 1;
636    }
637
638    result
639}
640
641/// Find closing delimiter (e.g., ** for bold) starting from `start`.
642fn find_closing(chars: &[char], start: usize, delim: &[char]) -> Option<usize> {
643    let dlen = delim.len();
644    if start + dlen > chars.len() {
645        return None;
646    }
647    for i in start..chars.len() - dlen + 1 {
648        if chars[i..i + dlen] == *delim {
649            return Some(i);
650        }
651    }
652    None
653}
654
655async fn handle_chat(
656    bot: &Bot,
657    chat_id: ChatId,
658    state: &Arc<BotState>,
659    text: &str,
660) -> ResponseResult<()> {
661    // Send initial "thinking" message
662    let thinking_msg = bot.send_message(chat_id, "Thinking...").await?;
663    let msg_id = thinking_msg.id;
664
665    // Acquire turn gate
666    let _gate_permit = state.turn_gate.acquire().await;
667
668    // Get or create agent session, then stream response
669    let mut sessions = state.sessions.lock().await;
670
671    if let std::collections::hash_map::Entry::Vacant(e) = sessions.entry(chat_id.0) {
672        let agent_config = AgentConfig {
673            model: state.config.agent.default_model.clone(),
674            context_window: state.config.agent.context_window,
675            reserve_tokens: state.config.agent.reserve_tokens,
676        };
677
678        let memory = std::sync::Arc::new(state.memory.clone());
679        match Agent::new(agent_config, &state.config, memory).await {
680            Ok(mut agent) => {
681                // Extend agent with additional tools from factory if provided (e.g., CLI tools from daemon)
682                if let Some(ref factory) = state.tool_factory {
683                    match factory(&state.config) {
684                        Ok(extra_tools) => {
685                            agent.extend_tools(extra_tools);
686                        }
687                        Err(err) => {
688                            error!("Failed to create additional tools: {}", err);
689                        }
690                    }
691                }
692
693                if let Err(err) = agent.new_session().await {
694                    error!("Failed to create session: {}", err);
695                    let _ = bot
696                        .edit_message_text(chat_id, msg_id, format!("Error: {}", err))
697                        .await;
698                    return Ok(());
699                }
700
701                // Send welcome message on first run
702                let is_brand_new = agent.is_brand_new();
703                if is_brand_new {
704                    let html = markdown_to_html(localgpt_core::agent::FIRST_RUN_WELCOME);
705                    let _ = bot
706                        .send_message(chat_id, html)
707                        .parse_mode(ParseMode::Html)
708                        .await;
709                }
710
711                e.insert(SessionEntry {
712                    agent,
713                    last_accessed: Instant::now(),
714                });
715            }
716            Err(err) => {
717                error!("Failed to create agent: {}", err);
718                let _ = bot
719                    .edit_message_text(chat_id, msg_id, format!("Error: {}", err))
720                    .await;
721                return Ok(());
722            }
723        }
724    }
725
726    let entry = sessions.get_mut(&chat_id.0).unwrap();
727    entry.last_accessed = Instant::now();
728
729    // Use streaming with tools
730    let response = match entry.agent.chat_stream_with_tools(text, Vec::new()).await {
731        Ok(event_stream) => {
732            use futures::StreamExt;
733
734            let mut full_response = String::new();
735            let mut last_edit = Instant::now();
736            let mut pinned_stream = std::pin::pin!(event_stream);
737            let mut tool_info = String::new();
738
739            while let Some(event) = pinned_stream.next().await {
740                match event {
741                    Ok(StreamEvent::Content(delta)) => {
742                        full_response.push_str(&delta);
743
744                        // Debounced edit
745                        if last_edit.elapsed().as_secs() >= EDIT_DEBOUNCE_SECS {
746                            let display = format_display(&full_response, &tool_info);
747                            let _ = bot.edit_message_text(chat_id, msg_id, &display).await;
748                            last_edit = Instant::now();
749                        }
750                    }
751                    Ok(StreamEvent::ToolCallStart {
752                        name, arguments, ..
753                    }) => {
754                        let detail = extract_tool_detail(&name, &arguments);
755                        let info_line = if let Some(d) = detail {
756                            format!("๐Ÿ”ง {}({})\n", name, d)
757                        } else {
758                            format!("๐Ÿ”ง {}\n", name)
759                        };
760                        tool_info.push_str(&info_line);
761
762                        let display = format_display(&full_response, &tool_info);
763                        let _ = bot.edit_message_text(chat_id, msg_id, &display).await;
764                        last_edit = Instant::now();
765                    }
766                    Ok(StreamEvent::ToolCallEnd { name, warnings, .. }) => {
767                        if !warnings.is_empty() {
768                            for w in &warnings {
769                                tool_info.push_str(&format!(
770                                    "\u{26a0} Suspicious content in {}: {}\n",
771                                    name, w
772                                ));
773                            }
774                            let display = format_display(&full_response, &tool_info);
775                            let _ = bot.edit_message_text(chat_id, msg_id, &display).await;
776                            last_edit = Instant::now();
777                        }
778                    }
779                    Ok(StreamEvent::Done) => break,
780                    Err(e) => {
781                        error!("Stream error: {}", e);
782                        full_response.push_str(&format!("\n\nError: {}", e));
783                        break;
784                    }
785                }
786            }
787
788            if full_response.is_empty() {
789                "(no response)".to_string()
790            } else {
791                full_response
792            }
793        }
794        Err(e) => format!("Error: {}", e),
795    };
796
797    // Save session before releasing lock
798    if let Err(e) = entry.agent.save_session_for_agent(TELEGRAM_AGENT_ID).await {
799        debug!("Failed to save telegram session: {}", e);
800    }
801
802    drop(sessions);
803
804    // Final edit with complete response
805    send_long_message(bot, chat_id, Some(msg_id), &response).await;
806
807    Ok(())
808}
809
810fn format_display(response: &str, tool_info: &str) -> String {
811    let mut display = String::new();
812    if !tool_info.is_empty() {
813        display.push_str(tool_info);
814        display.push('\n');
815    }
816    display.push_str(response);
817
818    // Truncate for Telegram limit
819    if display.len() > MAX_MESSAGE_LENGTH {
820        display.truncate(MAX_MESSAGE_LENGTH - 3);
821        display.push_str("...");
822    }
823
824    display
825}
826
827/// Send/edit agent response as HTML-converted markdown.
828async fn send_or_edit_html(bot: &Bot, chat_id: ChatId, msg_id: Option<MessageId>, text: &str) {
829    let html = markdown_to_html(text);
830    let result = if let Some(mid) = msg_id {
831        bot.edit_message_text(chat_id, mid, &html)
832            .parse_mode(ParseMode::Html)
833            .await
834    } else {
835        bot.send_message(chat_id, &html)
836            .parse_mode(ParseMode::Html)
837            .await
838    };
839
840    // Fallback to plain text on conversion issues
841    if result.is_err() {
842        if let Some(mid) = msg_id {
843            let _ = bot.edit_message_text(chat_id, mid, text).await;
844        } else {
845            let _ = bot.send_message(chat_id, text).await;
846        }
847    }
848}
849
850async fn send_long_message(bot: &Bot, chat_id: ChatId, edit_msg_id: Option<MessageId>, text: &str) {
851    if text.len() <= MAX_MESSAGE_LENGTH {
852        send_or_edit_html(bot, chat_id, edit_msg_id, text).await;
853        return;
854    }
855
856    // Split into chunks at char boundaries
857    let chunks = split_text_chunks(text);
858
859    // First chunk: edit existing message or send new
860    if let Some(first) = chunks.first() {
861        send_or_edit_html(bot, chat_id, edit_msg_id, first).await;
862    }
863
864    // Remaining chunks as new messages
865    for chunk in chunks.iter().skip(1) {
866        send_or_edit_html(bot, chat_id, None, chunk).await;
867    }
868}
869
870fn split_text_chunks(text: &str) -> Vec<&str> {
871    let mut chunks = Vec::new();
872    let mut start = 0;
873    while start < text.len() {
874        let mut end = (start + MAX_MESSAGE_LENGTH).min(text.len());
875        while end > start && !text.is_char_boundary(end) {
876            end -= 1;
877        }
878        chunks.push(&text[start..end]);
879        start = end;
880    }
881    chunks
882}