Skip to main content

wechat_profile/
thread_previews.rs

1use super::*;
2
3struct PreviewRoom {
4    name: String,
5    displays: BTreeMap<String, Option<String>>,
6    first: Vec<String>,
7    last: Option<String>,
8    count: usize,
9    valid: bool,
10}
11
12impl ProfileReader {
13    /// Read the saved pin bit for ordinary contacts and active groups in one
14    /// query. Incomplete identities and unsupported built-in peers stay unknown.
15    pub fn thread_pinned(&self, threads: &[String]) -> Result<BTreeMap<String, Option<bool>>> {
16        if threads.len() > 200 {
17            return Err(ProfileError::InvalidQuery);
18        }
19        if threads.is_empty() {
20            return Ok(BTreeMap::new());
21        }
22        for thread in threads {
23            validate_name(thread)?;
24        }
25        let c = self.snapshot.open(self.role("contact.db", "contacts")?)?;
26        #[cfg(test)]
27        super::batch_lookup_tests::record_batch_query();
28        let placeholders = vec!["?"; threads.len()].join(",");
29        let mut statement = c.prepare(&format!(
30            "SELECT username,flag,local_type,delete_flag,is_in_chat_room FROM contact WHERE username IN ({placeholders})"
31        ))?;
32        let rows = statement.query_map(rusqlite::params_from_iter(threads), raw)?;
33        let mut pinned = BTreeMap::new();
34        for row in rows {
35            let fields = row?;
36            let name = text(&fields, "username").ok_or(ProfileError::InvalidMetadata)?;
37            let flag = num(&fields, "flag").and_then(|flag| u32::try_from(flag).ok());
38            let ordinary = if name.ends_with("@chatroom") {
39                num(&fields, "local_type") == Some(2)
40                    && num(&fields, "is_in_chat_room") == Some(1)
41                    && flag.is_some_and(|flag| flag & 2 != 0)
42            } else {
43                !name.contains('@')
44                    && name != "filehelper"
45                    && !name.starts_with("weixin")
46                    && !name.starts_with("gh_")
47                    && ![
48                        "brandsessionholder",
49                        "brandservicesessionholder",
50                        "qmessage",
51                        "medianote",
52                        "qqsync",
53                        "lbsapp",
54                        "shakeapp",
55                        "floatbottle",
56                        "masssendapp",
57                        "appbrandcustomerservicemsg",
58                        "service_officialaccounts",
59                        "notifymessage",
60                        "notification_messages",
61                    ]
62                    .contains(&name.as_str())
63                    && num(&fields, "local_type") == Some(1)
64                    && flag.is_some_and(|flag| flag & 1 != 0)
65            };
66            let value = (ordinary && contact_not_deleted(&fields))
67                .then(|| flag.map(|flag| flag & 0x800 != 0))
68                .flatten();
69            pinned.insert(name, value);
70        }
71        Ok(pinned)
72    }
73
74    /// Derive notification preferences for a page without refreshing contacts.
75    /// Direct contacts and groups use different official fields; incomplete
76    /// identities and unexpected values remain unknown.
77    pub fn thread_muted(&self, threads: &[String]) -> Result<BTreeMap<String, Option<bool>>> {
78        if threads.len() > 200 {
79            return Err(ProfileError::InvalidQuery);
80        }
81        if threads.is_empty() {
82            return Ok(BTreeMap::new());
83        }
84        for thread in threads {
85            validate_name(thread)?;
86        }
87        let c = self.snapshot.open(self.role("contact.db", "contacts")?)?;
88        #[cfg(test)]
89        super::batch_lookup_tests::record_batch_query();
90        let placeholders = vec!["?"; threads.len()].join(",");
91        let mut statement = c.prepare(&format!(
92            "SELECT username,flag,chat_room_notify,local_type,delete_flag FROM contact WHERE username IN ({placeholders})"
93        ))?;
94        let rows = statement.query_map(rusqlite::params_from_iter(threads), raw)?;
95        let mut muted = BTreeMap::new();
96        for row in rows {
97            let fields = row?;
98            let name = text(&fields, "username").ok_or(ProfileError::InvalidMetadata)?;
99            let value = if name.ends_with("@chatroom") {
100                match num(&fields, "chat_room_notify") {
101                    Some(0) => Some(true),
102                    Some(1) => Some(false),
103                    _ => None,
104                }
105            } else if num(&fields, "local_type") == Some(1) && contact_not_deleted(&fields) {
106                num(&fields, "flag")
107                    .and_then(|flag| u32::try_from(flag).ok())
108                    .filter(|flag| flag & 1 != 0)
109                    .map(|flag| flag & 0x200 != 0)
110            } else {
111                None
112            };
113            muted.insert(name, value);
114        }
115        Ok(muted)
116    }
117
118    /// Read avatar members for a page of groups with three batched queries.
119    /// Validate membership against the same official room metadata used by
120    /// group_members; an inconsistent room does not hide other valid rooms.
121    pub fn group_avatar_members(
122        &self,
123        rooms: &[String],
124    ) -> Result<BTreeMap<String, Vec<GroupMember>>> {
125        if rooms.len() > 200 {
126            return Err(ProfileError::InvalidQuery);
127        }
128        if rooms.is_empty() {
129            return Ok(BTreeMap::new());
130        }
131        for room in rooms {
132            validate_name(room)?;
133        }
134        let c = self.snapshot.open(self.role("contact.db", "groups")?)?;
135        let placeholders = vec!["?"; rooms.len()].join(",");
136        let mut statement = c.prepare(&format!(
137            "SELECT id,username,ext_buffer FROM chat_room WHERE username IN ({placeholders})"
138        ))?;
139        let rows = statement.query_map(rusqlite::params_from_iter(rooms), |row| {
140            Ok((
141                row.get::<_, i64>(0)?,
142                row.get::<_, String>(1)?,
143                row.get::<_, Option<Vec<u8>>>(2)?,
144            ))
145        })?;
146        let mut selected = BTreeMap::new();
147        for row in rows {
148            let (id, name, bytes) = row?;
149            let Ok(displays) = room_displays(bytes.as_deref().unwrap_or_default()) else {
150                continue;
151            };
152            selected.insert(
153                id,
154                PreviewRoom {
155                    name,
156                    displays,
157                    first: Vec::new(),
158                    last: None,
159                    count: 0,
160                    valid: true,
161                },
162            );
163        }
164        if selected.is_empty() {
165            return Ok(BTreeMap::new());
166        }
167        let placeholders = vec!["?"; selected.len()].join(",");
168        let ids: Vec<_> = selected.keys().copied().collect();
169        let mut statement = c.prepare(&format!("SELECT m.room_id,c.username FROM chatroom_member m JOIN contact c ON c.id=m.member_id WHERE m.room_id IN ({placeholders}) ORDER BY m.room_id,c.username"))?;
170        let rows = statement.query_map(rusqlite::params_from_iter(ids), |row| {
171            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
172        })?;
173        for row in rows {
174            let (id, username) = row?;
175            let room = selected.get_mut(&id).ok_or(ProfileError::InvalidMetadata)?;
176            if room.last.as_ref() == Some(&username) {
177                continue;
178            }
179            room.last = Some(username.clone());
180            room.count += 1;
181            if room.count > 100_000 {
182                return Err(ProfileError::ResourceLimit);
183            }
184            if !room.displays.is_empty() && !room.displays.contains_key(&username) {
185                room.valid = false
186            }
187            if room.first.len() < 4 {
188                room.first.push(username)
189            }
190        }
191        selected.retain(|_, room| {
192            room.valid && (room.displays.is_empty() || room.displays.len() == room.count)
193        });
194        let names: BTreeSet<_> = selected
195            .values()
196            .flat_map(|room| room.first.iter())
197            .collect();
198        let mut contacts = BTreeMap::new();
199        if !names.is_empty() {
200            let placeholders = vec!["?"; names.len()].join(",");
201            let mut statement = c.prepare(&format!(
202                "SELECT * FROM contact WHERE username IN ({placeholders})"
203            ))?;
204            let rows = statement.query_map(rusqlite::params_from_iter(names), raw)?;
205            for row in rows {
206                let value = contact(row?)?;
207                contacts.insert(value.username.clone(), value);
208            }
209        }
210        Ok(selected
211            .into_values()
212            .map(|room| {
213                let members = room
214                    .first
215                    .into_iter()
216                    .filter_map(|name| {
217                        contacts.get(&name).cloned().map(|contact| {
218                            group_member(contact, room.displays.get(&name).cloned().flatten())
219                        })
220                    })
221                    .collect();
222                (room.name, members)
223            })
224            .collect())
225    }
226}