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>
This commit is contained in:
1 parent
185a1b561a
commit
e029c81274
8 files changed
+383
-116
No files matched your search
+65
-6
@@ -425,10 +425,14 @@ impl SmsApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Download one MMS attachment. `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.
|
||||
pub async fn fetch_mms_media(
|
||||
/// 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,
|
||||
@@ -442,14 +446,29 @@ impl SmsApiClient {
|
||||
if let Some(token) = &self.token {
|
||||
request = request.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
let response = request.send().await?;
|
||||
let mut response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"MMS media request failed: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
Ok(response.bytes().await?.to_vec())
|
||||
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(
|
||||
@@ -504,6 +523,29 @@ pub struct SmsMessage {
|
||||
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 {
|
||||
@@ -747,6 +789,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
Reference in new issue
Block a user