use anyhow::Result; use reqwest::Client; use serde::Deserialize; use super::ollama::OllamaClient; #[derive(Clone)] pub struct SmsApiClient { client: Client, base_url: String, token: Option, } impl SmsApiClient { pub fn new(base_url: String, token: Option) -> Self { Self { client: Client::new(), base_url, token, } } /// Compute a `[start, end]` unix-second window of `2 * radius_days` /// centered on `center_ts`. `radius_days < 1` is clamped to 1 to avoid /// degenerate zero-width windows. pub(crate) fn window_for_radius(center_ts: i64, radius_days: i64) -> (i64, i64) { let r = radius_days.max(1); let span = r * 86400; (center_ts - span, center_ts + span) } /// Fetch messages for a specific contact within ±`radius_days` of the /// given timestamp. Falls back to all contacts when no messages found /// for the named contact. Sorted by proximity to the center timestamp. pub async fn fetch_messages_for_contact( &self, contact: Option<&str>, center_timestamp: i64, radius_days: i64, ) -> Result> { let effective_radius = radius_days.max(1); let (start_ts, end_ts) = Self::window_for_radius(center_timestamp, radius_days); let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0) .ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?; // If contact specified, try fetching for that contact first if let Some(contact_name) = contact { log::info!( "Fetching SMS for contact: {} (±{} days from {})", contact_name, effective_radius, center_dt.format("%Y-%m-%d %H:%M:%S") ); let messages = self .fetch_messages(start_ts, end_ts, Some(contact_name), Some(center_timestamp)) .await?; if !messages.is_empty() { log::info!( "Found {} messages for contact {}", messages.len(), contact_name ); return Ok(messages); } log::info!( "No messages found for contact {}, falling back to all contacts", contact_name ); } // Fallback to all contacts log::info!( "Fetching all SMS messages (±{} days from {})", effective_radius, center_dt.format("%Y-%m-%d %H:%M:%S") ); self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp)) .await } /// Fetch all messages for a specific contact across all time /// Used for embedding generation - retrieves complete message history /// Handles pagination automatically if the API returns a limited number of results pub async fn fetch_all_messages_for_contact(&self, contact: &str) -> Result> { let start_ts = chrono::DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z") .unwrap() .timestamp(); let end_ts = chrono::Utc::now().timestamp(); log::info!("Fetching all historical messages for contact: {}", contact); let mut all_messages = Vec::new(); let mut offset = 0; let limit = 1000; // Fetch in batches of 1000 loop { log::debug!( "Fetching batch at offset {} for contact {}", offset, contact ); let batch = self .fetch_messages_paginated(start_ts, end_ts, Some(contact), None, limit, offset) .await?; let batch_size = batch.len(); all_messages.extend(batch); log::debug!( "Fetched {} messages (total so far: {})", batch_size, all_messages.len() ); // If we got fewer messages than the limit, we've reached the end if batch_size < limit { break; } offset += limit; } log::info!( "Fetched {} total messages for contact {}", all_messages.len(), contact ); Ok(all_messages) } /// Internal method to fetch messages with pagination support async fn fetch_messages_paginated( &self, start_ts: i64, end_ts: i64, contact: Option<&str>, center_timestamp: Option, limit: usize, offset: usize, ) -> Result> { let mut url = format!( "{}/api/messages/by-date-range/?start_date={}&end_date={}&limit={}&offset={}", self.base_url, start_ts, end_ts, limit, offset ); if let Some(contact_name) = contact { url.push_str(&format!("&contact={}", urlencoding::encode(contact_name))); } if let Some(ts) = center_timestamp { url.push_str(&format!("×tamp={}", ts)); } log::debug!("Fetching SMS messages from: {}", url); let mut request = self.client.get(&url); if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } let response = request.send().await?; log::debug!("SMS API response status: {}", response.status()); if !response.status().is_success() { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); log::error!("SMS API request failed: {} - {}", status, error_body); return Err(anyhow::anyhow!( "SMS API request failed: {} - {}", status, error_body )); } let data: SmsApiResponse = response.json().await?; Ok(data .messages .into_iter() .map(|m| SmsMessage { contact: m.contact_name, body: m.body, timestamp: m.date, is_sent: m.type_ == 2, }) .collect()) } /// Internal method to fetch messages with optional contact filter and timestamp sorting async fn fetch_messages( &self, start_ts: i64, end_ts: i64, contact: Option<&str>, center_timestamp: Option, ) -> Result> { // Call Django endpoint let mut url = format!( "{}/api/messages/by-date-range/?start_date={}&end_date={}", self.base_url, start_ts, end_ts ); // Add contact filter if provided if let Some(contact_name) = contact { url.push_str(&format!("&contact={}", urlencoding::encode(contact_name))); } // Add timestamp for proximity sorting if provided if let Some(ts) = center_timestamp { url.push_str(&format!("×tamp={}", ts)); } log::debug!("Fetching SMS messages from: {}", url); let mut request = self.client.get(&url); // Add authorization header if token exists if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } let response = request.send().await?; log::debug!("SMS API response status: {}", response.status()); if !response.status().is_success() { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); log::error!("SMS API request failed: {} - {}", status, error_body); return Err(anyhow::anyhow!( "SMS API request failed: {} - {}", status, error_body )); } let data: SmsApiResponse = response.json().await?; // Convert to internal format Ok(data .messages .into_iter() .map(|m| SmsMessage { contact: m.contact_name, body: m.body, timestamp: m.date, is_sent: m.type_ == 2, // type 2 = sent }) .collect()) } /// Search message bodies via the Django side's FTS5 / semantic / hybrid /// endpoint. `params.mode` selects the ranking strategy: /// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR /// - "semantic" embedding similarity /// - "hybrid" both merged via reciprocal rank fusion (recommended) /// /// All of `contact_id`, `date_from` / `date_to` (unix seconds), `is_mms`, /// `has_media`, and `offset` are pushed to SMS-API server-side so the /// filtered+paginated result set is exact rather than a client-side /// over-fetch. pub async fn search_messages( &self, query: &str, params: &SmsSearchParams<'_>, ) -> Result> { let mut url = format!( "{}/api/messages/search/?q={}&mode={}&limit={}", self.base_url, urlencoding::encode(query), urlencoding::encode(params.mode), params.limit, ); if let Some(cid) = params.contact_id { url.push_str(&format!("&contact_id={}", cid)); } if let Some(ref c) = params.contact { url.push_str(&format!("&contact={}", urlencoding::encode(c))); } if let Some(off) = params.offset { url.push_str(&format!("&offset={}", off)); } if let Some(from) = params.date_from { url.push_str(&format!("&date_from={}", from)); } if let Some(to) = params.date_to { url.push_str(&format!("&date_to={}", to)); } if let Some(is_mms) = params.is_mms { url.push_str(&format!("&is_mms={}", is_mms)); } if let Some(has_media) = params.has_media { url.push_str(&format!("&has_media={}", has_media)); } let mut request = self.client.get(&url); if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } let response = request.send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(anyhow::anyhow!( "SMS search request failed: {} - {}", status, body )); } let data: SmsSearchResponse = response.json().await?; Ok(data.results) } /// One page of a contact's thread from SMS-API's conversation endpoint, /// newest first. With `message_id`, SMS-API ignores `page` and returns /// the page that contains that message. pub async fn fetch_thread_page( &self, contact_id: i64, message_id: Option, page: usize, page_size: usize, ) -> Result { let mut url = format!( "{}/api/conversations/{}/?page={}&page_size={}", self.base_url, contact_id, page, page_size ); if let Some(mid) = message_id { url.push_str(&format!("&message_id={}", mid)); } let mut request = self.client.get(&url); if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } let response = request.send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(anyhow::anyhow!( "SMS conversation request failed: {} - {}", status, body )); } let raw: RawThreadPage = response.json().await?; Ok(raw.into()) } /// The slice of a thread around `anchor_message_id`: up to `before` /// messages older than the anchor, the anchor itself, and up to `after` /// newer ones — oldest first, ready to render as a transcript. /// /// SMS-API pages are fixed-size, so the anchor can sit at either edge of /// the page that holds it; the neighbouring page is pulled in whenever /// the requested side runs short. pub async fn fetch_thread_window( &self, contact_id: i64, anchor_message_id: i64, before: usize, after: usize, ) -> Result { let page_size = (before + after + 1).clamp(20, 200); let first = self .fetch_thread_page(contact_id, Some(anchor_message_id), 1, page_size) .await?; let contact = first.contact.clone(); let Some(idx) = first .messages .iter() .position(|m| m.id == anchor_message_id) else { return Err(anyhow::anyhow!( "message {} not found in conversation {}", anchor_message_id, contact_id )); }; // Newest first: index < idx is newer, > idx is older. let mut newer: Vec = first.messages[..idx].to_vec(); let anchor = first.messages[idx].clone(); let mut older: Vec = first.messages[idx + 1..].to_vec(); if older.len() < before && first.page < first.total_pages { let next = self .fetch_thread_page(contact_id, None, first.page + 1, page_size) .await?; older.extend(next.messages); } if newer.len() < after && first.page > 1 { let prev = self .fetch_thread_page(contact_id, None, first.page - 1, page_size) .await?; // The previous page is entirely newer; keep it newest-first so // the closest messages stay at the end of `newer`. let mut combined = prev.messages; combined.extend(newer); newer = combined; } older.truncate(before); older.reverse(); let newer_start = newer.len().saturating_sub(after); let mut newer: Vec = newer[newer_start..].to_vec(); newer.reverse(); let mut messages = older; let anchor_index = messages.len(); messages.push(anchor); messages.extend(newer); Ok(ThreadWindow { contact, messages, anchor_index, }) } /// Download one MMS *image*. `media_index` is the attachment's position /// among the message's image/video/audio parts — the same index /// SMS-API's `/media/{message}/{index}/file/` route takes. /// /// Checks the response before reading the body, so pointing this at a /// video or audio attachment costs a header round-trip, not the whole /// file in memory; images over [`MMS_IMAGE_MAX_BYTES`] are refused too. pub async fn fetch_mms_image( &self, contact_id: i64, message_id: i64, media_index: usize, ) -> Result> { let url = format!( "{}/api/conversations/{}/media/{}/{}/file/", self.base_url, contact_id, message_id, media_index ); let mut request = self.client.get(&url); if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } let mut response = request.send().await?; if !response.status().is_success() { return Err(anyhow::anyhow!( "MMS media request failed: {}", response.status() )); } let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(str::to_string); check_mms_image_response(content_type.as_deref(), response.content_length()) .map_err(|e| anyhow::anyhow!(e))?; // Content-Length can be absent (chunked); cap while reading too. let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await? { if bytes.len() + chunk.len() > MMS_IMAGE_MAX_BYTES { return Err(anyhow::anyhow!("attachment is larger than the image limit")); } bytes.extend_from_slice(&chunk); } Ok(bytes) } pub async fn summarize_context( &self, messages: &[SmsMessage], ollama: &OllamaClient, ) -> Result { if messages.is_empty() { return Ok(String::from("No messages on this day")); } // Create prompt for Ollama with sender/receiver distinction let user_name = crate::ai::user_display_name(); let messages_text: String = messages .iter() .take(60) // Limit to avoid token overflow .map(|m| { if m.is_sent { format!("{}: {}", user_name, m.body) } else { format!("{}: {}", m.contact, m.body) } }) .collect::>() .join("\n"); let prompt = format!( r#"Summarize these messages in up to 4-5 sentences. Focus on key topics, places, people mentioned, and the overall context of the conversations. Messages: {} Summary:"#, messages_text ); ollama .generate( &prompt, // Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."), Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."), ) .await } } #[derive(Debug, Clone)] pub struct SmsMessage { pub contact: String, pub body: String, pub timestamp: i64, pub is_sent: bool, } /// Largest MMS image `fetch_mms_image` will download. Phone photos sent by /// MMS are a few MB; anything past this is not worth decoding for a /// 1024px vision description. pub const MMS_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024; /// Decide from the response headers whether an MMS attachment is an image /// worth downloading. A missing content type is allowed through (the /// decoder decides); a declared non-image or an oversized length is not. fn check_mms_image_response( content_type: Option<&str>, content_length: Option, ) -> Result<(), String> { if let Some(ct) = content_type && !ct.trim().to_ascii_lowercase().starts_with("image/") { return Err(format!("attachment is {ct}, not an image")); } if content_length.is_some_and(|n| n > MMS_IMAGE_MAX_BYTES as u64) { return Err("attachment is larger than the image limit".to_string()); } Ok(()) } /// A thread participant as SMS-API's conversation endpoint describes it. #[derive(Debug, Clone, Deserialize)] pub struct ThreadContact { pub id: i64, #[serde(default)] pub name: String, #[serde(default)] pub address: String, } impl ThreadContact { /// Name to show for the other side of the thread — the stored name, or /// the phone number for a contact that was never named. pub fn display_name(&self) -> String { if self.name.trim().is_empty() { self.address.clone() } else { self.name.clone() } } } /// One message of a thread. `attachments` holds only the media parts /// (image/video/audio); MMS text parts are folded into `body`. #[derive(Debug, Clone)] pub struct ThreadMessage { pub id: i64, pub body: String, /// Unix seconds. pub timestamp: i64, pub is_sent: bool, pub attachments: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct ThreadAttachment { /// Position among the message's media parts — the index /// `fetch_mms_media` takes. pub media_index: usize, pub content_type: String, pub filename: String, } #[derive(Debug, Clone)] pub struct ThreadPage { pub contact: ThreadContact, /// Newest first, as SMS-API returns them. pub messages: Vec, pub page: usize, pub total_pages: usize, } /// A slice of a thread around an anchor message, oldest first. #[derive(Debug, Clone)] pub struct ThreadWindow { pub contact: ThreadContact, pub messages: Vec, /// Index of the anchor message within `messages`. pub anchor_index: usize, } #[derive(Deserialize)] struct RawThreadPage { contact: ThreadContact, messages: Vec, page: usize, total_pages: usize, } #[derive(Deserialize)] struct RawThreadMessage { id: i64, #[serde(default)] body: String, date: i64, #[serde(rename = "type")] type_: i32, #[serde(default)] parts: Vec, } #[derive(Deserialize)] struct RawThreadPart { content_type: String, #[serde(default)] filename: String, #[serde(default)] text: String, } impl From for ThreadPage { fn from(raw: RawThreadPage) -> Self { ThreadPage { contact: raw.contact, messages: raw.messages.into_iter().map(ThreadMessage::from).collect(), page: raw.page, total_pages: raw.total_pages, } } } impl From for ThreadMessage { /// Media indices are counted the way SMS-API's media routes count them: /// SMIL layout parts are skipped and only image/video/audio parts take /// an index, in part order. fn from(raw: RawThreadMessage) -> Self { let mut attachments = Vec::new(); let mut body = raw.body; for part in raw.parts { let ct = part.content_type.to_ascii_lowercase(); if ct == "application/smil" { continue; } if ct.starts_with("image/") || ct.starts_with("video/") || ct.starts_with("audio/") { attachments.push(ThreadAttachment { media_index: attachments.len(), content_type: part.content_type, filename: part.filename, }); } else if ct == "text/plain" && body.trim().is_empty() && !part.text.trim().is_empty() { // MMS keeps its caption in a text part, leaving `body` empty. body = part.text; } } ThreadMessage { id: raw.id, body, timestamp: raw.date, is_sent: raw.type_ == 2, attachments, } } } #[derive(Deserialize)] struct SmsApiResponse { messages: Vec, } #[derive(Deserialize)] struct SmsApiMessage { contact_name: String, body: String, date: i64, #[serde(rename = "type")] type_: i32, } #[derive(Debug, Clone, Deserialize)] pub struct SmsSearchHit { #[allow(dead_code)] pub message_id: i64, pub contact_name: String, #[allow(dead_code)] pub contact_address: String, pub body: String, pub date: i64, /// Message direction code: 1 = received, 2 = sent. #[serde(rename = "type")] pub type_: i32, /// Present for semantic / hybrid modes; absent for fts5. #[serde(default)] pub similarity_score: Option, /// SMS-API-generated excerpt around the match, wrapped in `` tags. /// For MMS messages that only matched via attachment text / filename /// (empty `body`), the snippet is the only meaningful preview. #[serde(default)] pub snippet: Option, } /// Optional filter / paging knobs for [`SmsApiClient::search_messages`]. /// All fields except `mode` and `limit` map 1:1 to the same-named SMS-API /// query params (added in the 2026-05 search-enhancements release). #[derive(Debug, Clone)] pub struct SmsSearchParams<'a> { pub mode: &'a str, pub limit: usize, pub contact_id: Option, /// Contact name (case-insensitive). Resolved to a numeric ID by the /// SMS-API server when `contact_id` is not set. pub contact: Option, /// Unix-seconds inclusive lower bound on `date`. pub date_from: Option, /// Unix-seconds inclusive upper bound on `date`. pub date_to: Option, /// `Some(true)` = MMS only, `Some(false)` = SMS only, `None` = both. pub is_mms: Option, /// `Some(true)` = only messages with image/video/audio attachments. pub has_media: Option, pub offset: Option, } #[derive(Deserialize)] struct SmsSearchResponse { results: Vec, #[allow(dead_code)] #[serde(default)] search_method: String, } #[cfg(test)] mod tests { use super::*; #[test] fn window_for_radius_produces_2n_day_span() { let center: i64 = 1_700_000_000; let (start, end) = SmsApiClient::window_for_radius(center, 7); assert_eq!(end - start, 14 * 86400); assert_eq!(start + 7 * 86400, center); assert_eq!(end - 7 * 86400, center); } #[test] fn thread_message_indexes_media_parts_like_sms_api() { // SMIL is skipped and the text part only fills an empty body, so // the image and video take media indices 0 and 1 — matching the // `/media/{id}/{index}/file/` route. let raw: RawThreadMessage = serde_json::from_value(serde_json::json!({ "id": 7, "body": "", "date": 1_700_000_000, "type": 2, "parts": [ { "content_type": "application/smil", "filename": "smil.xml" }, { "content_type": "image/jpeg", "filename": "IMG_1.jpg" }, { "content_type": "text/plain", "text": "look at this" }, { "content_type": "video/mp4", "filename": "clip.mp4" } ] })) .unwrap(); let msg = ThreadMessage::from(raw); assert!(msg.is_sent); assert_eq!(msg.body, "look at this"); assert_eq!( msg.attachments .iter() .map(|a| (a.media_index, a.filename.as_str())) .collect::>(), vec![(0, "IMG_1.jpg"), (1, "clip.mp4")] ); } #[test] fn mms_image_check_refuses_non_images_and_huge_files_before_download() { assert!(check_mms_image_response(Some("image/jpeg"), Some(2_000_000)).is_ok()); assert!(check_mms_image_response(Some("IMAGE/PNG"), None).is_ok()); assert!(check_mms_image_response(None, None).is_ok()); assert!( check_mms_image_response(Some("video/mp4"), Some(1)) .unwrap_err() .contains("video/mp4") ); assert!(check_mms_image_response(Some("audio/amr"), None).is_err()); assert!( check_mms_image_response(Some("image/jpeg"), Some(MMS_IMAGE_MAX_BYTES as u64 + 1)) .is_err() ); } #[test] fn thread_contact_falls_back_to_the_address() { let c = ThreadContact { id: 1, name: " ".to_string(), address: "+15551234".to_string(), }; assert_eq!(c.display_name(), "+15551234"); } #[test] fn window_for_radius_clamps_zero_to_one() { let (start, end) = SmsApiClient::window_for_radius(100_000, 0); assert_eq!(end - start, 2 * 86400); } #[test] fn window_for_radius_clamps_negative_to_one() { let (start, end) = SmsApiClient::window_for_radius(100_000, -7); assert_eq!(end - start, 2 * 86400); } }