openzeppelin_monitor/services/notification/
discord.rs1use async_trait::async_trait;
7use reqwest_middleware::ClientWithMiddleware;
8use serde::Serialize;
9use serde_json;
10use std::{collections::HashMap, sync::Arc};
11
12use crate::{
13 models::TriggerTypeConfig,
14 services::notification::{NotificationError, Notifier, WebhookConfig, WebhookNotifier},
15};
16
17#[derive(Debug)]
19pub struct DiscordNotifier {
20 inner: WebhookNotifier,
21}
22
23#[derive(Serialize)]
25struct DiscordField {
26 name: String,
28 value: String,
30 inline: Option<bool>,
32}
33
34#[derive(Serialize)]
36struct DiscordEmbed {
37 title: String,
39 description: Option<String>,
41 url: Option<String>,
43 color: Option<u32>,
45 fields: Option<Vec<DiscordField>>,
47 tts: Option<bool>,
49 thumbnail: Option<String>,
51 image: Option<String>,
53 footer: Option<String>,
55 author: Option<String>,
57 timestamp: Option<String>,
59}
60
61#[derive(Serialize)]
63struct DiscordMessage {
64 content: String,
66 username: Option<String>,
68 avatar_url: Option<String>,
70 embeds: Option<Vec<DiscordEmbed>>,
72}
73
74impl DiscordNotifier {
75 pub fn new(
83 url: String,
84 title: String,
85 body_template: String,
86 http_client: Arc<ClientWithMiddleware>,
87 ) -> Result<Self, NotificationError> {
88 let config = WebhookConfig {
89 url,
90 url_params: None,
91 title,
92 body_template,
93 method: Some("POST".to_string()),
94 secret: None,
95 headers: None,
96 payload_fields: None,
97 };
98
99 Ok(Self {
100 inner: WebhookNotifier::new(config, http_client)?,
101 })
102 }
103
104 pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
112 let message = self.inner.format_message(variables);
113 format!("*{}*\n\n{}", self.inner.title, message)
114 }
115
116 pub fn from_config(
125 config: &TriggerTypeConfig,
126 http_client: Arc<ClientWithMiddleware>,
127 ) -> Result<Self, NotificationError> {
128 if let TriggerTypeConfig::Discord {
129 discord_url,
130 message,
131 ..
132 } = config
133 {
134 let webhook_config = WebhookConfig {
135 url: discord_url.as_ref().to_string(),
136 url_params: None,
137 title: message.title.clone(),
138 body_template: message.body.clone(),
139 method: Some("POST".to_string()),
140 secret: None,
141 headers: None,
142 payload_fields: None,
143 };
144
145 Ok(Self {
146 inner: WebhookNotifier::new(webhook_config, http_client)?,
147 })
148 } else {
149 let msg = format!("Invalid discord configuration: {:?}", config);
150 Err(NotificationError::config_error(msg, None, None))
151 }
152 }
153}
154
155#[async_trait]
156impl Notifier for DiscordNotifier {
157 async fn notify(&self, message: &str) -> Result<(), NotificationError> {
165 let mut payload_fields = HashMap::new();
166 let discord_message = DiscordMessage {
167 content: message.to_string(),
168 username: None,
169 avatar_url: None,
170 embeds: None,
171 };
172
173 payload_fields.insert(
174 "content".to_string(),
175 serde_json::json!(discord_message.content),
176 );
177
178 self.inner
179 .notify_with_payload(message, payload_fields)
180 .await
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use crate::{
187 models::{NotificationMessage, SecretString, SecretValue},
188 utils::{tests::create_test_http_client, HttpRetryConfig},
189 };
190
191 use super::*;
192
193 fn create_test_notifier(body_template: &str) -> DiscordNotifier {
194 DiscordNotifier::new(
195 "https://non-existent-url-discord-webhook.com".to_string(),
196 "Alert".to_string(),
197 body_template.to_string(),
198 create_test_http_client(),
199 )
200 .unwrap()
201 }
202
203 fn create_test_discord_config() -> TriggerTypeConfig {
204 TriggerTypeConfig::Discord {
205 discord_url: SecretValue::Plain(SecretString::new(
206 "https://discord.example.com".to_string(),
207 )),
208 message: NotificationMessage {
209 title: "Test Alert".to_string(),
210 body: "Test message ${value}".to_string(),
211 },
212 retry_policy: HttpRetryConfig::default(),
213 }
214 }
215
216 #[test]
221 fn test_format_message() {
222 let notifier = create_test_notifier("Value is ${value} and status is ${status}");
223
224 let mut variables = HashMap::new();
225 variables.insert("value".to_string(), "100".to_string());
226 variables.insert("status".to_string(), "critical".to_string());
227
228 let result = notifier.format_message(&variables);
229 assert_eq!(result, "*Alert*\n\nValue is 100 and status is critical");
230 }
231
232 #[test]
233 fn test_format_message_with_missing_variables() {
234 let notifier = create_test_notifier("Value is ${value} and status is ${status}");
235
236 let mut variables = HashMap::new();
237 variables.insert("value".to_string(), "100".to_string());
238 let result = notifier.format_message(&variables);
241 assert_eq!(result, "*Alert*\n\nValue is 100 and status is ${status}");
242 }
243
244 #[test]
245 fn test_format_message_with_empty_template() {
246 let notifier = create_test_notifier("");
247
248 let variables = HashMap::new();
249 let result = notifier.format_message(&variables);
250 assert_eq!(result, "*Alert*\n\n");
251 }
252
253 #[test]
258 fn test_from_config_with_discord_config() {
259 let config = create_test_discord_config();
260 let http_client = create_test_http_client();
261 let notifier = DiscordNotifier::from_config(&config, http_client);
262 assert!(notifier.is_ok());
263
264 let notifier = notifier.unwrap();
265 assert_eq!(notifier.inner.url, "https://discord.example.com");
266 assert_eq!(notifier.inner.title, "Test Alert");
267 assert_eq!(notifier.inner.body_template, "Test message ${value}");
268 }
269
270 #[tokio::test]
275 async fn test_notify_failure() {
276 let notifier = create_test_notifier("Test message");
277 let result = notifier.notify("Test message").await;
278 assert!(result.is_err());
279
280 let error = result.unwrap_err();
281 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
282 }
283
284 #[tokio::test]
285 async fn test_notify_with_payload_failure() {
286 let notifier = create_test_notifier("Test message");
287 let result = notifier
288 .notify_with_payload("Test message", HashMap::new())
289 .await;
290 assert!(result.is_err());
291
292 let error = result.unwrap_err();
293 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
294 }
295}