1use std::path::Path;
2
3use async_trait::async_trait;
4
5use crate::error::{TelegramError, TelegramResult};
6
7#[async_trait]
11pub trait BotApi: Send + Sync {
12 async fn send_message(&self, chat_id: i64, text: &str) -> TelegramResult<i32>;
16
17 async fn send_document(
21 &self,
22 chat_id: i64,
23 file_path: &Path,
24 caption: Option<&str>,
25 ) -> TelegramResult<i32>;
26
27 async fn send_photo(
31 &self,
32 chat_id: i64,
33 file_path: &Path,
34 caption: Option<&str>,
35 ) -> TelegramResult<i32>;
36}
37
38pub struct TelegramBot {
40 bot: teloxide::Bot,
41}
42
43impl TelegramBot {
44 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 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 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 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
92pub fn escape_html(text: &str) -> String {
96 text.replace('&', "&")
97 .replace('<', "<")
98 .replace('>', ">")
99}
100
101pub 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 let trimmed = line.trim();
121 if trimmed.starts_with("```") {
122 if in_code_block {
123 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 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 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 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 if !result.is_empty() {
168 result.push('\n');
169 }
170 result.push_str(&convert_inline(&escape_html(line)));
171 }
172
173 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
183fn 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
200fn 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
211fn 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 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 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
258fn find_closing_backtick(chars: &[char], start: usize) -> Option<usize> {
260 (start..chars.len()).find(|&j| chars[j] == '`')
261}
262
263fn 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 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("<b>this</b>"));
463 assert!(msg.contains("& 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 < b & c > d"
497 );
498 assert_eq!(super::escape_html("no specials"), "no specials");
499 assert_eq!(super::escape_html(""), "");
500 }
501
502 #[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 <div> & <span>"
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><script>alert(1)</script></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><div>html</div></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}