1use 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
22const TELEGRAM_AGENT_ID: &str = "telegram";
24
25const MAX_MESSAGE_LENGTH: usize = 4096;
27
28const EDIT_DEBOUNCE_SECS: u64 = 2;
30
31pub 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 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 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 {
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 drop(paired);
185 return handle_pairing(bot, msg, &state, user_id, &text).await;
186 }
187 }
188
189 if text.starts_with('/') {
191 return handle_command(&bot, chat_id, &state, &text).await;
192 }
193
194 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 if text.trim() == code.as_str() {
211 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 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 let mut end = max;
484 while end > 0 && !s.is_char_boundary(end) {
485 end -= 1;
486 }
487 &s[..end]
488 }
489}
490
491fn escape_html(text: &str) -> String {
493 text.replace('&', "&")
494 .replace('<', "<")
495 .replace('>', ">")
496}
497
498fn 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 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 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 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
565fn 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 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 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 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 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 match chars[i] {
630 '&' => result.push_str("&"),
631 '<' => result.push_str("<"),
632 '>' => result.push_str(">"),
633 c => result.push(c),
634 }
635 i += 1;
636 }
637
638 result
639}
640
641fn 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 let thinking_msg = bot.send_message(chat_id, "Thinking...").await?;
663 let msg_id = thinking_msg.id;
664
665 let _gate_permit = state.turn_gate.acquire().await;
667
668 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 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 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 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 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 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 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 if display.len() > MAX_MESSAGE_LENGTH {
820 display.truncate(MAX_MESSAGE_LENGTH - 3);
821 display.push_str("...");
822 }
823
824 display
825}
826
827async 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 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 let chunks = split_text_chunks(text);
858
859 if let Some(first) = chunks.first() {
861 send_or_edit_html(bot, chat_id, edit_msg_id, first).await;
862 }
863
864 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}