- 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>
831 lines
27 KiB
Rust
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!("×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<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!("×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<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);
|
|
}
|
|
}
|