use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use serde_json::json;
use super::WebhookPayloadBuilder;
pub struct TelegramPayloadBuilder {
pub chat_id: String,
pub disable_web_preview: bool,
}
impl TelegramPayloadBuilder {
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
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(_)) => {
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('"', """);
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) => {
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();
assert!(text.contains("A < B & C > D"));
assert!(!text.contains("<b>A < B & C > D</b>"));
assert!(text.contains(
"Check: <script>alert(1)</script> & <b>bold</b>"
));
}
#[test]
fn test_telegram_complex_markdown() {
let builder = TelegramPayloadBuilder {
chat_id: "123".to_string(),
disable_web_preview: false,
};
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/"quoted"">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,
};
let payload = builder.build_payload("*Non-Bold Title*", "Body with **bold**");
let text = payload["text"].as_str().unwrap();
assert!(text.contains("*Non-Bold Title*"));
assert!(!text.contains("<b>*Non-Bold Title*</b>"));
assert!(text.contains("Body with <b>bold</b>"));
}
}