rustyclaw_core/messengers/
imessage.rs1use super::{Message, Messenger, SendOptions};
12use anyhow::{Context, Result};
13use async_trait::async_trait;
14use std::sync::atomic::{AtomicI64, Ordering};
15
16pub struct IMessageMessenger {
18 name: String,
19 server_url: String,
21 password: String,
23 connected: bool,
24 http: reqwest::Client,
25 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 fn api_url(&self, path: &str) -> String {
43 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 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 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, channel: chat_guid,
188 reply_to: None,
189 media: None,
190 });
191 }
192
193 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(), "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 assert!(url.contains("password=p%40ss%26word"));
250 assert!(!url.contains("p@ss&word"));
251 }
252}