1#![allow(dead_code)]
26
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct IntegrationResult {
36 pub status: String,
38 pub detail: Option<String>,
40 pub http_code: Option<u16>,
42}
43
44impl IntegrationResult {
45 fn sent(http_code: u16) -> Self {
46 Self {
47 status: "sent".to_string(),
48 detail: None,
49 http_code: Some(http_code),
50 }
51 }
52
53 fn disabled(message: impl Into<String>) -> Self {
54 Self {
55 status: "disabled".to_string(),
56 detail: Some(message.into()),
57 http_code: None,
58 }
59 }
60
61 fn error(detail: impl Into<String>) -> Self {
62 Self {
63 status: "error".to_string(),
64 detail: Some(detail.into()),
65 http_code: None,
66 }
67 }
68}
69
70fn client() -> Result<reqwest::Client, reqwest::Error> {
72 reqwest::Client::builder()
73 .timeout(std::time::Duration::from_secs(30))
74 .user_agent("RavenClaws/1.4.0")
75 .build()
76}
77
78async fn post_json(
80 client: &reqwest::Client,
81 url: &str,
82 payload: serde_json::Value,
83) -> IntegrationResult {
84 match client.post(url).json(&payload).send().await {
85 Ok(r) => IntegrationResult::sent(r.status().as_u16()),
86 Err(e) => IntegrationResult::error(e.to_string()),
87 }
88}
89
90pub async fn send_slack(text: &str) -> IntegrationResult {
96 let webhook_url = std::env::var("SLACK_WEBHOOK_URL").unwrap_or_default();
97 if webhook_url.is_empty() {
98 return IntegrationResult::disabled(
99 "Set SLACK_WEBHOOK_URL env to enable Slack notifications",
100 );
101 }
102 let client = match client() {
103 Ok(c) => c,
104 Err(e) => return IntegrationResult::error(e.to_string()),
105 };
106 let payload = serde_json::json!({ "text": text });
107 post_json(&client, &webhook_url, payload).await
108}
109
110pub async fn send_discord(content: &str) -> IntegrationResult {
116 let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").unwrap_or_default();
117 if webhook_url.is_empty() {
118 return IntegrationResult::disabled(
119 "Set DISCORD_WEBHOOK_URL env to enable Discord notifications",
120 );
121 }
122 let client = match client() {
123 Ok(c) => c,
124 Err(e) => return IntegrationResult::error(e.to_string()),
125 };
126 let payload = serde_json::json!({ "content": content });
127 post_json(&client, &webhook_url, payload).await
128}
129
130pub async fn send_teams(text: &str, title: &str) -> IntegrationResult {
136 let webhook_url = std::env::var("TEAMS_WEBHOOK_URL").unwrap_or_default();
137 if webhook_url.is_empty() {
138 return IntegrationResult::disabled(
139 "Set TEAMS_WEBHOOK_URL env to enable Teams notifications",
140 );
141 }
142 let client = match client() {
143 Ok(c) => c,
144 Err(e) => return IntegrationResult::error(e.to_string()),
145 };
146 let payload = serde_json::json!({
147 "@type": "MessageCard",
148 "@context": "http://schema.org/extensions",
149 "title": title,
150 "text": text,
151 });
152 post_json(&client, &webhook_url, payload).await
153}
154
155pub async fn send_signal(recipient: &str, message: &str) -> IntegrationResult {
161 let signald_url = std::env::var("SIGNALD_REST_URL").unwrap_or_default();
162 if signald_url.is_empty() {
163 return IntegrationResult::disabled(
164 "Set SIGNALD_REST_URL env to enable Signal notifications",
165 );
166 }
167 if recipient.is_empty() {
168 return IntegrationResult::error("recipient field required (phone number)");
169 }
170 let client = match client() {
171 Ok(c) => c,
172 Err(e) => return IntegrationResult::error(e.to_string()),
173 };
174 let payload = serde_json::json!({ "number": recipient, "message": message });
175 let url = format!("{}/v2/send", signald_url.trim_end_matches('/'));
176 post_json(&client, &url, payload).await
177}
178
179pub async fn send_matrix(room_id: &str, message: &str) -> IntegrationResult {
186 let homeserver = std::env::var("MATRIX_HOMESERVER").unwrap_or_default();
187 let access_token = std::env::var("MATRIX_ACCESS_TOKEN").unwrap_or_default();
188 let default_room = std::env::var("MATRIX_ROOM_ID").unwrap_or_default();
189
190 if homeserver.is_empty() || access_token.is_empty() {
191 return IntegrationResult::disabled(
192 "Set MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID env vars",
193 );
194 }
195 let room = if room_id.is_empty() {
196 &default_room
197 } else {
198 room_id
199 };
200 let url = format!(
201 "{}/_matrix/client/r0/rooms/{}/send/m.room.message",
202 homeserver.trim_end_matches('/'),
203 room
204 );
205 let payload = serde_json::json!({ "msgtype": "m.text", "body": message });
206
207 let client = match client() {
208 Ok(c) => c,
209 Err(e) => return IntegrationResult::error(e.to_string()),
210 };
211 match client
212 .post(&url)
213 .bearer_auth(&access_token)
214 .json(&payload)
215 .send()
216 .await
217 {
218 Ok(r) => IntegrationResult::sent(r.status().as_u16()),
219 Err(e) => IntegrationResult::error(e.to_string()),
220 }
221}
222
223pub async fn send_telegram(text: &str) -> IntegrationResult {
229 let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
230 let chat_id = std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default();
231 if token.is_empty() || chat_id.is_empty() {
232 return IntegrationResult::disabled(
233 "Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars to enable Telegram",
234 );
235 }
236 let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
237 let payload = serde_json::json!({ "chat_id": chat_id, "text": text });
238
239 let client = match client() {
240 Ok(c) => c,
241 Err(e) => return IntegrationResult::error(e.to_string()),
242 };
243 post_json(&client, &url, payload).await
244}
245
246pub async fn send_email(to: &str, subject: &str, body: &str) -> IntegrationResult {
252 let api_key = std::env::var("MAILGUN_API_KEY").unwrap_or_default();
253 let domain = std::env::var("MAILGUN_DOMAIN").unwrap_or_default();
254 if api_key.is_empty() || domain.is_empty() {
255 return IntegrationResult::disabled(
256 "Set MAILGUN_API_KEY and MAILGUN_DOMAIN env vars to enable email",
257 );
258 }
259 let url = format!("https://api.mailgun.net/v3/{}/messages", domain);
260 let client = match client() {
261 Ok(c) => c,
262 Err(e) => return IntegrationResult::error(e.to_string()),
263 };
264 match client
265 .post(&url)
266 .basic_auth("api", Some(&api_key))
267 .form(&[
268 ("from", format!("RavenClaws <noreply@{}>", domain)),
269 ("to", to.to_string()),
270 ("subject", subject.to_string()),
271 ("text", body.to_string()),
272 ])
273 .send()
274 .await
275 {
276 Ok(r) => {
277 let status = r.status();
278 if status.is_success() {
279 IntegrationResult::sent(status.as_u16())
280 } else {
281 IntegrationResult {
282 status: "error".to_string(),
283 detail: Some(r.text().await.unwrap_or_default()),
284 http_code: Some(status.as_u16()),
285 }
286 }
287 }
288 Err(e) => IntegrationResult::error(e.to_string()),
289 }
290}
291
292pub async fn send_sms(to: &str, message: &str) -> IntegrationResult {
299 let account_sid = std::env::var("TWILIO_ACCOUNT_SID").unwrap_or_default();
300 let auth_token = std::env::var("TWILIO_AUTH_TOKEN").unwrap_or_default();
301 let from_phone = std::env::var("TWILIO_PHONE_NUMBER").unwrap_or_default();
302
303 if account_sid.is_empty() || auth_token.is_empty() || from_phone.is_empty() {
304 return IntegrationResult::disabled(
305 "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars to enable SMS",
306 );
307 }
308 if to.is_empty() {
309 return IntegrationResult::error("to field required (phone number)");
310 }
311 let url = format!(
312 "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
313 account_sid
314 );
315 let client = match client() {
316 Ok(c) => c,
317 Err(e) => return IntegrationResult::error(e.to_string()),
318 };
319 match client
320 .post(&url)
321 .basic_auth(&account_sid, Some(&auth_token))
322 .form(&[("From", from_phone.as_str()), ("To", to), ("Body", message)])
323 .send()
324 .await
325 {
326 Ok(r) => {
327 let status = r.status();
328 if status.is_success() {
329 IntegrationResult::sent(status.as_u16())
330 } else {
331 IntegrationResult {
332 status: "error".to_string(),
333 detail: Some(r.text().await.unwrap_or_default()),
334 http_code: Some(status.as_u16()),
335 }
336 }
337 }
338 Err(e) => IntegrationResult::error(e.to_string()),
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn test_disabled_when_env_missing() {
348 std::env::remove_var("SLACK_WEBHOOK_URL");
350 std::env::remove_var("DISCORD_WEBHOOK_URL");
351 std::env::remove_var("TELEGRAM_BOT_TOKEN");
352 std::env::remove_var("TELEGRAM_CHAT_ID");
353 std::env::remove_var("MAILGUN_API_KEY");
354 std::env::remove_var("MAILGUN_DOMAIN");
355 std::env::remove_var("TWILIO_ACCOUNT_SID");
356 std::env::remove_var("TWILIO_AUTH_TOKEN");
357 std::env::remove_var("TWILIO_PHONE_NUMBER");
358 std::env::remove_var("TEAMS_WEBHOOK_URL");
359 std::env::remove_var("SIGNALD_REST_URL");
360 std::env::remove_var("MATRIX_HOMESERVER");
361 std::env::remove_var("MATRIX_ACCESS_TOKEN");
362 std::env::remove_var("MATRIX_ROOM_ID");
363 }
364
365 #[tokio::test]
366 async fn test_send_slack_disabled_without_env() {
367 std::env::remove_var("SLACK_WEBHOOK_URL");
368 let result = send_slack("hello").await;
369 assert_eq!(result.status, "disabled");
370 }
371
372 #[tokio::test]
373 async fn test_send_discord_disabled_without_env() {
374 std::env::remove_var("DISCORD_WEBHOOK_URL");
375 let result = send_discord("hello").await;
376 assert_eq!(result.status, "disabled");
377 }
378
379 #[tokio::test]
380 async fn test_send_telegram_disabled_without_env() {
381 std::env::remove_var("TELEGRAM_BOT_TOKEN");
382 std::env::remove_var("TELEGRAM_CHAT_ID");
383 let result = send_telegram("hello").await;
384 assert_eq!(result.status, "disabled");
385 }
386
387 #[tokio::test]
388 async fn test_send_teams_disabled_without_env() {
389 std::env::remove_var("TEAMS_WEBHOOK_URL");
390 let result = send_teams("hello", "title").await;
391 assert_eq!(result.status, "disabled");
392 }
393
394 #[tokio::test]
395 async fn test_send_email_disabled_without_env() {
396 std::env::remove_var("MAILGUN_API_KEY");
397 std::env::remove_var("MAILGUN_DOMAIN");
398 let result = send_email("to@example.com", "subject", "body").await;
399 assert_eq!(result.status, "disabled");
400 }
401
402 #[tokio::test]
403 async fn test_send_sms_disabled_without_env() {
404 std::env::remove_var("TWILIO_ACCOUNT_SID");
405 std::env::remove_var("TWILIO_AUTH_TOKEN");
406 std::env::remove_var("TWILIO_PHONE_NUMBER");
407 let result = send_sms("+15551234567", "hello").await;
408 assert_eq!(result.status, "disabled");
409 }
410
411 #[tokio::test]
412 async fn test_send_sms_requires_recipient() {
413 std::env::set_var("TWILIO_ACCOUNT_SID", "sid");
414 std::env::set_var("TWILIO_AUTH_TOKEN", "token");
415 std::env::set_var("TWILIO_PHONE_NUMBER", "+10000000000");
416 let result = send_sms("", "hello").await;
417 assert_eq!(result.status, "error");
418 }
419
420 #[tokio::test]
421 async fn test_send_signal_disabled_without_env() {
422 std::env::remove_var("SIGNALD_REST_URL");
423 let result = send_signal("+15551234567", "hello").await;
424 assert_eq!(result.status, "disabled");
425 }
426
427 #[tokio::test]
428 async fn test_send_matrix_disabled_without_env() {
429 std::env::remove_var("MATRIX_HOMESERVER");
430 std::env::remove_var("MATRIX_ACCESS_TOKEN");
431 std::env::remove_var("MATRIX_ROOM_ID");
432 let result = send_matrix("", "hello").await;
433 assert_eq!(result.status, "disabled");
434 }
435}