omnihook 0.1.2

Webhook client with payload builders for Slack, Discord, Telegram and generic webhooks with optional HMAC signing and idempotency key support.
Documentation
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use serde_json::json;

use super::WebhookPayloadBuilder;

/// A payload builder for Telegram notifications using HTML.
///
/// Converts markdown input to Telegram-compatible HTML using `pulldown-cmark`.
/// Requires a `chat_id` and optionally allows disabling web page previews.
///
/// ### JSON Output
/// ```json
/// {
///   "chat_id": "12345678",
///   "text": "Title\n\nBody message",
///   "parse_mode": "HTML",
///   "disable_web_page_preview": true
/// }
/// ```
pub struct TelegramPayloadBuilder {
    /// The chat ID to send the message to.
    pub chat_id: String,
    /// Whether to disable web page previews in the message.
    pub disable_web_preview: bool,
}

impl TelegramPayloadBuilder {
    /// Escapes HTML special characters: `<`, `>`, and `&`.
    fn escape_html(s: &str) -> String {
        s.replace('&', "&amp;")
            .replace('<', "&lt;")
            .replace('>', "&gt;")
    }

    /// Converts markdown to Telegram-compatible HTML.
    fn markdown_to_html(markdown: &str) -> String {
        let mut html = String::new();
        let mut options = Options::empty();
        options.insert(Options::ENABLE_STRIKETHROUGH);
        let parser = Parser::new_ext(markdown, options);

        for event in parser {
            match event {
                Event::Text(text) => html.push_str(&Self::escape_html(&text)),
                Event::Code(code) => {
                    html.push_str("<code>");
                    html.push_str(&Self::escape_html(&code));
                    html.push_str("</code>");
                }
                Event::Start(Tag::Strong) => html.push_str("<b>"),
                Event::End(TagEnd::Strong) => html.push_str("</b>"),
                Event::Start(Tag::Emphasis) => html.push_str("<i>"),
                Event::End(TagEnd::Emphasis) => html.push_str("</i>"),
                Event::Start(Tag::Strikethrough) => html.push_str("<s>"),
                Event::End(TagEnd::Strikethrough) => html.push_str("</s>"),
                Event::Start(Tag::BlockQuote(_)) => html.push_str("<blockquote>"),
                Event::End(TagEnd::BlockQuote(_)) => {
                    // Strip the trailing "\n\n" generated by the paragraph inside the quote.
                    if html.ends_with("\n\n") {
                        html.truncate(html.len() - 2);
                    }
                    html.push_str("</blockquote>\n\n");
                }
                Event::Start(Tag::Link { dest_url, .. }) => {
                    let href = Self::escape_html(dest_url.as_ref()).replace('"', "&quot;");
                    html.push_str(&format!(r#"<a href="{}">"#, href));
                }
                Event::End(TagEnd::Link) => html.push_str("</a>"),
                Event::Start(Tag::CodeBlock(_)) => html.push_str("<pre>"),
                Event::End(TagEnd::CodeBlock) => {
                    // pulldown-cmark typically includes a trailing '\n' at the end of fenced code blocks.
                    if html.ends_with('\n') {
                        html.pop();
                    }
                    html.push_str("</pre>\n\n");
                }
                Event::Start(Tag::Heading { .. }) => html.push_str("<b>"),
                Event::End(TagEnd::Heading(_)) => html.push_str("</b>\n\n"),
                Event::End(TagEnd::Paragraph) => html.push_str("\n\n"),
                Event::SoftBreak | Event::HardBreak => html.push('\n'),
                Event::Html(content) | Event::InlineHtml(content) => {
                    html.push_str(&Self::escape_html(&content))
                }
                _ => {}
            }
        }

        html.trim().to_string()
    }
}

impl WebhookPayloadBuilder for TelegramPayloadBuilder {
    fn build_payload(&self, title: &str, body: &str) -> serde_json::Value {
        let escaped_title = Self::escape_html(title);
        let rendered_body = Self::markdown_to_html(body);

        let text = if title.is_empty() {
            rendered_body
        } else if rendered_body.is_empty() {
            escaped_title
        } else {
            format!("{escaped_title}\n\n{rendered_body}")
        };

        json!({
            "chat_id": self.chat_id,
            "text": text,
            "parse_mode": "HTML",
            "disable_web_page_preview": self.disable_web_preview
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_telegram_payload_builder() {
        let builder = TelegramPayloadBuilder {
            chat_id: "12345".to_string(),
            disable_web_preview: true,
        };
        let payload = builder.build_payload("Test Title", "Test Message");
        assert_eq!(
            payload,
            json!({
                "chat_id": "12345",
                "text": "Test Title\n\nTest Message",
                "parse_mode": "HTML",
                "disable_web_page_preview": true
            })
        );
    }

    #[test]
    fn test_telegram_html_escaping() {
        let builder = TelegramPayloadBuilder {
            chat_id: "12345".to_string(),
            disable_web_preview: true,
        };
        let payload = builder.build_payload(
            "A < B & C > D",
            "Check: <script>alert(1)</script> & <b>bold</b>",
        );
        let text = payload["text"].as_str().unwrap();

        // Title should be escaped but NOT bolded automatically
        assert!(text.contains("A &lt; B &amp; C &gt; D"));
        assert!(!text.contains("<b>A &lt; B &amp; C &gt; D</b>"));
        // Body should be escaped
        assert!(text.contains(
            "Check: &lt;script&gt;alert(1)&lt;/script&gt; &amp; &lt;b&gt;bold&lt;/b&gt;"
        ));
    }

    #[test]
    fn test_telegram_complex_markdown() {
        let builder = TelegramPayloadBuilder {
            chat_id: "123".to_string(),
            disable_web_preview: false,
        };
        // Use proper markdown with spacing for code blocks
        let markdown = "# Header\n\n**Bold** and *Italic*.\n\n> Quote\n\n[Link](https://example.com/\"quoted\")\n\n`code` and \n\n```rust\nblock\n```";
        let payload = builder.build_payload("Title", markdown);
        let text = payload["text"].as_str().unwrap();

        assert!(text.contains("Title\n\n"));
        assert!(text.contains("<b>Header</b>"));
        assert!(text.contains("<b>Bold</b>"));
        assert!(text.contains("<i>Italic</i>"));
        assert!(text.contains("<blockquote>Quote</blockquote>"));
        assert!(text.contains(r#"<a href="https://example.com/&quot;quoted&quot;">Link</a>"#));
        assert!(text.contains("<code>code</code>"));
        assert!(text.contains("<pre>block</pre>"));
    }

    #[test]
    fn test_telegram_title_escaping() {
        let builder = TelegramPayloadBuilder {
            chat_id: "123".to_string(),
            disable_web_preview: true,
        };
        // Title contains characters that look like markdown but should be escaped
        let payload = builder.build_payload("*Non-Bold Title*", "Body with **bold**");
        let text = payload["text"].as_str().unwrap();

        // Title should contain literal asterisks and not be bolded
        assert!(text.contains("*Non-Bold Title*"));
        assert!(!text.contains("<b>*Non-Bold Title*</b>"));
        // Body should be parsed as markdown
        assert!(text.contains("Body with <b>bold</b>"));
    }
}