use crate::signal::types::*;
pub(super) fn parse_attachment(
value: &serde_json::Value,
download_dir: &std::path::Path,
) -> Option<Attachment> {
let id = value.get("id").and_then(|v| v.as_str())?.to_string();
let content_type = value
.get("contentType")
.and_then(|v| v.as_str())
.unwrap_or("application/octet-stream")
.to_string();
let filename = value
.get("filename")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut effective_name = filename.clone().unwrap_or_else(|| {
let ext = mime_to_ext(&content_type);
let short_id = if id.len() > 8 {
&id[id.len() - 8..]
} else {
&id
};
format!("{short_id}.{ext}")
});
if let Some(dot_pos) = effective_name.rfind('.') {
let ext = &effective_name[dot_pos..]; let base = &effective_name[..dot_pos];
if base.ends_with(ext) {
effective_name = base.to_string();
}
}
effective_name = effective_name.replace(['/', '\\'], "_").replace("..", "_");
if effective_name.is_empty() {
let short_id = if id.len() > 8 {
&id[id.len() - 8..]
} else {
&id
};
effective_name = format!("{short_id}.bin");
}
let dest = download_dir.join(&effective_name);
let canon_dir = download_dir
.canonicalize()
.unwrap_or_else(|_| download_dir.to_path_buf());
let canon_dest = dest
.canonicalize()
.unwrap_or_else(|_| canon_dir.join(&effective_name));
if !canon_dest.starts_with(&canon_dir) {
return None;
}
let local_path = if dest.exists() {
Some(dest.to_string_lossy().to_string())
} else {
let src = value
.get("file")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from)
.or_else(|| find_signal_cli_attachment(&id, &content_type));
if let Some(src) = src.filter(|p| p.exists()) {
let _ = std::fs::create_dir_all(download_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(download_dir, std::fs::Permissions::from_mode(0o700));
}
match std::fs::copy(&src, &dest) {
Ok(_) => Some(dest.to_string_lossy().to_string()),
Err(_) => Some(src.to_string_lossy().to_string()),
}
} else {
None
}
};
Some(Attachment {
id,
content_type,
filename: Some(effective_name),
local_path,
})
}
pub(super) fn parse_link_previews(
data: &serde_json::Value,
download_dir: &std::path::Path,
) -> Vec<LinkPreview> {
let arr = data
.get("previews")
.or_else(|| data.get("preview"))
.and_then(|v| v.as_array());
let Some(arr) = arr else { return Vec::new() };
arr.iter()
.filter_map(|p| {
let url = p.get("url").and_then(|v| v.as_str())?.to_string();
let title = p
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let description = p
.get("description")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let image_path = p
.get("image")
.and_then(|img| parse_attachment(img, download_dir))
.and_then(|att| att.local_path);
Some(LinkPreview {
url,
title,
description,
image_path,
})
})
.collect()
}
fn find_signal_cli_attachment(id: &str, content_type: &str) -> Option<std::path::PathBuf> {
let mut candidates = Vec::new();
if let Some(data_dir) = dirs::data_dir() {
candidates.push(data_dir.join("signal-cli").join("attachments"));
}
if let Some(home) = dirs::home_dir() {
candidates.push(
home.join(".local")
.join("share")
.join("signal-cli")
.join("attachments"),
);
}
let ext = mime_to_ext(content_type);
for attachments_dir in &candidates {
let with_ext = attachments_dir.join(format!("{id}.{ext}"));
if with_ext.exists() {
return Some(with_ext);
}
if let Ok(entries) = std::fs::read_dir(attachments_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(id) {
return Some(entry.path());
}
}
}
}
None
}
fn mime_to_ext(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"video/mp4" => "mp4",
"video/quicktime" => "mov",
"audio/mpeg" => "mp3",
"audio/ogg" => "ogg",
"audio/aac" => "aac",
"application/pdf" => "pdf",
"text/plain" => "txt",
_ => "bin",
}
}
pub(super) fn format_expiration(seconds: i64) -> String {
if seconds == 0 {
return "Disappearing messages disabled".to_string();
}
let (n, unit) = if seconds < 60 {
(seconds, "second")
} else if seconds < 3600 {
(seconds / 60, "minute")
} else if seconds < 86400 {
(seconds / 3600, "hour")
} else if seconds < 604800 {
(seconds / 86400, "day")
} else {
(seconds / 604800, "week")
};
let plural = if n == 1 { "" } else { "s" };
format!("Disappearing messages set to {n} {unit}{plural}")
}
pub(super) fn parse_mentions(data: &serde_json::Value) -> Vec<Mention> {
let arr = data
.get("mentions")
.and_then(|v| v.as_array())
.or_else(|| data.get("bodyRanges").and_then(|v| v.as_array()));
arr.map(|items| {
items
.iter()
.filter_map(|r| {
let start = r.get("start").and_then(|v| v.as_u64())? as usize;
let length = r.get("length").and_then(|v| v.as_u64())? as usize;
let uuid = r
.get("uuid")
.or_else(|| r.get("mentionUuid"))
.and_then(|v| v.as_str())?
.to_string();
Some(Mention {
start,
length,
uuid,
})
})
.collect()
})
.unwrap_or_default()
}
pub(super) fn parse_text_styles(data: &serde_json::Value) -> Vec<TextStyle> {
let arr = data
.get("textStyles")
.and_then(|v| v.as_array())
.or_else(|| data.get("bodyRanges").and_then(|v| v.as_array()));
arr.map(|items| {
items
.iter()
.filter_map(|r| {
let start = r.get("start").and_then(|v| v.as_u64())? as usize;
let length = r.get("length").and_then(|v| v.as_u64())? as usize;
let style_str = r.get("style").and_then(|v| v.as_str())?;
let style = match style_str {
"BOLD" => StyleType::Bold,
"ITALIC" => StyleType::Italic,
"STRIKETHROUGH" => StyleType::Strikethrough,
"MONOSPACE" => StyleType::Monospace,
"SPOILER" => StyleType::Spoiler,
_ => return None,
};
Some(TextStyle {
start,
length,
style,
})
})
.collect()
})
.unwrap_or_default()
}