openzeppelin_monitor/services/notification/
telegram.rs

1//! Telegram notification implementation.
2//!
3//! Provides functionality to send formatted messages to Telegram channels
4//! via incoming webhooks, supporting message templates with variable substitution.
5
6use 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/// Implementation of Telegram notifications via webhooks
17#[derive(Debug)]
18pub struct TelegramNotifier {
19	inner: WebhookNotifier,
20	/// Disable web preview
21	disable_web_preview: bool,
22}
23
24impl TelegramNotifier {
25	/// Creates a new Telegram notifier instance
26	///
27	/// # Arguments
28	/// * `token` - Telegram bot token
29	/// * `chat_id` - Telegram chat ID
30	/// * `disable_web_preview` - Disable web preview
31	/// * `title` - Title to display in the message
32	/// * `body_template` - Message template with variables
33	/// * `http_client` - HTTP client with middleware for retries
34	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		// Set up payload fields for the webhook
50		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	/// Formats a message by substituting variables in the template
72	///
73	/// # Arguments
74	/// * `variables` - Map of variable names to values
75	///
76	/// # Returns
77	/// * `String` - Formatted message with variables replaced
78	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	/// Escape a full MarkdownV2 message, preserving entities and
86	/// escaping *all* special chars inside link URLs too.
87	///
88	/// # Arguments
89	/// * `text` - The text to escape
90	///
91	/// # Returns
92	/// * `String` - The escaped text
93	pub fn escape_markdown_v2(text: &str) -> String {
94		// Full set of Telegram MDV2 metacharacters (including backslash)
95		const SPECIAL: &[char] = &[
96			'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.',
97			'!', '\\',
98		];
99
100		// Regex that captures either:
101		//  - any MD entity: ```…```, `…`, *…*, _…_, ~…~
102		//  - or an inline link, capturing label & URL separately
103		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			// 1) escape everything before this match
114			for c in text[last..mat.start()].chars() {
115				if SPECIAL.contains(&c) {
116					out.push('\\');
117				}
118				out.push(c);
119			}
120
121			// 2) if this is an inline link (has two capture groups)
122			if let (Some(lbl), Some(url)) = (caps.get(1), caps.get(2)) {
123				// fully escape the label
124				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				// fully escape the URL (dots, hyphens, slashes, etc.)
132				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				// emit the link markers unescaped
140				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				// 3) otherwise just copy the entire MD entity verbatim
148				out.push_str(mat.as_str());
149			}
150
151			last = mat.end();
152		}
153
154		// 4) escape the trailing text after the last match
155		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	/// Creates a Telegram notifier from a trigger configuration
166	///
167	/// # Arguments
168	/// * `config` - Trigger configuration containing Telegram parameters
169	/// * `http_client` - HTTP client with middleware for retries
170	///
171	/// # Returns
172	/// * `Result<Self, NotificationError>` - Notifier instance if config is Telegram type
173	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			// Set up payload fields for the webhook
186			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	/// Sends a formatted message to Telegram
218	///
219	/// # Arguments
220	/// * `message` - The formatted message to send
221	///
222	/// # Returns
223	/// * `Result<(), NotificationError>` - Success or error
224	async fn notify(&self, message: &str) -> Result<(), NotificationError> {
225		// Get default payload fields
226		let mut payload_fields = self.inner.payload_fields.clone().unwrap_or_default();
227
228		// Add the dynamic fields for this specific notification.
229		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		// Send the notification using the inner Webhook notifier
236		self.inner
237			// TODO: The `message` parameter is required by the Notifier trait for generic 
238			// webhook signing, but it's duplicated in this specific payload
239			.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	////////////////////////////////////////////////////////////
280	// format_message tests
281	////////////////////////////////////////////////////////////
282
283	#[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		// status variable is not provided
302
303		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	////////////////////////////////////////////////////////////
320	// from_config tests
321	////////////////////////////////////////////////////////////
322
323	#[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, // Test default within TriggerTypeConfig
345			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	////////////////////////////////////////////////////////////
357	// notify tests
358	////////////////////////////////////////////////////////////
359
360	#[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		// Test for real life examples
385		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		// Test basic special character escaping
391		assert_eq!(
392			TelegramNotifier::escape_markdown_v2("Hello *world*!"),
393			"Hello *world*\\!"
394		);
395
396		// Test multiple special characters
397		assert_eq!(
398			TelegramNotifier::escape_markdown_v2("(test) [test] {test} <test>"),
399			"\\(test\\) \\[test\\] \\{test\\} <test\\>"
400		);
401
402		// Test markdown code blocks (should be preserved)
403		assert_eq!(
404			TelegramNotifier::escape_markdown_v2("```code block```"),
405			"```code block```"
406		);
407
408		// Test inline code (should be preserved)
409		assert_eq!(
410			TelegramNotifier::escape_markdown_v2("`inline code`"),
411			"`inline code`"
412		);
413
414		// Test bold text (should be preserved)
415		assert_eq!(
416			TelegramNotifier::escape_markdown_v2("*bold text*"),
417			"*bold text*"
418		);
419
420		// Test italic text (should be preserved)
421		assert_eq!(
422			TelegramNotifier::escape_markdown_v2("_italic text_"),
423			"_italic text_"
424		);
425
426		// Test strikethrough (should be preserved)
427		assert_eq!(
428			TelegramNotifier::escape_markdown_v2("~strikethrough~"),
429			"~strikethrough~"
430		);
431
432		// Test links with special characters
433		assert_eq!(
434			TelegramNotifier::escape_markdown_v2("[link](https://example.com/test.html)"),
435			"[link](https://example\\.com/test\\.html)"
436		);
437
438		// Test complex link with special characters in both label and URL
439		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		// Test mixed content
445		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		// Test escaping backslashes
453		assert_eq!(
454			TelegramNotifier::escape_markdown_v2("test\\test"),
455			"test\\\\test"
456		);
457
458		// Test all special characters
459		assert_eq!(
460			TelegramNotifier::escape_markdown_v2("_*[]()~`>#+-=|{}.!\\"),
461			"\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
462		);
463
464		// Test nested markdown (outer should be preserved, inner escaped)
465		assert_eq!(
466			TelegramNotifier::escape_markdown_v2("*bold with [link](http://test.com)*"),
467			"*bold with [link](http://test.com)*"
468		);
469
470		// Test empty string
471		assert_eq!(TelegramNotifier::escape_markdown_v2(""), "");
472
473		// Test string with only special characters
474		assert_eq!(
475			TelegramNotifier::escape_markdown_v2("***"),
476			"**\\*" // First * is preserved as markdown, others escaped
477		);
478	}
479}