Skip to main content

ralph_telegram/
bot.rs

1use std::path::Path;
2
3use async_trait::async_trait;
4
5use crate::error::{TelegramError, TelegramResult};
6
7/// Trait abstracting Telegram bot operations for testability.
8///
9/// Production code uses [`TelegramBot`]; tests can provide a mock implementation.
10#[async_trait]
11pub trait BotApi: Send + Sync {
12    /// Send a text message to the given chat.
13    ///
14    /// Returns the Telegram message ID of the sent message.
15    async fn send_message(&self, chat_id: i64, text: &str) -> TelegramResult<i32>;
16
17    /// Send a document (file) to the given chat with an optional caption.
18    ///
19    /// Returns the Telegram message ID of the sent message.
20    async fn send_document(
21        &self,
22        chat_id: i64,
23        file_path: &Path,
24        caption: Option<&str>,
25    ) -> TelegramResult<i32>;
26
27    /// Send a photo to the given chat with an optional caption.
28    ///
29    /// Returns the Telegram message ID of the sent message.
30    async fn send_photo(
31        &self,
32        chat_id: i64,
33        file_path: &Path,
34        caption: Option<&str>,
35    ) -> TelegramResult<i32>;
36}
37
38/// Wraps a `teloxide::Bot` and provides formatted messaging for Ralph.
39pub struct TelegramBot {
40    bot: teloxide::Bot,
41}
42
43impl TelegramBot {
44    /// Create a new TelegramBot from a bot token.
45    pub fn new(token: &str) -> Self {
46        if cfg!(test) {
47            let client = teloxide::net::default_reqwest_settings()
48                .no_proxy()
49                .build()
50                .expect("Client creation failed");
51            Self {
52                bot: teloxide::Bot::with_client(token, client),
53            }
54        } else {
55            Self {
56                bot: teloxide::Bot::new(token),
57            }
58        }
59    }
60
61    /// Format an outgoing question message using Telegram HTML.
62    ///
63    /// Includes emoji, hat name, iteration number, and the question text.
64    /// The question body is converted from markdown to Telegram HTML for
65    /// rich rendering. The hat and loop ID are HTML-escaped for safety.
66    pub fn format_question(hat: &str, iteration: u32, loop_id: &str, question: &str) -> String {
67        let escaped_hat = escape_html(hat);
68        let escaped_loop = escape_html(loop_id);
69        let formatted_question = markdown_to_telegram_html(question);
70        format!(
71            "ā“ <b>{escaped_hat}</b> (iteration {iteration}, loop <code>{escaped_loop}</code>)\n\n{formatted_question}",
72        )
73    }
74
75    /// Format a greeting message sent when the bot starts.
76    pub fn format_greeting(loop_id: &str) -> String {
77        let escaped = escape_html(loop_id);
78        format!("šŸ¤– Ralph bot online — monitoring loop <code>{escaped}</code>")
79    }
80
81    /// Format a farewell message sent when the bot shuts down.
82    pub fn format_farewell(loop_id: &str) -> String {
83        let escaped = escape_html(loop_id);
84        format!("šŸ‘‹ Ralph bot shutting down — loop <code>{escaped}</code> complete")
85    }
86}
87
88/// Escape special HTML characters for Telegram's HTML parse mode.
89///
90/// Telegram requires `<`, `>`, and `&` to be escaped in HTML-formatted messages.
91pub fn escape_html(text: &str) -> String {
92    text.replace('&', "&amp;")
93        .replace('<', "&lt;")
94        .replace('>', "&gt;")
95}
96
97/// Convert Ralph-generated markdown to Telegram HTML.
98///
99/// Handles the subset of markdown that Ralph produces:
100/// - `**bold**` → `<b>bold</b>`
101/// - `` `inline code` `` → `<code>inline code</code>`
102/// - ````code blocks```` → `<pre>code</pre>`
103/// - `# Header` → `<b>Header</b>`
104/// - `- item` / `* item` → `• item`
105///
106/// Text that isn't markdown is HTML-escaped to prevent injection.
107/// This function is for Ralph-generated content; use [`escape_html`] for
108/// user-supplied text.
109pub fn markdown_to_telegram_html(md: &str) -> String {
110    let mut result = String::with_capacity(md.len());
111    let mut in_code_block = false;
112    let mut code_block_content = String::new();
113
114    for line in md.lines() {
115        // Handle fenced code blocks (``` or ```)
116        let trimmed = line.trim();
117        if trimmed.starts_with("```") {
118            if in_code_block {
119                // Closing code fence
120                result.push_str("<pre>");
121                result.push_str(&escape_html(&code_block_content));
122                result.push_str("</pre>");
123                result.push('\n');
124                code_block_content.clear();
125                in_code_block = false;
126            } else {
127                // Opening code fence (ignore language specifier)
128                in_code_block = true;
129            }
130            continue;
131        }
132
133        if in_code_block {
134            if !code_block_content.is_empty() {
135                code_block_content.push('\n');
136            }
137            code_block_content.push_str(line);
138            continue;
139        }
140
141        // Headers: # ... → bold line
142        if let Some(header_text) = strip_header(trimmed) {
143            if !result.is_empty() {
144                result.push('\n');
145            }
146            result.push_str("<b>");
147            result.push_str(&escape_html(header_text));
148            result.push_str("</b>");
149            continue;
150        }
151
152        // List items: - item or * item → • item
153        if let Some(item_text) = strip_list_item(trimmed) {
154            if !result.is_empty() {
155                result.push('\n');
156            }
157            result.push_str("• ");
158            result.push_str(&convert_inline(&escape_html(item_text)));
159            continue;
160        }
161
162        // Regular line: apply inline formatting
163        if !result.is_empty() {
164            result.push('\n');
165        }
166        result.push_str(&convert_inline(&escape_html(line)));
167    }
168
169    // Handle unclosed code block
170    if in_code_block && !code_block_content.is_empty() {
171        result.push_str("<pre>");
172        result.push_str(&escape_html(&code_block_content));
173        result.push_str("</pre>");
174    }
175
176    result
177}
178
179/// Strip markdown header prefix (# to ######) and return the header text.
180fn strip_header(line: &str) -> Option<&str> {
181    if !line.starts_with('#') {
182        return None;
183    }
184    let hash_count = line.chars().take_while(|c| *c == '#').count();
185    if hash_count > 6 {
186        return None;
187    }
188    let rest = &line[hash_count..];
189    if rest.starts_with(' ') {
190        Some(rest.trim())
191    } else {
192        None
193    }
194}
195
196/// Strip list item prefix (- or *) and return the item text.
197fn strip_list_item(line: &str) -> Option<&str> {
198    if let Some(rest) = line.strip_prefix("- ") {
199        Some(rest)
200    } else if let Some(rest) = line.strip_prefix("* ") {
201        Some(rest)
202    } else {
203        None
204    }
205}
206
207/// Convert inline markdown (bold and inline code) within already-escaped HTML text.
208///
209/// Processes `**bold**` → `<b>bold</b>` and `` `code` `` → `<code>code</code>`.
210/// Since input is already HTML-escaped, bold delimiters (`**`) and backticks
211/// appear literally and won't conflict with HTML entities.
212fn convert_inline(escaped: &str) -> String {
213    let mut out = String::with_capacity(escaped.len());
214    let chars: Vec<char> = escaped.chars().collect();
215    let len = chars.len();
216    let mut i = 0;
217
218    while i < len {
219        // Inline code: `...`
220        if chars[i] == '`'
221            && let Some(end) = find_closing_backtick(&chars, i + 1)
222        {
223            out.push_str("<code>");
224            for c in &chars[i + 1..end] {
225                out.push(*c);
226            }
227            out.push_str("</code>");
228            i = end + 1;
229            continue;
230        }
231
232        // Bold: **...**
233        if i + 1 < len
234            && chars[i] == '*'
235            && chars[i + 1] == '*'
236            && let Some(end) = find_closing_double_star(&chars, i + 2)
237        {
238            out.push_str("<b>");
239            for c in &chars[i + 2..end] {
240                out.push(*c);
241            }
242            out.push_str("</b>");
243            i = end + 2;
244            continue;
245        }
246
247        out.push(chars[i]);
248        i += 1;
249    }
250
251    out
252}
253
254/// Find closing backtick starting from position `start`.
255fn find_closing_backtick(chars: &[char], start: usize) -> Option<usize> {
256    (start..chars.len()).find(|&j| chars[j] == '`')
257}
258
259/// Find closing `**` starting from position `start`.
260fn find_closing_double_star(chars: &[char], start: usize) -> Option<usize> {
261    let len = chars.len();
262    let mut j = start;
263    while j + 1 < len {
264        if chars[j] == '*' && chars[j + 1] == '*' {
265            return Some(j);
266        }
267        j += 1;
268    }
269    None
270}
271
272#[async_trait]
273impl BotApi for TelegramBot {
274    async fn send_message(&self, chat_id: i64, text: &str) -> TelegramResult<i32> {
275        use teloxide::payloads::SendMessageSetters;
276        use teloxide::prelude::*;
277        use teloxide::types::ParseMode;
278
279        let result = self
280            .bot
281            .send_message(teloxide::types::ChatId(chat_id), text)
282            .parse_mode(ParseMode::Html)
283            .await
284            .map_err(|e| TelegramError::Send {
285                attempts: 1,
286                reason: e.to_string(),
287            })?;
288
289        Ok(result.id.0)
290    }
291
292    async fn send_document(
293        &self,
294        chat_id: i64,
295        file_path: &Path,
296        caption: Option<&str>,
297    ) -> TelegramResult<i32> {
298        use teloxide::payloads::SendDocumentSetters;
299        use teloxide::prelude::*;
300        use teloxide::types::{InputFile, ParseMode};
301
302        let input_file = InputFile::file(file_path);
303        let mut request = self
304            .bot
305            .send_document(teloxide::types::ChatId(chat_id), input_file);
306
307        if let Some(cap) = caption {
308            request = request.caption(cap).parse_mode(ParseMode::Html);
309        }
310
311        let result = request.await.map_err(|e| TelegramError::Send {
312            attempts: 1,
313            reason: e.to_string(),
314        })?;
315
316        Ok(result.id.0)
317    }
318
319    async fn send_photo(
320        &self,
321        chat_id: i64,
322        file_path: &Path,
323        caption: Option<&str>,
324    ) -> TelegramResult<i32> {
325        use teloxide::payloads::SendPhotoSetters;
326        use teloxide::prelude::*;
327        use teloxide::types::{InputFile, ParseMode};
328
329        let input_file = InputFile::file(file_path);
330        let mut request = self
331            .bot
332            .send_photo(teloxide::types::ChatId(chat_id), input_file);
333
334        if let Some(cap) = caption {
335            request = request.caption(cap).parse_mode(ParseMode::Html);
336        }
337
338        let result = request.await.map_err(|e| TelegramError::Send {
339            attempts: 1,
340            reason: e.to_string(),
341        })?;
342
343        Ok(result.id.0)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::sync::{Arc, Mutex};
351
352    /// A mock BotApi for testing that records sent messages.
353    struct MockBot {
354        sent: Arc<Mutex<Vec<(i64, String)>>>,
355        next_id: Arc<Mutex<i32>>,
356        should_fail: bool,
357    }
358
359    impl MockBot {
360        fn new() -> Self {
361            Self {
362                sent: Arc::new(Mutex::new(Vec::new())),
363                next_id: Arc::new(Mutex::new(1)),
364                should_fail: false,
365            }
366        }
367
368        fn failing() -> Self {
369            Self {
370                sent: Arc::new(Mutex::new(Vec::new())),
371                next_id: Arc::new(Mutex::new(1)),
372                should_fail: true,
373            }
374        }
375
376        fn sent_messages(&self) -> Vec<(i64, String)> {
377            self.sent.lock().unwrap().clone()
378        }
379    }
380
381    #[async_trait]
382    impl BotApi for MockBot {
383        async fn send_message(&self, chat_id: i64, text: &str) -> TelegramResult<i32> {
384            if self.should_fail {
385                return Err(TelegramError::Send {
386                    attempts: 1,
387                    reason: "mock failure".to_string(),
388                });
389            }
390            self.sent.lock().unwrap().push((chat_id, text.to_string()));
391            let mut id = self.next_id.lock().unwrap();
392            let current = *id;
393            *id += 1;
394            Ok(current)
395        }
396
397        async fn send_document(
398            &self,
399            chat_id: i64,
400            file_path: &Path,
401            caption: Option<&str>,
402        ) -> TelegramResult<i32> {
403            if self.should_fail {
404                return Err(TelegramError::Send {
405                    attempts: 1,
406                    reason: "mock failure".to_string(),
407                });
408            }
409            let label = format!(
410                "[doc:{}]{}",
411                file_path.display(),
412                caption.map(|c| format!(" {c}")).unwrap_or_default()
413            );
414            self.sent.lock().unwrap().push((chat_id, label));
415            let mut id = self.next_id.lock().unwrap();
416            let current = *id;
417            *id += 1;
418            Ok(current)
419        }
420
421        async fn send_photo(
422            &self,
423            chat_id: i64,
424            file_path: &Path,
425            caption: Option<&str>,
426        ) -> TelegramResult<i32> {
427            if self.should_fail {
428                return Err(TelegramError::Send {
429                    attempts: 1,
430                    reason: "mock failure".to_string(),
431                });
432            }
433            let label = format!(
434                "[photo:{}]{}",
435                file_path.display(),
436                caption.map(|c| format!(" {c}")).unwrap_or_default()
437            );
438            self.sent.lock().unwrap().push((chat_id, label));
439            let mut id = self.next_id.lock().unwrap();
440            let current = *id;
441            *id += 1;
442            Ok(current)
443        }
444    }
445
446    #[test]
447    fn format_question_includes_hat_and_loop() {
448        let msg = TelegramBot::format_question("Builder", 3, "main", "Which DB should I use?");
449        assert!(msg.contains("<b>Builder</b>"));
450        assert!(msg.contains("iteration 3"));
451        assert!(msg.contains("<code>main</code>"));
452        assert!(msg.contains("Which DB should I use?"));
453    }
454
455    #[test]
456    fn format_question_escapes_html_in_content() {
457        let msg = TelegramBot::format_question("Hat", 1, "loop-1", "Use <b>this</b> & that?");
458        assert!(msg.contains("&lt;b&gt;this&lt;/b&gt;"));
459        assert!(msg.contains("&amp; that?"));
460    }
461
462    #[test]
463    fn format_question_renders_markdown() {
464        let msg = TelegramBot::format_question(
465            "Builder",
466            5,
467            "main",
468            "Should I use **async** or `sync` here?",
469        );
470        assert!(msg.contains("<b>async</b>"));
471        assert!(msg.contains("<code>sync</code>"));
472    }
473
474    #[test]
475    fn format_greeting_includes_loop_id() {
476        let msg = TelegramBot::format_greeting("feature-auth");
477        assert!(msg.contains("<code>feature-auth</code>"));
478        assert!(msg.contains("online"));
479    }
480
481    #[test]
482    fn format_farewell_includes_loop_id() {
483        let msg = TelegramBot::format_farewell("main");
484        assert!(msg.contains("<code>main</code>"));
485        assert!(msg.contains("shutting down"));
486    }
487
488    #[test]
489    fn escape_html_handles_special_chars() {
490        assert_eq!(
491            super::escape_html("a < b & c > d"),
492            "a &lt; b &amp; c &gt; d"
493        );
494        assert_eq!(super::escape_html("no specials"), "no specials");
495        assert_eq!(super::escape_html(""), "");
496    }
497
498    // ---- markdown_to_telegram_html tests ----
499
500    #[test]
501    fn md_to_html_bold_text() {
502        assert_eq!(
503            super::markdown_to_telegram_html("This is **bold** text"),
504            "This is <b>bold</b> text"
505        );
506    }
507
508    #[test]
509    fn md_to_html_inline_code() {
510        assert_eq!(
511            super::markdown_to_telegram_html("Run `cargo test` now"),
512            "Run <code>cargo test</code> now"
513        );
514    }
515
516    #[test]
517    fn md_to_html_code_block() {
518        let input = "Before\n```rust\nfn main() {}\n```\nAfter";
519        let result = super::markdown_to_telegram_html(input);
520        assert!(result.contains("<pre>fn main() {}</pre>"));
521        assert!(result.contains("Before"));
522        assert!(result.contains("After"));
523    }
524
525    #[test]
526    fn md_to_html_headers() {
527        assert_eq!(super::markdown_to_telegram_html("# Title"), "<b>Title</b>");
528        assert_eq!(
529            super::markdown_to_telegram_html("## Subtitle"),
530            "<b>Subtitle</b>"
531        );
532        assert_eq!(super::markdown_to_telegram_html("### Deep"), "<b>Deep</b>");
533    }
534
535    #[test]
536    fn md_to_html_list_items() {
537        let input = "- first item\n- second item\n* third item";
538        let result = super::markdown_to_telegram_html(input);
539        assert_eq!(result, "• first item\n• second item\n• third item");
540    }
541
542    #[test]
543    fn md_to_html_escapes_html_in_content() {
544        assert_eq!(
545            super::markdown_to_telegram_html("Use <div> & <span>"),
546            "Use &lt;div&gt; &amp; &lt;span&gt;"
547        );
548    }
549
550    #[test]
551    fn md_to_html_escapes_html_in_bold() {
552        assert_eq!(
553            super::markdown_to_telegram_html("**<script>alert(1)</script>**"),
554            "<b>&lt;script&gt;alert(1)&lt;/script&gt;</b>"
555        );
556    }
557
558    #[test]
559    fn md_to_html_escapes_html_in_code_block() {
560        let input = "```\n<div>html</div>\n```";
561        let result = super::markdown_to_telegram_html(input);
562        assert_eq!(result, "<pre>&lt;div&gt;html&lt;/div&gt;</pre>\n");
563    }
564
565    #[test]
566    fn md_to_html_plain_text_passthrough() {
567        assert_eq!(
568            super::markdown_to_telegram_html("Just plain text"),
569            "Just plain text"
570        );
571    }
572
573    #[test]
574    fn md_to_html_empty_string() {
575        assert_eq!(super::markdown_to_telegram_html(""), "");
576    }
577
578    #[test]
579    fn md_to_html_mixed_formatting() {
580        let input = "# Status\n\nBuild **passed** with `0 errors`.\n\n- Tests: 42\n- Coverage: 85%";
581        let result = super::markdown_to_telegram_html(input);
582        assert!(result.contains("<b>Status</b>"));
583        assert!(result.contains("<b>passed</b>"));
584        assert!(result.contains("<code>0 errors</code>"));
585        assert!(result.contains("• Tests: 42"));
586        assert!(result.contains("• Coverage: 85%"));
587    }
588
589    #[test]
590    fn md_to_html_unclosed_code_block() {
591        let input = "```\nunclosed code";
592        let result = super::markdown_to_telegram_html(input);
593        assert_eq!(result, "<pre>unclosed code</pre>");
594    }
595
596    #[test]
597    fn md_to_html_list_items_with_inline_formatting() {
598        let input = "- **bold** item\n- `code` item";
599        let result = super::markdown_to_telegram_html(input);
600        assert_eq!(result, "• <b>bold</b> item\n• <code>code</code> item");
601    }
602
603    #[tokio::test]
604    async fn mock_bot_send_message_succeeds() {
605        let bot = MockBot::new();
606        let id = bot.send_message(123, "hello").await.unwrap();
607        assert_eq!(id, 1);
608
609        let sent = bot.sent_messages();
610        assert_eq!(sent.len(), 1);
611        assert_eq!(sent[0], (123, "hello".to_string()));
612    }
613
614    #[tokio::test]
615    async fn mock_bot_send_message_increments_id() {
616        let bot = MockBot::new();
617        let id1 = bot.send_message(123, "first").await.unwrap();
618        let id2 = bot.send_message(123, "second").await.unwrap();
619        assert_eq!(id1, 1);
620        assert_eq!(id2, 2);
621    }
622
623    #[tokio::test]
624    async fn mock_bot_failure_returns_send_error() {
625        let bot = MockBot::failing();
626        let result = bot.send_message(123, "hello").await;
627        assert!(result.is_err());
628        assert!(matches!(
629            result.unwrap_err(),
630            TelegramError::Send { attempts: 1, .. }
631        ));
632    }
633}