feat: expose avatars and image quality metadata

This commit is contained in:
pandorafuture
2026-07-22 11:20:16 +08:00
parent 7a8f881d33
commit 7bed9e767a
17 changed files with 247 additions and 34 deletions
+2
View File
@@ -47,6 +47,7 @@ mod tests {
last_sender_display_name: None,
},
display_name: "wxid_friend".to_string(),
avatar_url: None,
direction: None,
detected_at: None,
},
@@ -70,6 +71,7 @@ mod tests {
last_sender_display_name: None,
},
display_name: "wxid_friend".to_string(),
avatar_url: None,
direction: None,
detected_at: Some(2),
},
+19
View File
@@ -42,6 +42,7 @@ enum MediaPayload {
InlineBytes {
bytes: Vec<u8>,
content_type: &'static str,
quality: Option<&'static str>,
},
ServePath {
path: PathBuf,
@@ -56,11 +57,17 @@ impl MediaPayload {
Self::InlineBytes {
bytes,
content_type,
quality,
} => {
let mut response = bytes.into_response();
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
if let Some(quality) = quality {
response
.headers_mut()
.insert("x-wechat-media-quality", HeaderValue::from_static(quality));
}
Ok(response)
}
Self::ServePath {
@@ -242,6 +249,14 @@ fn resolve_image(
let dat_path = lookup.recommended.ok_or_else(|| {
ServeError::NotFound(format!("no candidate image file found for md5={md5}"))
})?;
let quality = if dat_path
.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with("_t.dat"))
{
"thumbnail"
} else {
"full"
};
let data = std::fs::read(&dat_path)
.map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?;
let decoded = wx_media::decrypt_dat(&data, &dat_decrypt)
@@ -259,12 +274,14 @@ fn resolve_image(
return Ok(MediaPayload::InlineBytes {
bytes: transcoded.data,
content_type: image_content_type(transcoded.ext),
quality: Some(quality),
});
}
Ok(MediaPayload::InlineBytes {
bytes: decoded.data,
content_type: image_content_type(&decoded.ext),
quality: Some(quality),
})
}
@@ -288,6 +305,7 @@ fn resolve_voice(
return Ok(MediaPayload::InlineBytes {
bytes: cached.bytes.clone(),
content_type: cached.content_type,
quality: None,
});
}
}
@@ -331,6 +349,7 @@ fn resolve_voice(
return Ok(MediaPayload::InlineBytes {
bytes: result.data,
content_type: result.mime,
quality: None,
});
}
Err(wx_media::MediaError::LookupMiss(_))
+1
View File
@@ -65,6 +65,7 @@ fn format_watch_line(
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
+9
View File
@@ -17,6 +17,8 @@ pub struct EnrichedSession {
pub session: Session,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<Direction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_at: Option<i64>,
@@ -61,10 +63,12 @@ pub fn enrich_session(
detected_at: Option<i64>,
) -> EnrichedSession {
let display_name = resolver.display_with_id(&session.username);
let avatar_url = resolver.avatar_url(&session.username).map(str::to_string);
let direction = derive_session_direction(session.last_msg_sender.as_deref(), self_wxid);
EnrichedSession {
session,
display_name,
avatar_url,
direction,
detected_at,
}
@@ -76,6 +80,7 @@ pub fn enrich_session_event(
resolver: &ContactResolver,
) -> EnrichedSession {
let display_name = resolver.display_with_id(&ev.username);
let avatar_url = resolver.avatar_url(&ev.username).map(str::to_string);
let direction = derive_session_direction(ev.last_msg_sender.as_deref(), self_wxid);
let session = Session {
username: ev.username,
@@ -88,6 +93,7 @@ pub fn enrich_session_event(
EnrichedSession {
session,
display_name,
avatar_url,
direction,
detected_at: Some(ev.detected_at),
}
@@ -743,6 +749,7 @@ mod tests {
last_sender_display_name: None,
},
display_name: "Spam".to_string(),
avatar_url: None,
direction: Some(Direction::Incoming),
detected_at: None,
};
@@ -764,6 +771,7 @@ mod tests {
last_sender_display_name: Some("Spammer".to_string()),
},
display_name: "Group".to_string(),
avatar_url: None,
direction: Some(Direction::Incoming),
detected_at: None,
};
@@ -787,6 +795,7 @@ mod tests {
last_sender_display_name: Some("Normal".to_string()),
},
display_name: "Group".to_string(),
avatar_url: None,
direction: Some(Direction::Incoming),
detected_at: None,
};
+42
View File
@@ -4,6 +4,7 @@ use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
@@ -18,6 +19,7 @@ const MSG_TABLE: &str = "Msg_29a6db07e8bbdb53f5d54cc3c309f3f1";
const GROUP_TALKER: &str = "test@chatroom";
const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8";
const HIDDEN_SENDER: &str = "wxid_spam";
static SERVER_START_LOCK: Mutex<()> = Mutex::new(());
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_wx-cli")
@@ -150,9 +152,22 @@ fn serve_media_dispatch_image_returns_png_bytes() {
);
assert_eq!(response.status_code, 200, "{response:#?}");
assert_eq!(response.header("content-type"), Some("image/png"));
assert_eq!(response.header("x-wechat-media-quality"), Some("full"));
assert_eq!(&response.body[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn serve_media_thumbnail_only_image_reports_quality() {
let server = spawn_test_server();
let response = http_get(
&server.base_url,
"/api/v1/media?server_id=3009&talker=wxid_alice",
);
assert_eq!(response.status_code, 200, "{response:#?}");
assert_eq!(response.header("content-type"), Some("image/png"));
assert_eq!(response.header("x-wechat-media-quality"), Some("thumbnail"));
}
#[test]
fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() {
let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]);
@@ -326,6 +341,10 @@ fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str])
}
let account_dir = fixture.path().join(TEST_ACCOUNT_ID);
let runtime_root = fixture.path().join("runtime");
// Keep ephemeral-port selection and worker binding atomic across parallel tests.
let _start_guard = SERVER_START_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let port = find_open_port();
let mut command = Command::new(bin());
command
@@ -712,6 +731,27 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
)
.expect("insert image ok message");
let image_thumb_only_info =
encode_packed_info_for_test(Some("md5_image_thumb_only"), None);
conn.execute(
&format!(
"INSERT INTO [{table}] VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
table = MSG_TABLE
),
params![
301_i64,
3009_i64,
3_i64,
1_i64,
1_709_251_200_i64,
Vec::<u8>::new(),
image_thumb_only_info,
0_i32,
None::<i32>,
],
)
.expect("insert thumbnail-only image message");
let image_wxgf_png_info = encode_packed_info_for_test(Some("md5_image_wxgf_png"), None);
conn.execute(
&format!(
@@ -985,6 +1025,8 @@ fn create_image_fixture(attach_dir: &Path) {
let encrypted = xor_bytes(&png, xor_key);
fs::write(month_dir.join("md5_image_ok_t.dat"), &encrypted).expect("write thumb dat");
fs::write(month_dir.join("md5_image_ok.dat"), &encrypted).expect("write image dat");
fs::write(month_dir.join("md5_image_thumb_only_t.dat"), &encrypted)
.expect("write thumbnail-only dat");
let wxgf_png = sample_wxgf_with_embedded_png();
let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key);
+11
View File
@@ -15,6 +15,7 @@ struct ResolvedContact {
signature: Option<String>,
region: Option<String>,
labels: Vec<String>,
avatar_url: Option<String>,
}
pub struct ContactResolver {
@@ -56,6 +57,7 @@ impl ContactResolver {
signature: c.signature,
region: c.region,
labels: c.labels,
avatar_url: c.avatar_url,
},
);
}
@@ -92,6 +94,13 @@ impl ContactResolver {
.unwrap_or(&[])
}
/// Resolve a wxid to its preferred avatar URL.
pub fn avatar_url(&self, wxid: &str) -> Option<&str> {
self.contacts
.get(wxid)
.and_then(|contact| contact.avatar_url.as_deref())
}
/// Iterate all contacts with their wxid and labels.
/// Used by VisibilityIndex to expand ignore_tags.
pub fn all_labels(&self) -> impl Iterator<Item = (&String, &[String])> {
@@ -200,6 +209,7 @@ mod tests {
signature: None,
region: None,
labels: Vec::new(),
avatar_url: None,
},
)
})
@@ -235,6 +245,7 @@ mod tests {
signature: None,
region: None,
labels: labels.iter().map(|s| s.to_string()).collect(),
avatar_url: None,
},
)
})
+34 -8
View File
@@ -43,16 +43,19 @@ impl WechatDb {
limit: usize,
label_map: &HashMap<String, String>,
) -> Result<QueryResult<Contact>, DbError> {
let avatar_expr = self.contact_avatar_select_expr()?;
let total_rows: usize =
self.contact_conn
.query_row("SELECT COUNT(*) FROM contact", [], |row| {
row.get::<_, i64>(0)
})? as usize;
let mut stmt = self.contact_conn.prepare(
"SELECT username, alias, remark, nick_name, description, extra_buffer \
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2",
)?;
let sql = format!(
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
{avatar_expr} AS avatar_url \
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2"
);
let mut stmt = self.contact_conn.prepare(&sql)?;
let rows = stmt.query_map(
[
Value::Integer(limit as i64),
@@ -81,6 +84,7 @@ impl WechatDb {
label_map: &HashMap<String, String>,
) -> Result<QueryResult<Contact>, DbError> {
let kw_lower = query.keyword.as_ref().unwrap().to_lowercase();
let avatar_expr = self.contact_avatar_select_expr()?;
let total_rows: usize =
self.contact_conn
@@ -88,10 +92,12 @@ impl WechatDb {
row.get::<_, i64>(0)
})? as usize;
let mut stmt = self.contact_conn.prepare(
"SELECT username, alias, remark, nick_name, description, extra_buffer \
FROM contact ORDER BY username ASC",
)?;
let sql = format!(
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
{avatar_expr} AS avatar_url \
FROM contact ORDER BY username ASC"
);
let mut stmt = self.contact_conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| self.map_contact_row(row, label_map))?;
let all_contacts: Vec<Contact> = rows.filter_map(|r| r.ok()).collect();
@@ -126,6 +132,10 @@ impl WechatDb {
let nick_name: String = row.get::<_, String>(3).unwrap_or_default();
let memo: Option<String> = row.get::<_, Option<String>>(4).unwrap_or(None);
let extra_buffer: Vec<u8> = row.get::<_, Vec<u8>>(5).unwrap_or_default();
let avatar_url: Option<String> = row
.get::<_, Option<String>>(6)
.unwrap_or(None)
.filter(|value| !value.is_empty());
let extra = contact_proto::decode_extra_buffer(&extra_buffer);
@@ -147,6 +157,22 @@ impl WechatDb {
source_scene: extra.source_scene,
phone: extra.phone,
labels,
avatar_url,
})
}
/// Build a compatible avatar expression for WeChat database variants.
/// Older fixtures and database versions may not contain either column.
fn contact_avatar_select_expr(&self) -> Result<&'static str, DbError> {
let has_small =
decode::check_column_exists(&self.contact_conn, "contact", "small_head_url")?;
let has_big = decode::check_column_exists(&self.contact_conn, "contact", "big_head_url")?;
Ok(match (has_small, has_big) {
(true, true) => "COALESCE(NULLIF(small_head_url, ''), NULLIF(big_head_url, ''))",
(true, false) => "NULLIF(small_head_url, '')",
(false, true) => "NULLIF(big_head_url, '')",
(false, false) => "NULL",
})
}
+3
View File
@@ -380,6 +380,9 @@ pub struct Contact {
pub phone: Option<String>,
/// Resolved label names from extra_buffer + contact_label table.
pub labels: Vec<String>,
/// Preferred avatar URL from `small_head_url`, falling back to `big_head_url`.
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
}
/// A WeChat chatroom (group chat) entry with its member list.
+49
View File
@@ -282,6 +282,55 @@ fn contacts_sessions_query_contacts_limit() {
assert_eq!(result.items.len(), 1);
}
#[test]
fn contacts_sessions_query_contacts_avatar_url() {
let dir = create_fixture();
let contact_path = dir.path().join("contact").join("contact.db");
let conn = Connection::open(contact_path).unwrap();
conn.execute_batch(
"ALTER TABLE contact ADD COLUMN small_head_url TEXT;
ALTER TABLE contact ADD COLUMN big_head_url TEXT;",
)
.unwrap();
conn.execute(
"UPDATE contact SET small_head_url = ?1, big_head_url = ?2 WHERE username = ?3",
params![
"https://avatar.example/alice-small.jpg",
"https://avatar.example/alice-big.jpg",
"wxid_alice"
],
)
.unwrap();
conn.execute(
"UPDATE contact SET big_head_url = ?1 WHERE username = ?2",
params!["https://avatar.example/bob-big.jpg", "wxid_bob"],
)
.unwrap();
drop(conn);
let db = WechatDb::open(dir.path()).unwrap();
let result = db.query_contacts(&ContactQuery::new()).unwrap();
let alice = result
.items
.iter()
.find(|contact| contact.user_name == "wxid_alice")
.unwrap();
let bob = result
.items
.iter()
.find(|contact| contact.user_name == "wxid_bob")
.unwrap();
assert_eq!(
alice.avatar_url.as_deref(),
Some("https://avatar.example/alice-small.jpg")
);
assert_eq!(
bob.avatar_url.as_deref(),
Some("https://avatar.example/bob-big.jpg")
);
}
// ---- sessions tests ----
#[test]
+12 -12
View File
@@ -15,21 +15,21 @@ fn make_xor_dat(key: u8) -> Vec<u8> {
/// Header: 07 08 V1 08 07 (6B) + aes_size LE (4B) + xor_size LE (4B) + 0x01 (1B) = 15B
/// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail.
fn make_v1_dat() -> Vec<u8> {
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
use aes::Aes128;
let key = b"cfcd208495d565ef"; // md5("0")[:16]
let cipher = Aes128::new(key.into());
// Plaintext: JPEG header (16 bytes = 1 AES block) with PKCS7 padding
let mut block1 = [
let mut block1 = Array::from([
0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
0x01,
];
let mut block2 = [16u8; 16]; // full PKCS7 padding block
]);
let mut block2 = Array::from([16u8; 16]); // full PKCS7 padding block
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
cipher.encrypt_block(&mut block1);
cipher.encrypt_block(&mut block2);
let aes_size: u32 = 16; // original plaintext size
let xor_size: u32 = 0;
@@ -46,20 +46,20 @@ fn make_v1_dat() -> Vec<u8> {
/// Build a V2-encrypted `.dat` file with known AES key and XOR tail.
fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec<u8> {
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
use aes::Aes128;
let cipher = Aes128::new(aes_key.into());
// Plaintext: PNG header (16 bytes = 1 block)
let mut block1 = [
let mut block1 = Array::from([
0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52,
];
let mut block2 = [16u8; 16]; // PKCS7 padding block
]);
let mut block2 = Array::from([16u8; 16]); // PKCS7 padding block
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
cipher.encrypt_block(&mut block1);
cipher.encrypt_block(&mut block2);
let aes_size: u32 = 16;
// Tail: 4 bytes XOR-encrypted
+3 -3
View File
@@ -369,21 +369,21 @@ mod tests {
#[test]
fn server_lock_file_under_server_state() {
let ap = AppPaths::new().unwrap();
assert!(ap.server_lock_file().starts_with(&ap.server_state_dir()));
assert!(ap.server_lock_file().starts_with(ap.server_state_dir()));
assert!(ap.server_lock_file().ends_with("manager.lock"));
}
#[test]
fn server_config_file_under_server_state() {
let ap = AppPaths::new().unwrap();
assert!(ap.server_config_file().starts_with(&ap.server_state_dir()));
assert!(ap.server_config_file().starts_with(ap.server_state_dir()));
assert!(ap.server_config_file().ends_with("config.json"));
}
#[test]
fn server_state_file_under_server_state() {
let ap = AppPaths::new().unwrap();
assert!(ap.server_state_file().starts_with(&ap.server_state_dir()));
assert!(ap.server_state_file().starts_with(ap.server_state_dir()));
assert!(ap.server_state_file().ends_with("state.json"));
}