openzeppelin_monitor/services/notification/
slack.rs1use 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#[derive(Debug)]
17pub struct SlackNotifier {
18 inner: WebhookNotifier,
19}
20
21impl SlackNotifier {
22 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 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 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 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 #[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 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 #[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 #[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}