1use anyhow::{Context, Result};
9use serde::Serialize;
10use std::time::Duration;
11use tracing::{debug, error, info};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SlackMethod {
16 Webhook,
18 WebApi,
20 Disabled,
22}
23
24impl SlackMethod {
25 #[allow(clippy::should_implement_trait)]
27 pub fn from_str(s: &str) -> Self {
28 match s.to_lowercase().as_str() {
29 "webhook" | "incoming_webhook" => SlackMethod::Webhook,
30 "webapi" | "web_api" | "api" => SlackMethod::WebApi,
31 _ => SlackMethod::Disabled,
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
38pub struct SlackConfig {
39 pub method: SlackMethod,
41 pub webhook_url: Option<String>,
43 pub bot_token: Option<String>,
45 pub default_channel: Option<String>,
47}
48
49impl SlackConfig {
50 pub fn from_env() -> Self {
52 let method = std::env::var("SLACK_METHOD").unwrap_or_else(|_| "disabled".to_string());
53
54 Self {
55 method: SlackMethod::from_str(&method),
56 webhook_url: std::env::var("SLACK_WEBHOOK_URL").ok(),
57 bot_token: std::env::var("SLACK_BOT_TOKEN").ok(),
58 default_channel: std::env::var("SLACK_DEFAULT_CHANNEL")
59 .or_else(|_| std::env::var("SLACK_CHANNEL"))
60 .ok(),
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct SlackMessage {
68 pub channel: Option<String>,
70 pub text: String,
72 pub title: Option<String>,
74 pub fields: Vec<(String, String)>,
76}
77
78pub struct SlackService {
80 config: SlackConfig,
81 client: reqwest::Client,
82}
83
84impl SlackService {
85 pub fn new(config: SlackConfig) -> Self {
87 let client = reqwest::Client::builder()
88 .timeout(Duration::from_secs(10))
89 .build()
90 .expect("Failed to create HTTP client for Slack service");
91
92 Self { config, client }
93 }
94
95 pub fn from_env() -> Self {
97 Self::new(SlackConfig::from_env())
98 }
99
100 pub async fn send(&self, message: SlackMessage) -> Result<()> {
102 match &self.config.method {
103 SlackMethod::Webhook => self.send_via_webhook(message).await,
104 SlackMethod::WebApi => self.send_via_webapi(message).await,
105 SlackMethod::Disabled => {
106 info!("Slack disabled, would send: '{}'", message.text);
107 debug!("Slack message details: {:?}", message);
108 Ok(())
109 }
110 }
111 }
112
113 pub async fn send_to_multiple(
115 &self,
116 message: SlackMessage,
117 recipients: &[String],
118 ) -> Result<()> {
119 let mut errors = Vec::new();
120
121 for recipient in recipients {
122 let mut msg = message.clone();
123 msg.channel = Some(recipient.clone());
124
125 match self.send(msg).await {
126 Ok(()) => {
127 debug!("Slack message sent successfully to {}", recipient);
128 }
129 Err(e) => {
130 let error_msg = format!("Failed to send Slack message to {}: {}", recipient, e);
131 error!("{}", error_msg);
132 errors.push(error_msg);
133 }
134 }
135 }
136
137 if !errors.is_empty() {
138 anyhow::bail!(
139 "Failed to send Slack messages to some recipients: {}",
140 errors.join("; ")
141 );
142 }
143
144 Ok(())
145 }
146
147 async fn send_via_webhook(&self, message: SlackMessage) -> Result<()> {
149 let webhook_url = self
150 .config
151 .webhook_url
152 .as_ref()
153 .context("Slack webhook requires SLACK_WEBHOOK_URL environment variable")?;
154
155 #[derive(Serialize)]
156 struct SlackWebhookPayload {
157 text: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 channel: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 attachments: Option<Vec<SlackAttachment>>,
162 }
163
164 #[derive(Serialize)]
165 struct SlackAttachment {
166 #[serde(skip_serializing_if = "Option::is_none")]
167 title: Option<String>,
168 text: String,
169 color: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 fields: Option<Vec<SlackField>>,
172 }
173
174 #[derive(Serialize)]
175 struct SlackField {
176 title: String,
177 value: String,
178 short: bool,
179 }
180
181 let mut attachments = Vec::new();
182 let mut attachment = SlackAttachment {
183 title: message.title.clone(),
184 text: message.text.clone(),
185 color: "#36a64f".to_string(), fields: None,
187 };
188
189 if !message.fields.is_empty() {
190 attachment.fields = Some(
191 message
192 .fields
193 .iter()
194 .map(|(title, value)| SlackField {
195 title: title.clone(),
196 value: value.clone(),
197 short: true,
198 })
199 .collect(),
200 );
201 }
202
203 attachments.push(attachment);
204
205 let payload = SlackWebhookPayload {
206 text: message.text.clone(),
207 channel: message.channel.clone(),
208 attachments: Some(attachments),
209 };
210
211 let response = self
212 .client
213 .post(webhook_url)
214 .header("Content-Type", "application/json")
215 .json(&payload)
216 .send()
217 .await
218 .context("Failed to send Slack message via webhook")?;
219
220 let status = response.status();
221 if !status.is_success() {
222 let error_text = response.text().await.unwrap_or_default();
223 anyhow::bail!("Slack webhook error ({}): {}", status, error_text);
224 }
225
226 info!("Slack message sent via webhook");
227 Ok(())
228 }
229
230 async fn send_via_webapi(&self, message: SlackMessage) -> Result<()> {
232 let bot_token = self
233 .config
234 .bot_token
235 .as_ref()
236 .context("Slack Web API requires SLACK_BOT_TOKEN environment variable")?;
237
238 let channel = message.channel.as_ref().or(self.config.default_channel.as_ref()).context(
239 "Slack Web API requires channel (set SLACK_DEFAULT_CHANNEL or provide in message)",
240 )?;
241
242 #[derive(Serialize)]
243 struct SlackApiPayload {
244 channel: String,
245 text: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 blocks: Option<Vec<serde_json::Value>>,
248 }
249
250 let mut blocks = Vec::new();
252 if let Some(ref title) = message.title {
253 blocks.push(serde_json::json!({
254 "type": "header",
255 "text": {
256 "type": "plain_text",
257 "text": title
258 }
259 }));
260 }
261
262 blocks.push(serde_json::json!({
263 "type": "section",
264 "text": {
265 "type": "mrkdwn",
266 "text": message.text
267 }
268 }));
269
270 if !message.fields.is_empty() {
271 let fields: Vec<serde_json::Value> = message
272 .fields
273 .iter()
274 .map(|(title, value)| {
275 serde_json::json!({
276 "type": "mrkdwn",
277 "text": format!("*{}:*\n{}", title, value)
278 })
279 })
280 .collect();
281
282 blocks.push(serde_json::json!({
283 "type": "section",
284 "fields": fields
285 }));
286 }
287
288 let payload = SlackApiPayload {
289 channel: channel.clone(),
290 text: message.text.clone(),
291 blocks: if blocks.is_empty() {
292 None
293 } else {
294 Some(blocks)
295 },
296 };
297
298 let response = self
299 .client
300 .post("https://slack.com/api/chat.postMessage")
301 .header("Authorization", format!("Bearer {}", bot_token))
302 .header("Content-Type", "application/json")
303 .json(&payload)
304 .send()
305 .await
306 .context("Failed to send Slack message via Web API")?;
307
308 let status = response.status();
309 if !status.is_success() {
310 let error_text = response.text().await.unwrap_or_default();
311 anyhow::bail!("Slack Web API error ({}): {}", status, error_text);
312 }
313
314 let api_response: serde_json::Value =
316 response.json().await.context("Failed to parse Slack API response")?;
317
318 if let Some(ok) = api_response.get("ok").and_then(|v| v.as_bool()) {
319 if !ok {
320 let error_msg =
321 api_response.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
322 anyhow::bail!("Slack API returned error: {}", error_msg);
323 }
324 }
325
326 info!("Slack message sent via Web API to channel {}", channel);
327 Ok(())
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_slack_method_from_str() {
337 assert_eq!(SlackMethod::from_str("webhook"), SlackMethod::Webhook);
338 assert_eq!(SlackMethod::from_str("incoming_webhook"), SlackMethod::Webhook);
339 assert_eq!(SlackMethod::from_str("webapi"), SlackMethod::WebApi);
340 assert_eq!(SlackMethod::from_str("web_api"), SlackMethod::WebApi);
341 assert_eq!(SlackMethod::from_str("api"), SlackMethod::WebApi);
342 assert_eq!(SlackMethod::from_str("disabled"), SlackMethod::Disabled);
343 assert_eq!(SlackMethod::from_str("unknown"), SlackMethod::Disabled);
344 }
345
346 #[tokio::test]
347 async fn test_slack_service_disabled() {
348 let config = SlackConfig {
349 method: SlackMethod::Disabled,
350 webhook_url: None,
351 bot_token: None,
352 default_channel: None,
353 };
354
355 let service = SlackService::new(config);
356 let message = SlackMessage {
357 channel: None,
358 text: "Test message".to_string(),
359 title: None,
360 fields: Vec::new(),
361 };
362
363 let result = service.send(message).await;
365 assert!(result.is_ok());
366 }
367}