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) -> 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 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 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 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
88pub fn escape_html(text: &str) -> String {
92 text.replace('&', "&")
93 .replace('<', "<")
94 .replace('>', ">")
95}
96
97pub 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 let trimmed = line.trim();
117 if trimmed.starts_with("```") {
118 if in_code_block {
119 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 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 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 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 if !result.is_empty() {
164 result.push('\n');
165 }
166 result.push_str(&convert_inline(&escape_html(line)));
167 }
168
169 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
179fn 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
196fn 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
207fn 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 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 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
254fn find_closing_backtick(chars: &[char], start: usize) -> Option<usize> {
256 (start..chars.len()).find(|&j| chars[j] == '`')
257}
258
259fn 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 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("<b>this</b>"));
459 assert!(msg.contains("& 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 < b & c > d"
493 );
494 assert_eq!(super::escape_html("no specials"), "no specials");
495 assert_eq!(super::escape_html(""), "");
496 }
497
498 #[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 <div> & <span>"
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><script>alert(1)</script></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><div>html</div></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}