1use super::{
7 Channel, ChannelCapabilities, ChannelMessage, ChannelStatus, ChannelType, MessageId,
8 StreamingMode,
9};
10use crate::error::{ChannelError, RustantError};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct IMessageConfig {
17 pub enabled: bool,
18 pub polling_interval_ms: u64,
19}
20
21impl Default for IMessageConfig {
22 fn default() -> Self {
23 Self {
24 enabled: false,
25 polling_interval_ms: 5000,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ResolvedContact {
33 pub name: String,
34 pub phone: Option<String>,
35 pub email: Option<String>,
36}
37
38#[async_trait]
40pub trait IMessageBridge: Send + Sync {
41 async fn send_message(&self, recipient: &str, text: &str) -> Result<(), String>;
42 async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String>;
43 async fn is_available(&self) -> Result<bool, String>;
44 async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String>;
46}
47
48#[derive(Debug, Clone)]
50pub struct IMessageIncoming {
51 pub sender: String,
52 pub text: String,
53 pub timestamp: u64,
54}
55
56pub struct IMessageChannel {
58 config: IMessageConfig,
59 status: ChannelStatus,
60 bridge: Box<dyn IMessageBridge>,
61 name: String,
62}
63
64impl IMessageChannel {
65 pub fn new(config: IMessageConfig, bridge: Box<dyn IMessageBridge>) -> Self {
66 Self {
67 config,
68 status: ChannelStatus::Disconnected,
69 bridge,
70 name: "imessage".to_string(),
71 }
72 }
73
74 pub fn with_name(mut self, name: impl Into<String>) -> Self {
75 self.name = name.into();
76 self
77 }
78
79 pub async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
81 self.bridge.resolve_contact(query).await
82 }
83
84 pub async fn send_imessage(&self, recipient: &str, text: &str) -> Result<(), RustantError> {
87 if self.status != ChannelStatus::Connected {
88 return Err(RustantError::Channel(ChannelError::NotConnected {
89 name: self.name.clone(),
90 }));
91 }
92 self.bridge
93 .send_message(recipient, text)
94 .await
95 .map_err(|e| {
96 RustantError::Channel(ChannelError::SendFailed {
97 name: self.name.clone(),
98 message: e,
99 })
100 })
101 }
102}
103
104#[async_trait]
105impl Channel for IMessageChannel {
106 fn name(&self) -> &str {
107 &self.name
108 }
109
110 fn channel_type(&self) -> ChannelType {
111 ChannelType::IMessage
112 }
113
114 async fn connect(&mut self) -> Result<(), RustantError> {
115 if !self.config.enabled {
116 return Err(RustantError::Channel(ChannelError::ConnectionFailed {
117 name: self.name.clone(),
118 message: "iMessage channel is not enabled".into(),
119 }));
120 }
121 let available = self.bridge.is_available().await.map_err(|e| {
122 RustantError::Channel(ChannelError::ConnectionFailed {
123 name: self.name.clone(),
124 message: e,
125 })
126 })?;
127 if !available {
128 return Err(RustantError::Channel(ChannelError::ConnectionFailed {
129 name: self.name.clone(),
130 message: "Messages.app not available".into(),
131 }));
132 }
133 self.status = ChannelStatus::Connected;
134 Ok(())
135 }
136
137 async fn disconnect(&mut self) -> Result<(), RustantError> {
138 self.status = ChannelStatus::Disconnected;
139 Ok(())
140 }
141
142 async fn send_message(&self, msg: ChannelMessage) -> Result<MessageId, RustantError> {
143 if self.status != ChannelStatus::Connected {
144 return Err(RustantError::Channel(ChannelError::NotConnected {
145 name: self.name.clone(),
146 }));
147 }
148 let text = msg.content.as_text().unwrap_or("");
149 self.bridge
150 .send_message(&msg.channel_id, text)
151 .await
152 .map_err(|e| {
153 RustantError::Channel(ChannelError::SendFailed {
154 name: self.name.clone(),
155 message: e,
156 })
157 })?;
158 Ok(MessageId::random())
159 }
160
161 async fn receive_messages(&self) -> Result<Vec<ChannelMessage>, RustantError> {
162 let incoming = self.bridge.receive_messages().await.map_err(|e| {
163 RustantError::Channel(ChannelError::ConnectionFailed {
164 name: self.name.clone(),
165 message: e,
166 })
167 })?;
168
169 let messages = incoming
170 .into_iter()
171 .map(|m| {
172 let sender = super::ChannelUser::new(&m.sender, ChannelType::IMessage);
173 ChannelMessage::text(ChannelType::IMessage, &m.sender, sender, &m.text)
174 })
175 .collect();
176
177 Ok(messages)
178 }
179
180 fn status(&self) -> ChannelStatus {
181 self.status
182 }
183
184 fn capabilities(&self) -> ChannelCapabilities {
185 ChannelCapabilities {
186 supports_threads: false,
187 supports_reactions: true,
188 supports_files: true,
189 supports_voice: false,
190 supports_video: false,
191 max_message_length: None,
192 supports_editing: false,
193 supports_deletion: false,
194 }
195 }
196
197 fn streaming_mode(&self) -> StreamingMode {
198 StreamingMode::Polling {
199 interval_ms: self.config.polling_interval_ms,
200 }
201 }
202}
203
204#[cfg(target_os = "macos")]
206pub struct RealIMessageBridge;
207
208#[cfg(target_os = "macos")]
209impl Default for RealIMessageBridge {
210 fn default() -> Self {
211 Self
212 }
213}
214
215#[cfg(target_os = "macos")]
216impl RealIMessageBridge {
217 pub fn new() -> Self {
218 Self
219 }
220}
221
222#[cfg(target_os = "macos")]
223#[async_trait]
224impl IMessageBridge for RealIMessageBridge {
225 async fn send_message(&self, recipient: &str, text: &str) -> Result<(), String> {
226 let escaped_recipient = recipient.replace('"', "\\\"");
227 let escaped_text = text.replace('"', "\\\"");
228 let script = format!(
229 "tell application \"Messages\"\n\
230 \tset targetService to 1st service whose service type = iMessage\n\
231 \tset targetBuddy to buddy \"{}\" of targetService\n\
232 \tsend \"{}\" to targetBuddy\n\
233 end tell",
234 escaped_recipient, escaped_text,
235 );
236
237 let output = tokio::process::Command::new("osascript")
238 .args(["-e", &script])
239 .output()
240 .await
241 .map_err(|e| format!("Failed to run osascript: {e}"))?;
242
243 if !output.status.success() {
244 let stderr = String::from_utf8_lossy(&output.stderr);
245 return Err(format!("osascript failed: {}", stderr));
246 }
247
248 Ok(())
249 }
250
251 async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String> {
252 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
253 let db_path = format!("{}/Library/Messages/chat.db", home);
254
255 let output = tokio::process::Command::new("sqlite3")
256 .args([
257 &db_path,
258 "-json",
259 "SELECT m.ROWID, m.text, h.id as sender, m.date \
260 FROM message m \
261 JOIN handle h ON m.handle_id = h.ROWID \
262 WHERE m.is_from_me = 0 \
263 AND m.date > strftime('%s', 'now', '-60 seconds') * 1000000000 \
264 ORDER BY m.date DESC \
265 LIMIT 20;",
266 ])
267 .output()
268 .await
269 .map_err(|e| format!("Failed to read Messages DB: {e}"))?;
270
271 let stdout = String::from_utf8_lossy(&output.stdout);
272 if stdout.trim().is_empty() {
273 return Ok(vec![]);
274 }
275
276 let rows: Vec<serde_json::Value> =
277 serde_json::from_str(&stdout).map_err(|e| format!("JSON parse error: {e}"))?;
278
279 let messages = rows
280 .iter()
281 .filter_map(|r| {
282 Some(IMessageIncoming {
283 sender: r["sender"].as_str()?.to_string(),
284 text: r["text"].as_str().unwrap_or("").to_string(),
285 timestamp: r["date"].as_u64().unwrap_or(0),
286 })
287 })
288 .collect();
289
290 Ok(messages)
291 }
292
293 async fn is_available(&self) -> Result<bool, String> {
294 let output = tokio::process::Command::new("osascript")
295 .args([
296 "-e",
297 "tell application \"System Events\" to (name of processes) contains \"Messages\"",
298 ])
299 .output()
300 .await
301 .map_err(|e| format!("Failed to check Messages.app: {e}"))?;
302
303 let stdout = String::from_utf8_lossy(&output.stdout);
304 Ok(stdout.trim() == "true")
305 }
306
307 async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
308 let escaped_query = query.replace('"', "\\\"");
309 let script = format!(
310 r#"tell application "Contacts"
311 set matchingPeople to every person whose name contains "{query}"
312 set output to ""
313 repeat with p in matchingPeople
314 set pName to name of p
315 set pPhone to ""
316 set pEmail to ""
317 try
318 set pPhone to value of phone 1 of p
319 end try
320 try
321 set pEmail to value of email 1 of p
322 end try
323 set output to output & pName & "||" & pPhone & "||" & pEmail & "%%"
324 end repeat
325 return output
326end tell"#,
327 query = escaped_query
328 );
329
330 let output = tokio::process::Command::new("osascript")
331 .args(["-e", &script])
332 .output()
333 .await
334 .map_err(|e| format!("Failed to run osascript: {e}"))?;
335
336 if !output.status.success() {
337 let stderr = String::from_utf8_lossy(&output.stderr);
338 return Err(format!("Contacts lookup failed: {}", stderr));
339 }
340
341 let stdout = String::from_utf8_lossy(&output.stdout);
342 let contacts = stdout
343 .trim()
344 .split("%%")
345 .filter(|s| !s.is_empty())
346 .filter_map(|entry| {
347 let parts: Vec<&str> = entry.split("||").collect();
348 if parts.is_empty() {
349 return None;
350 }
351 let name = parts[0].trim().to_string();
352 if name.is_empty() {
353 return None;
354 }
355 let phone = parts.get(1).and_then(|p| {
356 let p = p.trim();
357 if p.is_empty() {
358 None
359 } else {
360 Some(p.to_string())
361 }
362 });
363 let email = parts.get(2).and_then(|e| {
364 let e = e.trim();
365 if e.is_empty() {
366 None
367 } else {
368 Some(e.to_string())
369 }
370 });
371 Some(ResolvedContact { name, phone, email })
372 })
373 .collect();
374
375 Ok(contacts)
376 }
377}
378
379#[cfg(target_os = "macos")]
381pub fn create_imessage_channel(config: IMessageConfig) -> IMessageChannel {
382 IMessageChannel::new(config, Box::new(RealIMessageBridge::new()))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 struct MockIMessageBridge {
390 available: bool,
391 }
392
393 impl MockIMessageBridge {
394 fn new(available: bool) -> Self {
395 Self { available }
396 }
397 }
398
399 #[async_trait]
400 impl IMessageBridge for MockIMessageBridge {
401 async fn send_message(&self, _recipient: &str, _text: &str) -> Result<(), String> {
402 Ok(())
403 }
404 async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String> {
405 Ok(vec![])
406 }
407 async fn is_available(&self) -> Result<bool, String> {
408 Ok(self.available)
409 }
410 async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
411 Ok(vec![ResolvedContact {
413 name: format!("Mock {}", query),
414 phone: Some("+1234567890".to_string()),
415 email: Some("mock@example.com".to_string()),
416 }])
417 }
418 }
419
420 #[test]
421 fn test_imessage_channel_creation() {
422 let ch = IMessageChannel::new(
423 IMessageConfig::default(),
424 Box::new(MockIMessageBridge::new(true)),
425 );
426 assert_eq!(ch.name(), "imessage");
427 assert_eq!(ch.channel_type(), ChannelType::IMessage);
428 }
429
430 #[test]
431 fn test_imessage_capabilities() {
432 let ch = IMessageChannel::new(
433 IMessageConfig::default(),
434 Box::new(MockIMessageBridge::new(true)),
435 );
436 let caps = ch.capabilities();
437 assert!(caps.supports_reactions);
438 assert!(caps.supports_files);
439 assert!(!caps.supports_threads);
440 }
441
442 #[test]
443 fn test_imessage_streaming_mode() {
444 let ch = IMessageChannel::new(
445 IMessageConfig::default(),
446 Box::new(MockIMessageBridge::new(true)),
447 );
448 assert_eq!(
449 ch.streaming_mode(),
450 StreamingMode::Polling { interval_ms: 5000 }
451 );
452 }
453
454 #[test]
455 fn test_imessage_status_disconnected() {
456 let ch = IMessageChannel::new(
457 IMessageConfig::default(),
458 Box::new(MockIMessageBridge::new(true)),
459 );
460 assert_eq!(ch.status(), ChannelStatus::Disconnected);
461 assert!(!ch.is_connected());
462 }
463
464 #[tokio::test]
465 async fn test_imessage_send_without_connect() {
466 let ch = IMessageChannel::new(
467 IMessageConfig::default(),
468 Box::new(MockIMessageBridge::new(true)),
469 );
470 let sender = super::super::ChannelUser::new("me", ChannelType::IMessage);
471 let msg = ChannelMessage::text(ChannelType::IMessage, "+1234", sender, "hi");
472 let result = ch.send_message(msg).await;
473 assert!(result.is_err());
474 }
475}