openzeppelin_monitor/services/notification/
slack.rs

1//! Slack notification implementation.
2//!
3//! Provides functionality to send formatted messages to Slack channels
4//! via incoming webhooks, supporting message templates with variable substitution.
5
6use async_trait::async_trait;
7use reqwest_middleware::ClientWithMiddleware;
8use std::{collections::HashMap, sync::Arc};
9
10use crate::{
11	models::TriggerTypeConfig,
12	services::notification::{NotificationError, Notifier, WebhookConfig, WebhookNotifier},
13};
14
15/// Implementation of Slack notifications via webhooks
16#[derive(Debug)]
17pub struct SlackNotifier {
18	inner: WebhookNotifier,
19}
20
21impl SlackNotifier {
22	/// Creates a new Slack notifier instance
23	///
24	/// # Arguments
25	/// * `url` - Slack webhook URL
26	/// * `title` - Message title
27	/// * `body_template` - Message template with variables
28	/// * `http_client` - HTTP client with middleware for retries
29	pub fn new(
30		url: String,
31		title: String,
32		body_template: String,
33		http_client: Arc<ClientWithMiddleware>,
34	) -> Result<Self, NotificationError> {
35		let config = WebhookConfig {
36			url,
37			url_params: None,
38			title,
39			body_template,
40			method: Some("POST".to_string()),
41			secret: None,
42			headers: None,
43			payload_fields: None,
44		};
45
46		Ok(Self {
47			inner: WebhookNotifier::new(config, http_client)?,
48		})
49	}
50
51	/// Formats a message by substituting variables in the template
52	///
53	/// # Arguments
54	/// * `variables` - Map of variable names to values
55	///
56	/// # Returns
57	/// * `String` - Formatted message with variables replaced
58	pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
59		let message = self.inner.format_message(variables);
60		format!("*{}*\n\n{}", self.inner.title, message)
61	}
62
63	/// Creates a Slack notifier from a trigger configuration
64	///
65	/// # Arguments
66	/// * `config` - Trigger configuration containing Slack parameters
67	/// * `http_client` - HTTP client with middleware for retries
68	///
69	/// # Returns
70	/// * `Result<Self, NotificationError>` - Notifier instance if config is Slack type
71	pub fn from_config(
72		config: &TriggerTypeConfig,
73		http_client: Arc<ClientWithMiddleware>,
74	) -> Result<Self, NotificationError> {
75		if let TriggerTypeConfig::Slack {
76			slack_url, message, ..
77		} = config
78		{
79			let webhook_config = WebhookConfig {
80				url: slack_url.as_ref().to_string(),
81				url_params: None,
82				title: message.title.clone(),
83				body_template: message.body.clone(),
84				method: Some("POST".to_string()),
85				secret: None,
86				headers: None,
87				payload_fields: None,
88			};
89
90			Ok(Self {
91				inner: WebhookNotifier::new(webhook_config, http_client)?,
92			})
93		} else {
94			Err(NotificationError::config_error(
95				format!("Invalid slack configuration: {:?}", config),
96				None,
97				None,
98			))
99		}
100	}
101}
102
103#[async_trait]
104impl Notifier for SlackNotifier {
105	/// Sends a formatted message to Slack
106	///
107	/// # Arguments
108	/// * `message` - The formatted message to send
109	///
110	/// # Returns
111	/// * `Result<(), NotificationError>` - Success or error
112	async fn notify(&self, message: &str) -> Result<(), NotificationError> {
113		let mut payload_fields = HashMap::new();
114		let blocks = serde_json::json!([
115			{
116				"type": "section",
117				"text": {
118					"type": "mrkdwn",
119					"text": message
120				}
121			}
122		]);
123		payload_fields.insert("blocks".to_string(), blocks);
124
125		self.inner
126			.notify_with_payload(message, payload_fields)
127			.await
128	}
129}
130
131#[cfg(test)]
132mod tests {
133	use crate::{
134		models::{NotificationMessage, SecretString, SecretValue},
135		utils::{tests::create_test_http_client, HttpRetryConfig},
136	};
137
138	use super::*;
139
140	fn create_test_notifier(body_template: &str) -> SlackNotifier {
141		SlackNotifier::new(
142			"https://non-existent-url-slack-webhook.com".to_string(),
143			"Alert".to_string(),
144			body_template.to_string(),
145			create_test_http_client(),
146		)
147		.unwrap()
148	}
149
150	fn create_test_slack_config() -> TriggerTypeConfig {
151		TriggerTypeConfig::Slack {
152			slack_url: SecretValue::Plain(SecretString::new(
153				"https://slack.example.com".to_string(),
154			)),
155			message: NotificationMessage {
156				title: "Test Alert".to_string(),
157				body: "Test message ${value}".to_string(),
158			},
159			retry_policy: HttpRetryConfig::default(),
160		}
161	}
162
163	////////////////////////////////////////////////////////////
164	// format_message tests
165	////////////////////////////////////////////////////////////
166
167	#[test]
168	fn test_format_message() {
169		let notifier = create_test_notifier("Value is ${value} and status is ${status}");
170
171		let mut variables = HashMap::new();
172		variables.insert("value".to_string(), "100".to_string());
173		variables.insert("status".to_string(), "critical".to_string());
174
175		let result = notifier.format_message(&variables);
176		assert_eq!(result, "*Alert*\n\nValue is 100 and status is critical");
177	}
178
179	#[test]
180	fn test_format_message_with_missing_variables() {
181		let notifier = create_test_notifier("Value is ${value} and status is ${status}");
182
183		let mut variables = HashMap::new();
184		variables.insert("value".to_string(), "100".to_string());
185		// status variable is not provided
186
187		let result = notifier.format_message(&variables);
188		assert_eq!(result, "*Alert*\n\nValue is 100 and status is ${status}");
189	}
190
191	#[test]
192	fn test_format_message_with_empty_template() {
193		let notifier = create_test_notifier("");
194
195		let variables = HashMap::new();
196		let result = notifier.format_message(&variables);
197		assert_eq!(result, "*Alert*\n\n");
198	}
199
200	////////////////////////////////////////////////////////////
201	// from_config tests
202	////////////////////////////////////////////////////////////
203
204	#[test]
205	fn test_from_config_with_slack_config() {
206		let config = create_test_slack_config();
207		let http_client = create_test_http_client();
208		let notifier = SlackNotifier::from_config(&config, http_client);
209		assert!(notifier.is_ok());
210
211		let notifier = notifier.unwrap();
212		assert_eq!(notifier.inner.url, "https://slack.example.com");
213		assert_eq!(notifier.inner.title, "Test Alert");
214		assert_eq!(notifier.inner.body_template, "Test message ${value}");
215	}
216
217	////////////////////////////////////////////////////////////
218	// notify tests
219	////////////////////////////////////////////////////////////
220
221	#[tokio::test]
222	async fn test_notify_failure() {
223		let notifier = create_test_notifier("Test message");
224		let result = notifier.notify("Test message").await;
225		assert!(result.is_err());
226
227		let error = result.unwrap_err();
228		assert!(matches!(error, NotificationError::NotifyFailed { .. }));
229	}
230
231	#[tokio::test]
232	async fn test_notify_with_payload_failure() {
233		let notifier = create_test_notifier("Test message");
234		let result = notifier
235			.notify_with_payload("Test message", HashMap::new())
236			.await;
237		assert!(result.is_err());
238
239		let error = result.unwrap_err();
240		assert!(matches!(error, NotificationError::NotifyFailed { .. }));
241	}
242}