1use async_trait::async_trait;
7
8use std::{collections::HashMap, sync::Arc};
9
10mod email;
11mod error;
12pub mod payload_builder;
13mod pool;
14mod script;
15mod template_formatter;
16mod webhook;
17
18use crate::{
19	models::{
20		MonitorMatch, NotificationMessage, ScriptLanguage, Trigger, TriggerType, TriggerTypeConfig,
21	},
22	utils::{normalize_string, RetryConfig},
23};
24
25pub use email::{EmailContent, EmailNotifier, SmtpConfig};
26pub use error::NotificationError;
27pub use payload_builder::{
28	DiscordPayloadBuilder, GenericWebhookPayloadBuilder, SlackPayloadBuilder,
29	TelegramPayloadBuilder, WebhookPayloadBuilder,
30};
31pub use pool::NotificationClientPool;
32pub use script::ScriptNotifier;
33pub use webhook::{WebhookConfig, WebhookNotifier};
34
35struct WebhookComponents {
37	config: WebhookConfig,
38	retry_policy: RetryConfig,
39	builder: Box<dyn WebhookPayloadBuilder>,
40}
41
42type WebhookParts = (
44	String,                          NotificationMessage,             Option<String>,                  Option<String>,                  Option<HashMap<String, String>>, Box<dyn WebhookPayloadBuilder>,  );
51
52trait AsWebhookComponents {
55	fn as_webhook_components(&self) -> Result<WebhookComponents, NotificationError>;
59}
60
61impl AsWebhookComponents for TriggerTypeConfig {
62	fn as_webhook_components(&self) -> Result<WebhookComponents, NotificationError> {
63		let (url, message, method, secret, headers, builder): WebhookParts = match self {
64			TriggerTypeConfig::Webhook {
65				url,
66				message,
67				method,
68				secret,
69				headers,
70				..
71			} => (
72				url.as_ref().to_string(),
73				message.clone(),
74				method.clone(),
75				secret.as_ref().map(|s| s.as_ref().to_string()),
76				headers.clone(),
77				Box::new(GenericWebhookPayloadBuilder),
78			),
79			TriggerTypeConfig::Discord {
80				discord_url,
81				message,
82				..
83			} => (
84				discord_url.as_ref().to_string(),
85				message.clone(),
86				Some("POST".to_string()),
87				None,
88				None,
89				Box::new(DiscordPayloadBuilder),
90			),
91			TriggerTypeConfig::Telegram {
92				token,
93				message,
94				chat_id,
95				disable_web_preview,
96				..
97			} => (
98				format!("https://api.telegram.org/bot{}/sendMessage", token.as_ref()),
99				message.clone(),
100				Some("POST".to_string()),
101				None,
102				None,
103				Box::new(TelegramPayloadBuilder {
104					chat_id: chat_id.clone(),
105					disable_web_preview: disable_web_preview.unwrap_or(false),
106				}),
107			),
108			TriggerTypeConfig::Slack {
109				slack_url, message, ..
110			} => (
111				slack_url.as_ref().to_string(),
112				message.clone(),
113				Some("POST".to_string()),
114				None,
115				None,
116				Box::new(SlackPayloadBuilder),
117			),
118			_ => {
119				return Err(NotificationError::config_error(
120					format!("Trigger type is not webhook-compatible: {:?}", self),
121					None,
122					None,
123				))
124			}
125		};
126
127		let config = WebhookConfig {
129			url,
130			title: message.title,
131			body_template: message.body,
132			method,
133			secret,
134			headers,
135			url_params: None,
136			payload_fields: None,
137		};
138
139		let retry_policy = self.get_retry_policy().ok_or_else(|| {
141			NotificationError::config_error(
142				"Webhook trigger config is unexpectedly missing a retry policy.",
143				None,
144				None,
145			)
146		})?;
147
148		Ok(WebhookComponents {
149			config,
150			retry_policy,
151			builder,
152		})
153	}
154}
155
156#[async_trait]
161pub trait ScriptExecutor {
162	async fn script_notify(
171		&self,
172		monitor_match: &MonitorMatch,
173		script_content: &(ScriptLanguage, String),
174	) -> Result<(), NotificationError>;
175}
176
177pub struct NotificationService {
179	client_pool: Arc<NotificationClientPool>,
181}
182
183impl NotificationService {
184	pub fn new() -> Self {
186		NotificationService {
187			client_pool: Arc::new(NotificationClientPool::new()),
188		}
189	}
190
191	pub async fn execute(
203		&self,
204		trigger: &Trigger,
205		variables: &HashMap<String, String>,
206		monitor_match: &MonitorMatch,
207		trigger_scripts: &HashMap<String, (ScriptLanguage, String)>,
208	) -> Result<(), NotificationError> {
209		match &trigger.trigger_type {
210			TriggerType::Slack
212			| TriggerType::Discord
213			| TriggerType::Webhook
214			| TriggerType::Telegram => {
215				let components = trigger.config.as_webhook_components()?;
217
218				let http_client = self
220					.client_pool
221					.get_or_create_http_client(&components.retry_policy)
222					.await
223					.map_err(|e| {
224						NotificationError::execution_error(
225							"Failed to get or create HTTP client from pool".to_string(),
226							Some(e.into()),
227							None,
228						)
229					})?;
230
231				let payload = components.builder.build_payload(
233					&components.config.title,
234					&components.config.body_template,
235					variables,
236				);
237
238				let notifier = WebhookNotifier::new(components.config, http_client)?;
240
241				notifier.notify_json(&payload).await?;
242			}
243			TriggerType::Email => {
244				let smtp_config = match &trigger.config {
246					TriggerTypeConfig::Email {
247						host,
248						port,
249						username,
250						password,
251						..
252					} => SmtpConfig {
253						host: host.clone(),
254						port: port.unwrap_or(465),
255						username: username.as_ref().to_string(),
256						password: password.as_ref().to_string(),
257					},
258					_ => {
259						return Err(NotificationError::config_error(
260							"Invalid email configuration".to_string(),
261							None,
262							None,
263						));
264					}
265				};
266
267				let smtp_client = self
269					.client_pool
270					.get_or_create_smtp_client(&smtp_config)
271					.await
272					.map_err(|e| {
273						NotificationError::execution_error(
274							"Failed to get SMTP client from pool".to_string(),
275							Some(e.into()),
276							None,
277						)
278					})?;
279
280				let notifier = EmailNotifier::from_config(&trigger.config, smtp_client)?;
281				let message = EmailNotifier::format_message(notifier.body_template(), variables);
282				notifier.notify(&message).await?;
283			}
284			TriggerType::Script => {
285				let notifier = ScriptNotifier::from_config(&trigger.config)?;
286				let monitor_name = match monitor_match {
287					MonitorMatch::EVM(evm_match) => &evm_match.monitor.name,
288					MonitorMatch::Stellar(stellar_match) => &stellar_match.monitor.name,
289					MonitorMatch::Midnight(midnight_match) => &midnight_match.monitor.name,
290				};
291				let script_path = match &trigger.config {
292					TriggerTypeConfig::Script { script_path, .. } => script_path,
293					_ => {
294						return Err(NotificationError::config_error(
295							"Invalid script configuration".to_string(),
296							None,
297							None,
298						));
299					}
300				};
301				let script = trigger_scripts
302					.get(&format!(
303						"{}|{}",
304						normalize_string(monitor_name),
305						script_path
306					))
307					.ok_or_else(|| {
308						NotificationError::config_error(
309							"Script content not found".to_string(),
310							None,
311							None,
312						)
313					});
314				let script_content = match &script {
315					Ok(content) => content,
316					Err(e) => {
317						return Err(NotificationError::config_error(e.to_string(), None, None));
318					}
319				};
320
321				notifier
322					.script_notify(monitor_match, script_content)
323					.await?;
324			}
325		}
326		Ok(())
327	}
328}
329
330impl Default for NotificationService {
331	fn default() -> Self {
332		Self::new()
333	}
334}
335
336#[cfg(test)]
337mod tests {
338	use super::*;
339	use crate::{
340		models::{
341			AddressWithSpec, EVMMonitorMatch, EVMTransactionReceipt, EventCondition,
342			FunctionCondition, MatchConditions, Monitor, MonitorMatch, NotificationMessage,
343			ScriptLanguage, SecretString, SecretValue, TransactionCondition, TriggerType,
344		},
345		utils::tests::{
346			builders::{evm::monitor::MonitorBuilder, trigger::TriggerBuilder},
347			evm::transaction::TransactionBuilder,
348		},
349	};
350	use std::collections::HashMap;
351
352	fn create_test_monitor(
353		event_conditions: Vec<EventCondition>,
354		function_conditions: Vec<FunctionCondition>,
355		transaction_conditions: Vec<TransactionCondition>,
356		addresses: Vec<AddressWithSpec>,
357	) -> Monitor {
358		let mut builder = MonitorBuilder::new()
359			.name("test")
360			.networks(vec!["evm_mainnet".to_string()]);
361
362		for event in event_conditions {
364			builder = builder.event(&event.signature, event.expression);
365		}
366		for function in function_conditions {
367			builder = builder.function(&function.signature, function.expression);
368		}
369		for transaction in transaction_conditions {
370			builder = builder.transaction(transaction.status, transaction.expression);
371		}
372
373		for addr in addresses {
375			builder = builder.address(&addr.address);
376		}
377
378		builder.build()
379	}
380
381	fn create_mock_monitor_match() -> MonitorMatch {
382		MonitorMatch::EVM(Box::new(EVMMonitorMatch {
383			monitor: create_test_monitor(vec![], vec![], vec![], vec![]),
384			transaction: TransactionBuilder::new().build(),
385			receipt: Some(EVMTransactionReceipt::default()),
386			logs: Some(vec![]),
387			network_slug: "evm_mainnet".to_string(),
388			matched_on: MatchConditions {
389				functions: vec![],
390				events: vec![],
391				transactions: vec![],
392			},
393			matched_on_args: None,
394		}))
395	}
396
397	#[tokio::test]
398	async fn test_slack_notification_invalid_config() {
399		let service = NotificationService::new();
400
401		let trigger = TriggerBuilder::new()
402			.name("test_slack")
403			.script("invalid", ScriptLanguage::Python)
404			.trigger_type(TriggerType::Slack) .build();
406
407		let variables = HashMap::new();
408		let result = service
409			.execute(
410				&trigger,
411				&variables,
412				&create_mock_monitor_match(),
413				&HashMap::new(),
414			)
415			.await;
416		assert!(result.is_err());
417		match result {
418			Err(NotificationError::ConfigError(ctx)) => {
419				assert!(ctx
420					.message
421					.contains("Trigger type is not webhook-compatible"));
422			}
423			_ => panic!("Expected ConfigError"),
424		}
425	}
426
427	#[tokio::test]
428	async fn test_email_notification_invalid_config() {
429		let service = NotificationService::new();
430
431		let trigger = TriggerBuilder::new()
432			.name("test_email")
433			.script("invalid", ScriptLanguage::Python)
434			.trigger_type(TriggerType::Email) .build();
436
437		let variables = HashMap::new();
438		let result = service
439			.execute(
440				&trigger,
441				&variables,
442				&create_mock_monitor_match(),
443				&HashMap::new(),
444			)
445			.await;
446		assert!(result.is_err());
447		match result {
448			Err(NotificationError::ConfigError(ctx)) => {
449				assert!(ctx.message.contains("Invalid email configuration"));
450			}
451			_ => panic!("Expected ConfigError"),
452		}
453	}
454
455	#[tokio::test]
456	async fn test_webhook_notification_invalid_config() {
457		let service = NotificationService::new();
458
459		let trigger = TriggerBuilder::new()
460			.name("test_webhook")
461			.script("invalid", ScriptLanguage::Python)
462			.trigger_type(TriggerType::Webhook) .build();
464
465		let variables = HashMap::new();
466		let result = service
467			.execute(
468				&trigger,
469				&variables,
470				&create_mock_monitor_match(),
471				&HashMap::new(),
472			)
473			.await;
474		assert!(result.is_err());
475		match result {
476			Err(NotificationError::ConfigError(ctx)) => {
477				assert!(ctx
478					.message
479					.contains("Trigger type is not webhook-compatible"));
480			}
481			_ => panic!("Expected ConfigError"),
482		}
483	}
484
485	#[tokio::test]
486	async fn test_discord_notification_invalid_config() {
487		let service = NotificationService::new();
488
489		let trigger = TriggerBuilder::new()
490			.name("test_discord")
491			.script("invalid", ScriptLanguage::Python)
492			.trigger_type(TriggerType::Discord) .build();
494
495		let variables = HashMap::new();
496		let result = service
497			.execute(
498				&trigger,
499				&variables,
500				&create_mock_monitor_match(),
501				&HashMap::new(),
502			)
503			.await;
504		assert!(result.is_err());
505		match result {
506			Err(NotificationError::ConfigError(ctx)) => {
507				assert!(ctx
508					.message
509					.contains("Trigger type is not webhook-compatible"));
510			}
511			_ => panic!("Expected ConfigError"),
512		}
513	}
514
515	#[tokio::test]
516	async fn test_telegram_notification_invalid_config() {
517		let service = NotificationService::new();
518
519		let trigger = TriggerBuilder::new()
520			.name("test_telegram")
521			.script("invalid", ScriptLanguage::Python)
522			.trigger_type(TriggerType::Telegram) .build();
524
525		let variables = HashMap::new();
526		let result = service
527			.execute(
528				&trigger,
529				&variables,
530				&create_mock_monitor_match(),
531				&HashMap::new(),
532			)
533			.await;
534		assert!(result.is_err());
535		match result {
536			Err(NotificationError::ConfigError(ctx)) => {
537				assert!(ctx
538					.message
539					.contains("Trigger type is not webhook-compatible"));
540			}
541			_ => panic!("Expected ConfigError"),
542		}
543	}
544
545	#[tokio::test]
546	async fn test_script_notification_invalid_config() {
547		let service = NotificationService::new();
548
549		let trigger = TriggerBuilder::new()
550			.name("test_script")
551			.telegram("invalid", "invalid", false)
552			.trigger_type(TriggerType::Script) .build();
554
555		let variables = HashMap::new();
556
557		let result = service
558			.execute(
559				&trigger,
560				&variables,
561				&create_mock_monitor_match(),
562				&HashMap::new(),
563			)
564			.await;
565
566		assert!(result.is_err());
567		match result {
568			Err(NotificationError::ConfigError(ctx)) => {
569				assert!(ctx.message.contains("Invalid script configuration"));
570			}
571			_ => panic!("Expected ConfigError"),
572		}
573	}
574
575	#[test]
576	fn as_webhook_components_trait_for_slack_config() {
577		let title = "Slack Title";
578		let message = "Slack Body";
579
580		let slack_config = TriggerTypeConfig::Slack {
581			slack_url: SecretValue::Plain(SecretString::new(
582				"https://slack.example.com".to_string(),
583			)),
584			message: NotificationMessage {
585				title: title.to_string(),
586				body: message.to_string(),
587			},
588			retry_policy: RetryConfig::default(),
589		};
590
591		let components = slack_config.as_webhook_components().unwrap();
592
593		assert_eq!(components.config.url, "https://slack.example.com");
595		assert_eq!(components.config.title, title);
596		assert_eq!(components.config.body_template, message);
597		assert_eq!(components.config.method, Some("POST".to_string()));
598		assert!(components.config.secret.is_none());
599
600		let payload = components
602			.builder
603			.build_payload(title, message, &HashMap::new());
604		assert!(
605			payload.get("blocks").is_some(),
606			"Expected a Slack payload with 'blocks'"
607		);
608		assert!(
609			payload.get("content").is_none(),
610			"Did not expect a Discord payload"
611		);
612	}
613
614	#[test]
615	fn as_webhook_components_trait_for_discord_config() {
616		let title = "Discord Title";
617		let message = "Discord Body";
618		let discord_config = TriggerTypeConfig::Discord {
619			discord_url: SecretValue::Plain(SecretString::new(
620				"https://discord.example.com".to_string(),
621			)),
622			message: NotificationMessage {
623				title: title.to_string(),
624				body: message.to_string(),
625			},
626			retry_policy: RetryConfig::default(),
627		};
628
629		let components = discord_config.as_webhook_components().unwrap();
630
631		assert_eq!(components.config.url, "https://discord.example.com");
633		assert_eq!(components.config.title, title);
634		assert_eq!(components.config.body_template, message);
635		assert_eq!(components.config.method, Some("POST".to_string()));
636
637		let payload = components
639			.builder
640			.build_payload(title, message, &HashMap::new());
641		assert!(
642			payload.get("content").is_some(),
643			"Expected a Discord payload with 'content'"
644		);
645		assert!(
646			payload.get("blocks").is_none(),
647			"Did not expect a Slack payload"
648		);
649	}
650
651	#[test]
652	fn as_webhook_components_trait_for_telegram_config() {
653		let title = "Telegram Title";
654		let message = "Telegram Body";
655		let telegram_config = TriggerTypeConfig::Telegram {
656			token: SecretValue::Plain(SecretString::new("test-token".to_string())),
657			chat_id: "12345".to_string(),
658			disable_web_preview: Some(true),
659			message: NotificationMessage {
660				title: title.to_string(),
661				body: message.to_string(),
662			},
663			retry_policy: RetryConfig::default(),
664		};
665
666		let components = telegram_config.as_webhook_components().unwrap();
667
668		assert_eq!(
670			components.config.url,
671			"https://api.telegram.org/bottest-token/sendMessage"
672		);
673		assert_eq!(components.config.title, title);
674		assert_eq!(components.config.body_template, message);
675
676		let payload = components
678			.builder
679			.build_payload(title, message, &HashMap::new());
680		assert_eq!(payload.get("chat_id").unwrap(), "12345");
681		assert_eq!(payload.get("disable_web_page_preview").unwrap(), &true);
682		assert!(payload.get("text").is_some());
683	}
684
685	#[test]
686	fn as_webhook_components_trait_for_generic_webhook_config() {
687		let title = "Generic Title";
688		let body_template = "Generic Body";
689		let webhook_config = TriggerTypeConfig::Webhook {
690			url: SecretValue::Plain(SecretString::new("https://generic.example.com".to_string())),
691			message: NotificationMessage {
692				title: title.to_string(),
693				body: body_template.to_string(),
694			},
695			method: Some("PUT".to_string()),
696			secret: Some(SecretValue::Plain(SecretString::new(
697				"my-secret".to_string(),
698			))),
699			headers: Some([("X-Custom".to_string(), "Value".to_string())].into()),
700			retry_policy: RetryConfig::default(),
701		};
702
703		let components = webhook_config.as_webhook_components().unwrap();
704
705		assert_eq!(components.config.url, "https://generic.example.com");
707		assert_eq!(components.config.method, Some("PUT".to_string()));
708		assert_eq!(components.config.secret, Some("my-secret".to_string()));
709		assert!(components.config.headers.is_some());
710		assert_eq!(
711			components.config.headers.unwrap().get("X-Custom").unwrap(),
712			"Value"
713		);
714
715		let payload = components
717			.builder
718			.build_payload(title, body_template, &HashMap::new());
719		assert!(payload.get("title").is_some());
720		assert!(payload.get("body").is_some());
721	}
722}