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