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