Files
ImageApi/src/ai/sms_client.rs
T
Cameron CordesandClaude Opus 5.5 e029c81274 fix(persona-chat): review fixes before merge
- Thread tools are bound server-side to the chat's own thread. The model
  used to pass contact_id, so practice mode could read any conversation it
  named; a model-supplied contact_id is now only checked, never trusted, and
  the prompts no longer hand it one.
- Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID
  and run with an inert stand-in, so deleting or editing personas can't hide
  or break them. persona_id is optional on create for as_contact.
- The per-thread list filters in SQL (list_persona_chats_for_contact, served
  by idx_persona_chat_anchor_contact) instead of decoding every transcript.
- fetch_mms_image checks the content type and a 20 MB cap before reading
  the body, so a video attachment isn't downloaded in full.
- get_photos_near_time unions tags over every path sharing the content, per
  the multi-library model.
- look_at_photo only opens indexed photos, so an excluded folder can't be
  read by guessing a path.
- One date parser: get_photos_near_time uses date_info::parse_center.
- Practice prompt source rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:38:08 -04:00

831 lines
27 KiB
Rust

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<String>,
}
impl SmsApiClient {
pub fn new(base_url: String, token: Option<String>) -> 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<Vec<SmsMessage>> {
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<Vec<SmsMessage>> {
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<i64>,
limit: usize,
offset: usize,
) -> Result<Vec<SmsMessage>> {
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!("&timestamp={}", 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<i64>,
) -> Result<Vec<SmsMessage>> {
// 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!("&timestamp={}", 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<Vec<SmsSearchHit>> {
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<i64>,
page: usize,
page_size: usize,
) -> Result<ThreadPage> {
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<ThreadWindow> {
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<ThreadMessage> = first.messages[..idx].to_vec();
let anchor = first.messages[idx].clone();
let mut older: Vec<ThreadMessage> = 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<ThreadMessage> = 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<Vec<u8>> {
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<String> {
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::<Vec<_>>()
.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<u64>,
) -> 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<ThreadAttachment>,
}
#[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<ThreadMessage>,
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<ThreadMessage>,
/// Index of the anchor message within `messages`.
pub anchor_index: usize,
}
#[derive(Deserialize)]
struct RawThreadPage {
contact: ThreadContact,
messages: Vec<RawThreadMessage>,
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<RawThreadPart>,
}
#[derive(Deserialize)]
struct RawThreadPart {
content_type: String,
#[serde(default)]
filename: String,
#[serde(default)]
text: String,
}
impl From<RawThreadPage> 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<RawThreadMessage> 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<SmsApiMessage>,
}
#[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<f32>,
/// SMS-API-generated excerpt around the match, wrapped in `<mark>` 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<String>,
}
/// 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<i64>,
/// Contact name (case-insensitive). Resolved to a numeric ID by the
/// SMS-API server when `contact_id` is not set.
pub contact: Option<String>,
/// Unix-seconds inclusive lower bound on `date`.
pub date_from: Option<i64>,
/// Unix-seconds inclusive upper bound on `date`.
pub date_to: Option<i64>,
/// `Some(true)` = MMS only, `Some(false)` = SMS only, `None` = both.
pub is_mms: Option<bool>,
/// `Some(true)` = only messages with image/video/audio attachments.
pub has_media: Option<bool>,
pub offset: Option<usize>,
}
#[derive(Deserialize)]
struct SmsSearchResponse {
results: Vec<SmsSearchHit>,
#[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<_>>(),
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);
}
}