Skip to main content

rustyclaw_core/messengers/
imessage.rs

1//! iMessage messenger via BlueBubbles server API.
2//!
3//! BlueBubbles is a self-hosted iMessage bridge that exposes a REST API.
4//! This messenger connects to a BlueBubbles server to send and receive
5//! iMessage conversations.
6//!
7//! Requirements:
8//! - A running BlueBubbles server (macOS with iMessage)
9//! - Server URL and password configured
10
11use super::{Message, Messenger, SendOptions};
12use anyhow::{Context, Result};
13use async_trait::async_trait;
14use std::sync::atomic::{AtomicI64, Ordering};
15
16/// iMessage messenger via BlueBubbles REST API.
17pub struct IMessageMessenger {
18    name: String,
19    /// BlueBubbles server URL (e.g. "http://localhost:1234").
20    server_url: String,
21    /// BlueBubbles server password.
22    password: String,
23    connected: bool,
24    http: reqwest::Client,
25    /// Last message timestamp for polling (atomic for interior mutability).
26    last_poll_ts: AtomicI64,
27}
28
29impl IMessageMessenger {
30    pub fn new(name: String, server_url: String, password: String) -> Self {
31        Self {
32            name,
33            server_url: server_url.trim_end_matches('/').to_string(),
34            password,
35            connected: false,
36            http: reqwest::Client::new(),
37            last_poll_ts: AtomicI64::new(chrono::Utc::now().timestamp_millis()),
38        }
39    }
40
41    /// Build a BlueBubbles API URL with password query param.
42    fn api_url(&self, path: &str) -> String {
43        // URL-encode the password to handle special characters (&, =, #, etc.)
44        let encoded_password: String = self.password.bytes().map(|b| {
45            match b {
46                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
47                    (b as char).to_string()
48                }
49                _ => format!("%{:02X}", b),
50            }
51        }).collect();
52        format!(
53            "{}/api/v1/{}?password={}",
54            self.server_url, path, encoded_password
55        )
56    }
57}
58
59#[async_trait]
60impl Messenger for IMessageMessenger {
61    fn name(&self) -> &str {
62        &self.name
63    }
64
65    fn messenger_type(&self) -> &str {
66        "imessage"
67    }
68
69    async fn initialize(&mut self) -> Result<()> {
70        // Check server connectivity
71        let url = self.api_url("server/info");
72        let resp = self
73            .http
74            .get(&url)
75            .send()
76            .await
77            .context("Failed to connect to BlueBubbles server")?;
78
79        if !resp.status().is_success() {
80            anyhow::bail!(
81                "BlueBubbles server returned {} — check URL and password",
82                resp.status()
83            );
84        }
85
86        let data: serde_json::Value = resp.json().await?;
87        if data["status"].as_i64() != Some(200) {
88            anyhow::bail!(
89                "BlueBubbles server error: {}",
90                data["message"].as_str().unwrap_or("unknown")
91            );
92        }
93
94        self.connected = true;
95        tracing::info!(
96            server = %self.server_url,
97            os_version = ?data["data"]["os_version"].as_str(),
98            "BlueBubbles/iMessage connected"
99        );
100
101        Ok(())
102    }
103
104    async fn send_message(&self, chat_guid: &str, content: &str) -> Result<String> {
105        let url = self.api_url("message/text");
106        let payload = serde_json::json!({
107            "chatGuid": chat_guid,
108            "message": content,
109            "method": "apple-script"
110        });
111
112        let resp = self
113            .http
114            .post(&url)
115            .json(&payload)
116            .send()
117            .await
118            .context("BlueBubbles send failed")?;
119
120        if resp.status().is_success() {
121            let data: serde_json::Value = resp.json().await?;
122            return Ok(
123                data["data"]["guid"]
124                    .as_str()
125                    .unwrap_or("sent")
126                    .to_string(),
127            );
128        }
129
130        anyhow::bail!("BlueBubbles send returned {}", resp.status())
131    }
132
133    async fn send_message_with_options(&self, opts: SendOptions<'_>) -> Result<String> {
134        self.send_message(opts.recipient, opts.content).await
135    }
136
137    async fn receive_messages(&self) -> Result<Vec<Message>> {
138        let poll_ts = self.last_poll_ts.load(Ordering::Relaxed);
139        let url = format!(
140            "{}&after={}&limit=50&sort=asc",
141            self.api_url("message"), poll_ts
142        );
143
144        let resp = self.http.get(&url).send().await?;
145        if !resp.status().is_success() {
146            return Ok(Vec::new());
147        }
148
149        let data: serde_json::Value = resp.json().await?;
150        let messages = data["data"].as_array().cloned().unwrap_or_default();
151
152        let mut result = Vec::new();
153        let mut max_ts = poll_ts;
154        for msg in &messages {
155            // Skip messages we sent
156            if msg["isFromMe"].as_bool() == Some(true) {
157                continue;
158            }
159
160            let text = match msg["text"].as_str() {
161                Some(t) if !t.is_empty() => t,
162                _ => continue,
163            };
164
165            let guid = msg["guid"].as_str().unwrap_or("").to_string();
166            let sender = msg["handle"]["address"]
167                .as_str()
168                .or(msg["handle"]["id"].as_str())
169                .unwrap_or("unknown")
170                .to_string();
171            let date_created = msg["dateCreated"].as_i64().unwrap_or(0);
172            let chat_guid = msg["chats"]
173                .as_array()
174                .and_then(|c| c.first())
175                .and_then(|c| c["guid"].as_str())
176                .map(|s| s.to_string());
177
178            if date_created > max_ts {
179                max_ts = date_created;
180            }
181
182            result.push(Message {
183                id: guid,
184                sender,
185                content: text.to_string(),
186                timestamp: date_created / 1000, // ms to seconds
187                channel: chat_guid,
188                reply_to: None,
189                media: None,
190            });
191        }
192
193        // Advance the poll timestamp so we don't re-fetch the same messages.
194        if max_ts > poll_ts {
195            self.last_poll_ts.store(max_ts, Ordering::Relaxed);
196        }
197
198        Ok(result)
199    }
200
201    fn is_connected(&self) -> bool {
202        self.connected
203    }
204
205    async fn disconnect(&mut self) -> Result<()> {
206        self.connected = false;
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_imessage_creation() {
217        let m = IMessageMessenger::new(
218            "test".to_string(),
219            "http://localhost:1234".to_string(),
220            "password123".to_string(),
221        );
222        assert_eq!(m.name(), "test");
223        assert_eq!(m.messenger_type(), "imessage");
224        assert!(!m.is_connected());
225        assert_eq!(m.server_url, "http://localhost:1234");
226    }
227
228    #[test]
229    fn test_api_url() {
230        let m = IMessageMessenger::new(
231            "test".to_string(),
232            "http://localhost:1234/".to_string(), // trailing slash
233            "pass".to_string(),
234        );
235        let url = m.api_url("server/info");
236        assert!(url.starts_with("http://localhost:1234/api/v1/server/info"));
237        assert!(url.contains("password=pass"));
238    }
239
240    #[test]
241    fn test_api_url_encodes_special_chars() {
242        let m = IMessageMessenger::new(
243            "test".to_string(),
244            "http://localhost:1234".to_string(),
245            "p@ss&word".to_string(),
246        );
247        let url = m.api_url("server/info");
248        // Special chars should be percent-encoded
249        assert!(url.contains("password=p%40ss%26word"));
250        assert!(!url.contains("p@ss&word"));
251    }
252}