openzeppelin_monitor/services/notification/
telegram.rs1use async_trait::async_trait;
7use regex::Regex;
8use reqwest_middleware::ClientWithMiddleware;
9use std::{collections::HashMap, sync::Arc};
10
11use crate::{
12 models::TriggerTypeConfig,
13 services::notification::{NotificationError, Notifier, WebhookConfig, WebhookNotifier},
14};
15
16#[derive(Debug)]
18pub struct TelegramNotifier {
19 inner: WebhookNotifier,
20 disable_web_preview: bool,
22}
23
24impl TelegramNotifier {
25 pub fn new(
35 base_url: Option<String>,
36 token: String,
37 chat_id: String,
38 disable_web_preview: Option<bool>,
39 title: String,
40 body_template: String,
41 http_client: Arc<ClientWithMiddleware>,
42 ) -> Result<Self, NotificationError> {
43 let url = format!(
44 "{}/bot{}/sendMessage",
45 base_url.unwrap_or("https://api.telegram.org".to_string()),
46 token
47 );
48
49 let mut payload_fields = HashMap::new();
51 payload_fields.insert("chat_id".to_string(), serde_json::json!(chat_id));
52 payload_fields.insert("parse_mode".to_string(), serde_json::json!("MarkdownV2"));
53
54 let config = WebhookConfig {
55 url,
56 url_params: None,
57 title,
58 body_template,
59 method: Some("POST".to_string()),
60 secret: None,
61 headers: None,
62 payload_fields: Some(payload_fields),
63 };
64
65 Ok(Self {
66 inner: WebhookNotifier::new(config, http_client)?,
67 disable_web_preview: disable_web_preview.unwrap_or(false),
68 })
69 }
70
71 pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
79 let message = self.inner.format_message(variables);
80 let escaped_message = Self::escape_markdown_v2(&message);
81 let escaped_title = Self::escape_markdown_v2(&self.inner.title);
82 format!("*{}* \n\n{}", escaped_title, escaped_message)
83 }
84
85 pub fn escape_markdown_v2(text: &str) -> String {
94 const SPECIAL: &[char] = &[
96 '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.',
97 '!', '\\',
98 ];
99
100 let re =
104 Regex::new(r"(?s)```.*?```|`[^`]*`|\*[^*]*\*|_[^_]*_|~[^~]*~|\[([^\]]+)\]\(([^)]+)\)")
105 .unwrap();
106
107 let mut out = String::with_capacity(text.len());
108 let mut last = 0;
109
110 for caps in re.captures_iter(text) {
111 let mat = caps.get(0).unwrap();
112
113 for c in text[last..mat.start()].chars() {
115 if SPECIAL.contains(&c) {
116 out.push('\\');
117 }
118 out.push(c);
119 }
120
121 if let (Some(lbl), Some(url)) = (caps.get(1), caps.get(2)) {
123 let mut esc_label = String::with_capacity(lbl.as_str().len() * 2);
125 for c in lbl.as_str().chars() {
126 if SPECIAL.contains(&c) {
127 esc_label.push('\\');
128 }
129 esc_label.push(c);
130 }
131 let mut esc_url = String::with_capacity(url.as_str().len() * 2);
133 for c in url.as_str().chars() {
134 if SPECIAL.contains(&c) {
135 esc_url.push('\\');
136 }
137 esc_url.push(c);
138 }
139 out.push('[');
141 out.push_str(&esc_label);
142 out.push(']');
143 out.push('(');
144 out.push_str(&esc_url);
145 out.push(')');
146 } else {
147 out.push_str(mat.as_str());
149 }
150
151 last = mat.end();
152 }
153
154 for c in text[last..].chars() {
156 if SPECIAL.contains(&c) {
157 out.push('\\');
158 }
159 out.push(c);
160 }
161
162 out
163 }
164
165 pub fn from_config(
174 config: &TriggerTypeConfig,
175 http_client: Arc<ClientWithMiddleware>,
176 ) -> Result<Self, NotificationError> {
177 if let TriggerTypeConfig::Telegram {
178 token,
179 chat_id,
180 disable_web_preview,
181 message,
182 ..
183 } = config
184 {
185 let mut payload_fields = HashMap::new();
187 payload_fields.insert("chat_id".to_string(), serde_json::json!(chat_id));
188 payload_fields.insert("parse_mode".to_string(), serde_json::json!("MarkdownV2"));
189
190 let webhook_config = WebhookConfig {
191 url: format!("https://api.telegram.org/bot{}/sendMessage", token),
192 url_params: None,
193 title: message.title.clone(),
194 body_template: message.body.clone(),
195 method: Some("POST".to_string()),
196 secret: None,
197 headers: None,
198 payload_fields: Some(payload_fields),
199 };
200
201 Ok(Self {
202 inner: WebhookNotifier::new(webhook_config, http_client)?,
203 disable_web_preview: disable_web_preview.unwrap_or(false),
204 })
205 } else {
206 Err(NotificationError::config_error(
207 format!("Invalid telegram configuration: {:?}", config),
208 None,
209 None,
210 ))
211 }
212 }
213}
214
215#[async_trait]
216impl Notifier for TelegramNotifier {
217 async fn notify(&self, message: &str) -> Result<(), NotificationError> {
225 let mut payload_fields = self.inner.payload_fields.clone().unwrap_or_default();
227
228 payload_fields.insert("text".to_string(), serde_json::json!(message));
230 payload_fields.insert(
231 "disable_web_page_preview".to_string(),
232 serde_json::json!(self.disable_web_preview),
233 );
234
235 self.inner
237 .notify_with_payload(message, payload_fields)
240 .await
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use crate::{
247 models::{NotificationMessage, SecretString, SecretValue},
248 utils::{tests::create_test_http_client, HttpRetryConfig},
249 };
250
251 use super::*;
252
253 fn create_test_notifier(body_template: &str) -> TelegramNotifier {
254 TelegramNotifier::new(
255 None,
256 "test-token".to_string(),
257 "test-chat-id".to_string(),
258 Some(true),
259 "Alert".to_string(),
260 body_template.to_string(),
261 create_test_http_client(),
262 )
263 .unwrap()
264 }
265
266 fn create_test_telegram_config() -> TriggerTypeConfig {
267 TriggerTypeConfig::Telegram {
268 token: SecretValue::Plain(SecretString::new("test-token".to_string())),
269 chat_id: "test-chat-id".to_string(),
270 disable_web_preview: Some(true),
271 message: NotificationMessage {
272 title: "Alert".to_string(),
273 body: "Test message ${value}".to_string(),
274 },
275 retry_policy: HttpRetryConfig::default(),
276 }
277 }
278
279 #[test]
284 fn test_format_message() {
285 let notifier = create_test_notifier("Value is ${value} and status is ${status}");
286
287 let mut variables = HashMap::new();
288 variables.insert("value".to_string(), "100".to_string());
289 variables.insert("status".to_string(), "critical".to_string());
290
291 let result = notifier.format_message(&variables);
292 assert_eq!(result, "*Alert* \n\nValue is 100 and status is critical");
293 }
294
295 #[test]
296 fn test_format_message_with_missing_variables() {
297 let notifier = create_test_notifier("Value is ${value} and status is ${status}");
298
299 let mut variables = HashMap::new();
300 variables.insert("value".to_string(), "100".to_string());
301 let result = notifier.format_message(&variables);
304 assert_eq!(
305 result,
306 "*Alert* \n\nValue is 100 and status is $\\{status\\}"
307 );
308 }
309
310 #[test]
311 fn test_format_message_with_empty_template() {
312 let notifier = create_test_notifier("");
313
314 let variables = HashMap::new();
315 let result = notifier.format_message(&variables);
316 assert_eq!(result, "*Alert* \n\n");
317 }
318
319 #[test]
324 fn test_from_config_with_telegram_config() {
325 let config = create_test_telegram_config();
326 let http_client = create_test_http_client();
327 let notifier = TelegramNotifier::from_config(&config, http_client);
328 assert!(notifier.is_ok());
329
330 let notifier = notifier.unwrap();
331 assert_eq!(
332 notifier.inner.url,
333 "https://api.telegram.org/bottest-token/sendMessage"
334 );
335 assert!(notifier.disable_web_preview);
336 assert_eq!(notifier.inner.body_template, "Test message ${value}");
337 }
338
339 #[test]
340 fn test_from_config_disable_web_preview_default_in_config() {
341 let config = TriggerTypeConfig::Telegram {
342 token: SecretValue::Plain(SecretString::new("test-token".to_string())),
343 chat_id: "test-chat-id".to_string(),
344 disable_web_preview: None, message: NotificationMessage {
346 title: "Alert".to_string(),
347 body: "Test message ${value}".to_string(),
348 },
349 retry_policy: HttpRetryConfig::default(),
350 };
351 let http_client = create_test_http_client();
352 let notifier = TelegramNotifier::from_config(&config, http_client).unwrap();
353 assert!(!notifier.disable_web_preview);
354 }
355
356 #[tokio::test]
361 async fn test_notify_failure() {
362 let notifier = create_test_notifier("Test message");
363 let result = notifier.notify("Test message").await;
364 assert!(result.is_err());
365
366 let error = result.unwrap_err();
367 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
368 }
369
370 #[tokio::test]
371 async fn test_notify_with_payload_failure() {
372 let notifier = create_test_notifier("Test message");
373 let result = notifier
374 .notify_with_payload("Test message", HashMap::new())
375 .await;
376 assert!(result.is_err());
377
378 let error = result.unwrap_err();
379 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
380 }
381
382 #[test]
383 fn test_escape_markdown_v2() {
384 assert_eq!(
386 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)"),
387 "*Transaction Alert*\n*Network:* Base Sepolia\n*From:* 0x00001\n*To:* 0x00002\n*Transaction:* [View on Blockscout](https://base\\-sepolia\\.blockscout\\.com/tx/0x00003)"
388 );
389
390 assert_eq!(
392 TelegramNotifier::escape_markdown_v2("Hello *world*!"),
393 "Hello *world*\\!"
394 );
395
396 assert_eq!(
398 TelegramNotifier::escape_markdown_v2("(test) [test] {test} <test>"),
399 "\\(test\\) \\[test\\] \\{test\\} <test\\>"
400 );
401
402 assert_eq!(
404 TelegramNotifier::escape_markdown_v2("```code block```"),
405 "```code block```"
406 );
407
408 assert_eq!(
410 TelegramNotifier::escape_markdown_v2("`inline code`"),
411 "`inline code`"
412 );
413
414 assert_eq!(
416 TelegramNotifier::escape_markdown_v2("*bold text*"),
417 "*bold text*"
418 );
419
420 assert_eq!(
422 TelegramNotifier::escape_markdown_v2("_italic text_"),
423 "_italic text_"
424 );
425
426 assert_eq!(
428 TelegramNotifier::escape_markdown_v2("~strikethrough~"),
429 "~strikethrough~"
430 );
431
432 assert_eq!(
434 TelegramNotifier::escape_markdown_v2("[link](https://example.com/test.html)"),
435 "[link](https://example\\.com/test\\.html)"
436 );
437
438 assert_eq!(
440 TelegramNotifier::escape_markdown_v2("[test!*_]{link}](https://test.com/path[1])"),
441 "\\[test\\!\\*\\_\\]\\{link\\}\\]\\(https://test\\.com/path\\[1\\]\\)"
442 );
443
444 assert_eq!(
446 TelegramNotifier::escape_markdown_v2(
447 "Hello *bold* and [link](http://test.com) and `code`"
448 ),
449 "Hello *bold* and [link](http://test\\.com) and `code`"
450 );
451
452 assert_eq!(
454 TelegramNotifier::escape_markdown_v2("test\\test"),
455 "test\\\\test"
456 );
457
458 assert_eq!(
460 TelegramNotifier::escape_markdown_v2("_*[]()~`>#+-=|{}.!\\"),
461 "\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
462 );
463
464 assert_eq!(
466 TelegramNotifier::escape_markdown_v2("*bold with [link](http://test.com)*"),
467 "*bold with [link](http://test.com)*"
468 );
469
470 assert_eq!(TelegramNotifier::escape_markdown_v2(""), "");
472
473 assert_eq!(
475 TelegramNotifier::escape_markdown_v2("***"),
476 "**\\*" );
478 }
479}