openzeppelin_monitor/services/notification/
telegram.rsuse async_trait::async_trait;
use regex::Regex;
use std::collections::HashMap;
use crate::{
models::TriggerTypeConfig,
services::notification::{NotificationError, Notifier, WebhookConfig, WebhookNotifier},
};
pub struct TelegramNotifier {
inner: WebhookNotifier,
disable_web_preview: bool,
}
impl TelegramNotifier {
pub fn new(
base_url: Option<String>,
token: String,
chat_id: String,
disable_web_preview: Option<bool>,
title: String,
body_template: String,
) -> Result<Self, Box<NotificationError>> {
let url = format!(
"{}/bot{}/sendMessage",
base_url.unwrap_or("https://api.telegram.org".to_string()),
token
);
let mut url_params = HashMap::new();
url_params.insert("chat_id".to_string(), chat_id);
url_params.insert("parse_mode".to_string(), "MarkdownV2".to_string());
Ok(Self {
inner: WebhookNotifier::new(WebhookConfig {
url,
url_params: Some(url_params),
title,
body_template,
method: Some("GET".to_string()),
secret: None,
headers: None,
payload_fields: None,
})?,
disable_web_preview: disable_web_preview.unwrap_or(false),
})
}
pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
let message = self.inner.format_message(variables);
let escaped_message = Self::escape_markdown_v2(&message);
let escaped_title = Self::escape_markdown_v2(&self.inner.title);
format!("*{}* \n\n{}", escaped_title, escaped_message)
}
pub fn escape_markdown_v2(text: &str) -> String {
const SPECIAL: &[char] = &[
'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.',
'!', '\\',
];
let re =
Regex::new(r"(?s)```.*?```|`[^`]*`|\*[^*]*\*|_[^_]*_|~[^~]*~|\[([^\]]+)\]\(([^)]+)\)")
.unwrap();
let mut out = String::with_capacity(text.len());
let mut last = 0;
for caps in re.captures_iter(text) {
let mat = caps.get(0).unwrap();
for c in text[last..mat.start()].chars() {
if SPECIAL.contains(&c) {
out.push('\\');
}
out.push(c);
}
if let (Some(lbl), Some(url)) = (caps.get(1), caps.get(2)) {
let mut esc_label = String::with_capacity(lbl.as_str().len() * 2);
for c in lbl.as_str().chars() {
if SPECIAL.contains(&c) {
esc_label.push('\\');
}
esc_label.push(c);
}
let mut esc_url = String::with_capacity(url.as_str().len() * 2);
for c in url.as_str().chars() {
if SPECIAL.contains(&c) {
esc_url.push('\\');
}
esc_url.push(c);
}
out.push('[');
out.push_str(&esc_label);
out.push(']');
out.push('(');
out.push_str(&esc_url);
out.push(')');
} else {
out.push_str(mat.as_str());
}
last = mat.end();
}
for c in text[last..].chars() {
if SPECIAL.contains(&c) {
out.push('\\');
}
out.push(c);
}
out
}
pub fn from_config(config: &TriggerTypeConfig) -> Option<Self> {
match config {
TriggerTypeConfig::Telegram {
token,
chat_id,
message,
disable_web_preview,
} => {
let mut url_params = HashMap::new();
url_params.insert("chat_id".to_string(), chat_id.clone());
url_params.insert("parse_mode".to_string(), "MarkdownV2".to_string());
WebhookNotifier::new(WebhookConfig {
url: format!("https://api.telegram.org/bot{}/sendMessage", token),
url_params: Some(url_params),
title: message.title.clone(),
body_template: message.body.clone(),
method: Some("GET".to_string()),
secret: None,
headers: Some(HashMap::from([(
"Content-Type".to_string(),
"application/json".to_string(),
)])),
payload_fields: None,
})
.ok()
.map(|inner| Self {
inner,
disable_web_preview: disable_web_preview.unwrap_or(false),
})
}
_ => None,
}
}
}
#[async_trait]
impl Notifier for TelegramNotifier {
async fn notify(&self, message: &str) -> Result<(), anyhow::Error> {
let mut url_params = self.inner.url_params.clone().unwrap_or_default();
url_params.insert("text".to_string(), message.to_string());
url_params.insert(
"disable_web_page_preview".to_string(),
self.disable_web_preview.to_string(),
);
let notifier = WebhookNotifier::new(WebhookConfig {
url: self.inner.url.clone(),
url_params: Some(url_params),
title: self.inner.title.clone(),
body_template: self.inner.body_template.clone(),
method: Some("GET".to_string()),
secret: None,
headers: self.inner.headers.clone(),
payload_fields: None,
})?;
notifier.notify_with_payload(message, HashMap::new()).await
}
}
#[cfg(test)]
mod tests {
use crate::models::{NotificationMessage, SecretString, SecretValue};
use super::*;
fn create_test_notifier(body_template: &str) -> TelegramNotifier {
TelegramNotifier::new(
None,
"test-token".to_string(),
"test-chat-id".to_string(),
Some(true),
"Alert".to_string(),
body_template.to_string(),
)
.unwrap()
}
fn create_test_telegram_config() -> TriggerTypeConfig {
TriggerTypeConfig::Telegram {
token: SecretValue::Plain(SecretString::new("test-token".to_string())),
chat_id: "test-chat-id".to_string(),
disable_web_preview: Some(true),
message: NotificationMessage {
title: "Alert".to_string(),
body: "Test message ${value}".to_string(),
},
}
}
#[test]
fn test_format_message() {
let notifier = create_test_notifier("Value is ${value} and status is ${status}");
let mut variables = HashMap::new();
variables.insert("value".to_string(), "100".to_string());
variables.insert("status".to_string(), "critical".to_string());
let result = notifier.format_message(&variables);
assert_eq!(result, "*Alert* \n\nValue is 100 and status is critical");
}
#[test]
fn test_format_message_with_missing_variables() {
let notifier = create_test_notifier("Value is ${value} and status is ${status}");
let mut variables = HashMap::new();
variables.insert("value".to_string(), "100".to_string());
let result = notifier.format_message(&variables);
assert_eq!(
result,
"*Alert* \n\nValue is 100 and status is $\\{status\\}"
);
}
#[test]
fn test_format_message_with_empty_template() {
let notifier = create_test_notifier("");
let variables = HashMap::new();
let result = notifier.format_message(&variables);
assert_eq!(result, "*Alert* \n\n");
}
#[test]
fn test_from_config_with_telegram_config() {
let config = create_test_telegram_config();
let notifier = TelegramNotifier::from_config(&config);
assert!(notifier.is_some());
let notifier = notifier.unwrap();
assert_eq!(
notifier.inner.url,
"https://api.telegram.org/bottest-token/sendMessage"
);
assert!(notifier.disable_web_preview);
assert_eq!(notifier.inner.body_template, "Test message ${value}");
}
#[tokio::test]
async fn test_notify_failure() {
let notifier = create_test_notifier("Test message");
let result = notifier.notify("Test message").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_notify_with_payload_failure() {
let notifier = create_test_notifier("Test message");
let result = notifier
.notify_with_payload("Test message", HashMap::new())
.await;
assert!(result.is_err());
}
#[test]
fn test_escape_markdown_v2() {
assert_eq!(
TelegramNotifier::escape_markdown_v2("*Transaction Alert*\n*Network:* Base Sepolia\n*From:* 0x00001\n*To:* 0x00002\n*Transaction:* [View on Blockscout](https://base-sepolia.blockscout.com/tx/0x00003)"),
"*Transaction Alert*\n*Network:* Base Sepolia\n*From:* 0x00001\n*To:* 0x00002\n*Transaction:* [View on Blockscout](https://base\\-sepolia\\.blockscout\\.com/tx/0x00003)"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("Hello *world*!"),
"Hello *world*\\!"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("(test) [test] {test} <test>"),
"\\(test\\) \\[test\\] \\{test\\} <test\\>"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("```code block```"),
"```code block```"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("`inline code`"),
"`inline code`"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("*bold text*"),
"*bold text*"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("_italic text_"),
"_italic text_"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("~strikethrough~"),
"~strikethrough~"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("[link](https://example.com/test.html)"),
"[link](https://example\\.com/test\\.html)"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("[test!*_]{link}](https://test.com/path[1])"),
"\\[test\\!\\*\\_\\]\\{link\\}\\]\\(https://test\\.com/path\\[1\\]\\)"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2(
"Hello *bold* and [link](http://test.com) and `code`"
),
"Hello *bold* and [link](http://test\\.com) and `code`"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("test\\test"),
"test\\\\test"
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("_*[]()~`>#+-=|{}.!\\"),
"\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
);
assert_eq!(
TelegramNotifier::escape_markdown_v2("*bold with [link](http://test.com)*"),
"*bold with [link](http://test.com)*"
);
assert_eq!(TelegramNotifier::escape_markdown_v2(""), "");
assert_eq!(
TelegramNotifier::escape_markdown_v2("***"),
"**\\*" );
}
}