Skip to main content

ravenclaws/
integrations.rs

1//! # Messaging & Connector Integrations
2//!
3//! Outbound notifications to common messaging platforms. Each integration is
4//! **env-var gated** — when the required credentials are absent, the function
5//! returns a `disabled` result instead of failing. This mirrors the
6//! `RavenAssistant01` orchestrator's integration surface, re-expressed as a
7//! clean library API (no HTTP framework coupling).
8//!
9//! Supported channels: Slack, Discord, Microsoft Teams, Signal (signald),
10//! Matrix, Telegram, Email (Mailgun), and SMS (Twilio).
11//!
12//! ## Usage
13//!
14//! ```rust,no_run
15//! use ravenclaws::integrations::send_slack;
16//!
17//! # async fn example() {
18//! let result = send_slack("Hello from RavenClaws").await;
19//! println!("{}", result.status);
20//! # }
21//! ```
22//!
23//! This module exposes a public API consumed by library users rather than by the
24//! default binary, so dead-code analysis on the binary produces false positives.
25#![allow(dead_code)]
26
27use serde::{Deserialize, Serialize};
28
29/// Outcome of an integration attempt.
30///
31/// # Stability
32/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct IntegrationResult {
36    /// One of: `sent`, `disabled`, `error`
37    pub status: String,
38    /// Human-readable detail (error message or configuration hint)
39    pub detail: Option<String>,
40    /// HTTP status code when an HTTP call was made
41    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
70/// Build a shared `reqwest` client for integrations.
71fn 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
78/// Post a JSON payload and map the response to an [`IntegrationResult`].
79async 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
90// ── Slack ──────────────────────────────────────────────────────────────────
91
92/// Send a Slack message via incoming webhook.
93///
94/// Requires the `SLACK_WEBHOOK_URL` environment variable.
95pub 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
110// ── Discord ────────────────────────────────────────────────────────────────
111
112/// Send a Discord message via webhook.
113///
114/// Requires the `DISCORD_WEBHOOK_URL` environment variable.
115pub 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
130// ── Microsoft Teams ────────────────────────────────────────────────────────
131
132/// Send a Microsoft Teams message via incoming webhook.
133///
134/// Requires the `TEAMS_WEBHOOK_URL` environment variable.
135pub 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
155// ── Signal (signald) ───────────────────────────────────────────────────────
156
157/// Send a Signal message via a signald REST endpoint.
158///
159/// Requires the `SIGNALD_REST_URL` environment variable.
160pub 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
179// ── Matrix ─────────────────────────────────────────────────────────────────
180
181/// Send a Matrix message.
182///
183/// Requires `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, and `MATRIX_ROOM_ID`
184/// environment variables.
185pub 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
223// ── Telegram ───────────────────────────────────────────────────────────────
224
225/// Send a Telegram message via the Bot API.
226///
227/// Requires `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` environment variables.
228pub 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
246// ── Email (Mailgun) ────────────────────────────────────────────────────────
247
248/// Send an email via the Mailgun API.
249///
250/// Requires `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` environment variables.
251pub 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
292// ── SMS (Twilio) ───────────────────────────────────────────────────────────
293
294/// Send an SMS via the Twilio API.
295///
296/// Requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER`
297/// environment variables.
298pub 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        // Ensure the env vars are not set for these tests.
349        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}