openzeppelin_monitor/services/notification/
discord.rs

1//! Discord notification implementation.
2//!
3//! Provides functionality to send formatted messages to Discord channels
4//! via incoming webhooks, supporting message templates with variable substitution.
5
6use 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/// Implementation of Discord notifications via webhooks
18#[derive(Debug)]
19pub struct DiscordNotifier {
20	inner: WebhookNotifier,
21}
22
23/// Represents a field in a Discord embed message
24#[derive(Serialize)]
25struct DiscordField {
26	/// The name of the field (max 256 characters)
27	name: String,
28	/// The value of the field (max 1024 characters)
29	value: String,
30	/// Indicates whether the field should be displayed inline with other fields (optional)
31	inline: Option<bool>,
32}
33
34/// Represents an embed message in Discord
35#[derive(Serialize)]
36struct DiscordEmbed {
37	/// The title of the embed (max 256 characters)
38	title: String,
39	/// The description of the embed (max 4096 characters)
40	description: Option<String>,
41	/// A URL that the title links to (optional)
42	url: Option<String>,
43	/// The color of the embed represented as a hexadecimal integer (optional)
44	color: Option<u32>,
45	/// A list of fields included in the embed (max 25 fields, optional)
46	fields: Option<Vec<DiscordField>>,
47	/// Indicates whether text-to-speech is enabled for the embed (optional)
48	tts: Option<bool>,
49	/// A thumbnail image for the embed (optional)
50	thumbnail: Option<String>,
51	/// An image for the embed (optional)
52	image: Option<String>,
53	/// Footer information for the embed (max 2048 characters, optional)
54	footer: Option<String>,
55	/// Author information for the embed (max 256 characters, optional)
56	author: Option<String>,
57	/// A timestamp for the embed (optional)
58	timestamp: Option<String>,
59}
60
61/// Represents a formatted Discord message
62#[derive(Serialize)]
63struct DiscordMessage {
64	/// The content of the message
65	content: String,
66	/// The username to display as the sender of the message (optional)
67	username: Option<String>,
68	/// The avatar URL to display for the sender (optional)
69	avatar_url: Option<String>,
70	/// A list of embeds included in the message (max 10 embeds, optional)
71	embeds: Option<Vec<DiscordEmbed>>,
72}
73
74impl DiscordNotifier {
75	/// Creates a new Discord notifier instance
76	///
77	/// # Arguments
78	/// * `url` - Discord webhook URL
79	/// * `title` - Message title
80	/// * `body_template` - Message template with variables
81	/// * `http_client` - HTTP client with middleware for retries
82	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	/// Formats a message by substituting variables in the template
105	///
106	/// # Arguments
107	/// * `variables` - Map of variable names to values
108	///
109	/// # Returns
110	/// * `String` - Formatted message with variables replaced
111	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	/// Creates a Discord notifier from a trigger configuration
117	///
118	/// # Arguments
119	/// * `config` - Trigger configuration containing Discord parameters
120	/// * `http_client` - HTTP client with middleware for retries
121	///
122	/// # Returns
123	/// * `Result<Self, NotificationError>` - Notifier instance if config is Discord type
124	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	/// Sends a formatted message to Discord
158	///
159	/// # Arguments
160	/// * `message` - The formatted message to send
161	///
162	/// # Returns
163	/// * `Result<(), NotificationError>` - Success or error
164	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	////////////////////////////////////////////////////////////
217	// format_message tests
218	////////////////////////////////////////////////////////////
219
220	#[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		// status variable is not provided
239
240		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	////////////////////////////////////////////////////////////
254	// from_config tests
255	////////////////////////////////////////////////////////////
256
257	#[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	////////////////////////////////////////////////////////////
271	// notify tests
272	////////////////////////////////////////////////////////////
273
274	#[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}