openzeppelin_monitor/services/notification/
script.rs

1use async_trait::async_trait;
2
3use crate::{
4	models::{MonitorMatch, ScriptLanguage, TriggerTypeConfig},
5	services::notification::{NotificationError, ScriptExecutor},
6	services::trigger::ScriptExecutorFactory,
7};
8
9/// A notification handler that executes scripts when triggered
10///
11/// This notifier takes a script configuration and executes the specified script
12/// when a monitor match occurs. It supports different script languages and
13/// allows passing arguments and setting timeouts for script execution.
14#[derive(Debug)]
15pub struct ScriptNotifier {
16	config: TriggerTypeConfig,
17}
18
19impl ScriptNotifier {
20	/// Creates a Script notifier from a trigger configuration
21	pub fn from_config(config: &TriggerTypeConfig) -> Result<Self, NotificationError> {
22		if let TriggerTypeConfig::Script { .. } = config {
23			Ok(Self {
24				config: config.clone(),
25			})
26		} else {
27			let msg = format!("Invalid script configuration: {:?}", config);
28			Err(NotificationError::config_error(msg, None, None))
29		}
30	}
31}
32
33#[async_trait]
34impl ScriptExecutor for ScriptNotifier {
35	/// Implement the actual script notification logic
36	async fn script_notify(
37		&self,
38		monitor_match: &MonitorMatch,
39		script_content: &(ScriptLanguage, String),
40	) -> Result<(), NotificationError> {
41		match &self.config {
42			TriggerTypeConfig::Script {
43				script_path: _,
44				language,
45				arguments,
46				timeout_ms,
47			} => {
48				let executor = ScriptExecutorFactory::create(language, &script_content.1);
49
50				let result = executor
51					.execute(
52						monitor_match.clone(),
53						timeout_ms,
54						arguments.as_deref(),
55						true,
56					)
57					.await;
58
59				match result {
60					Ok(true) => Ok(()),
61					Ok(false) => Err(NotificationError::execution_error(
62						"Trigger script execution failed",
63						None,
64						None,
65					)),
66					Err(e) => {
67						return Err(NotificationError::execution_error(
68							format!("Trigger script execution error: {}", e),
69							Some(e.into()),
70							None,
71						));
72					}
73				}
74			}
75			_ => Err(NotificationError::config_error(
76				"Invalid configuration type for ScriptNotifier",
77				None,
78				None,
79			)),
80		}
81	}
82}
83
84#[cfg(test)]
85mod tests {
86	use super::*;
87	use crate::{
88		models::{
89			EVMMonitorMatch, EVMTransactionReceipt, MatchConditions, Monitor, MonitorMatch,
90			NotificationMessage, SecretString, SecretValue, TriggerType,
91		},
92		services::notification::NotificationService,
93		utils::tests::{
94			builders::evm::monitor::MonitorBuilder, evm::transaction::TransactionBuilder,
95			trigger::TriggerBuilder,
96		},
97	};
98	use std::{collections::HashMap, time::Instant};
99
100	fn create_test_script_config() -> TriggerTypeConfig {
101		TriggerTypeConfig::Script {
102			language: ScriptLanguage::Python,
103			script_path: "test_script.py".to_string(),
104			arguments: Some(vec!["arg1".to_string(), "arg2".to_string()]),
105			timeout_ms: 1000,
106		}
107	}
108
109	fn create_test_monitor(
110		name: &str,
111		networks: Vec<&str>,
112		paused: bool,
113		triggers: Vec<&str>,
114	) -> Monitor {
115		MonitorBuilder::new()
116			.name(name)
117			.networks(networks.into_iter().map(|s| s.to_string()).collect())
118			.paused(paused)
119			.triggers(triggers.into_iter().map(|s| s.to_string()).collect())
120			.build()
121	}
122
123	fn create_test_monitor_match() -> MonitorMatch {
124		MonitorMatch::EVM(Box::new(EVMMonitorMatch {
125			monitor: create_test_monitor("test_monitor", vec!["ethereum_mainnet"], false, vec![]),
126			transaction: TransactionBuilder::new().build(),
127			receipt: Some(EVMTransactionReceipt::default()),
128			logs: Some(vec![]),
129			network_slug: "ethereum_mainnet".to_string(),
130			matched_on: MatchConditions::default(),
131			matched_on_args: None,
132		}))
133	}
134
135	#[test]
136	fn test_from_config_with_script_config() {
137		let config = create_test_script_config();
138		let notifier = ScriptNotifier::from_config(&config);
139		assert!(notifier.is_ok());
140	}
141
142	#[test]
143	fn test_from_config_invalid_type() {
144		// Create a config that is not a script type
145		let config = TriggerTypeConfig::Slack {
146			slack_url: SecretValue::Plain(SecretString::new("random.url".to_string())),
147			message: NotificationMessage {
148				title: "Test Slack".to_string(),
149				body: "This is a test message".to_string(),
150			},
151			retry_policy: Default::default(),
152		};
153
154		let notifier = ScriptNotifier::from_config(&config);
155		assert!(notifier.is_err());
156
157		let error = notifier.unwrap_err();
158		assert!(matches!(error, NotificationError::ConfigError { .. }));
159	}
160
161	#[tokio::test]
162	async fn test_script_notify_with_valid_script() {
163		let config = create_test_script_config();
164		let notifier = ScriptNotifier::from_config(&config).unwrap();
165		let monitor_match = create_test_monitor_match();
166		let script_content = (ScriptLanguage::Python, "print(True)".to_string());
167
168		let result = notifier
169			.script_notify(&monitor_match, &script_content)
170			.await;
171		assert!(result.is_ok());
172	}
173
174	#[tokio::test]
175	async fn test_script_notify_succeeds_within_timeout() {
176		let config = TriggerTypeConfig::Script {
177			language: ScriptLanguage::Python,
178			script_path: "test_script.py".to_string(),
179			arguments: None,
180			timeout_ms: 1000, // Timeout longer than sleep time
181		};
182		let notifier = ScriptNotifier::from_config(&config).unwrap();
183		let monitor_match = create_test_monitor_match();
184
185		let script_content = (
186			ScriptLanguage::Python,
187			"import time\ntime.sleep(0.3)\nprint(True)".to_string(),
188		);
189
190		let start_time = Instant::now();
191		let result = notifier
192			.script_notify(&monitor_match, &script_content)
193			.await;
194		let elapsed = start_time.elapsed();
195
196		assert!(result.is_ok());
197		// Verify that execution took at least 300ms (the sleep time)
198		assert!(elapsed.as_millis() >= 300);
199		// Verify that execution took less than the timeout
200		assert!(elapsed.as_millis() < 1000);
201	}
202
203	#[tokio::test]
204	async fn test_script_notify_completes_before_timeout() {
205		let config = TriggerTypeConfig::Script {
206			language: ScriptLanguage::Python,
207			script_path: "test_script.py".to_string(),
208			arguments: None,
209			timeout_ms: 400, // Set timeout lower than the sleep time
210		};
211		let notifier = ScriptNotifier::from_config(&config).unwrap();
212		let monitor_match = create_test_monitor_match();
213
214		let script_content = (
215			ScriptLanguage::Python,
216			"import time\ntime.sleep(0.5)\nprint(True)".to_string(),
217		);
218		let start_time = Instant::now();
219		let result = notifier
220			.script_notify(&monitor_match, &script_content)
221			.await;
222		let elapsed = start_time.elapsed();
223
224		// The script should fail because it takes 500ms but timeout is 400ms
225		assert!(result.is_err());
226		assert!(result
227			.unwrap_err()
228			.to_string()
229			.contains("Script execution timed out"));
230		// Verify that it failed around the timeout time
231		assert!(elapsed.as_millis() >= 400 && elapsed.as_millis() < 600);
232	}
233
234	#[tokio::test]
235	async fn test_script_notify_with_invalid_script() {
236		let config = create_test_script_config();
237		let notifier = ScriptNotifier::from_config(&config).unwrap();
238		let monitor_match = create_test_monitor_match();
239		let script_content = (ScriptLanguage::Python, "invalid syntax".to_string());
240
241		let result = notifier
242			.script_notify(&monitor_match, &script_content)
243			.await;
244		assert!(result.is_err());
245
246		let error = result.unwrap_err();
247		assert!(matches!(error, NotificationError::ExecutionError { .. }));
248	}
249
250	#[tokio::test]
251	async fn test_script_notification_script_content_not_found() {
252		let service = NotificationService::new();
253		let script_config = TriggerTypeConfig::Script {
254			language: ScriptLanguage::Python,
255			script_path: "non_existent_script.py".to_string(), // This path won't be in the map
256			arguments: None,
257			timeout_ms: 1000,
258		};
259		let trigger = TriggerBuilder::new()
260        .name("test_script_missing")
261        .config(script_config) // Use the actual script config
262        .trigger_type(TriggerType::Script)
263        .build();
264
265		let variables = HashMap::new();
266		let monitor_match = create_test_monitor_match();
267		let trigger_scripts = HashMap::new(); // Empty map, so script won't be found
268
269		let result = service
270			.execute(&trigger, &variables, &monitor_match, &trigger_scripts)
271			.await;
272
273		assert!(result.is_err());
274		match result.unwrap_err() {
275			NotificationError::ConfigError(ctx) => {
276				assert!(ctx.message.contains("Script content not found"));
277			}
278			_ => panic!("Expected ConfigError for missing script content"),
279		}
280	}
281}