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 && num(&fields, "delete_flag") == Some(0))
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)
106                && num(&fields, "delete_flag") == Some(0)
107            {
108                num(&fields, "flag")
109                    .and_then(|flag| u32::try_from(flag).ok())
110                    .filter(|flag| flag & 1 != 0)
111                    .map(|flag| flag & 0x200 != 0)
112            } else {
113                None
114            };
115            muted.insert(name, value);
116        }
117        Ok(muted)
118    }
119
120    /// Read avatar members for a page of groups with three batched queries.
121    /// Validate membership against the same official room metadata used by
122    /// group_members; an inconsistent room does not hide other valid rooms.
123    pub fn group_avatar_members(
124        &self,
125        rooms: &[String],
126    ) -> Result<BTreeMap<String, Vec<GroupMember>>> {
127        if rooms.len() > 200 {
128            return Err(ProfileError::InvalidQuery);
129        }
130        if rooms.is_empty() {
131            return Ok(BTreeMap::new());
132        }
133        for room in rooms {
134            validate_name(room)?;
135        }
136        let c = self.snapshot.open(self.role("contact.db", "groups")?)?;
137        let placeholders = vec!["?"; rooms.len()].join(",");
138        let mut statement = c.prepare(&format!(
139            "SELECT id,username,ext_buffer FROM chat_room WHERE username IN ({placeholders})"
140        ))?;
141        let rows = statement.query_map(rusqlite::params_from_iter(rooms), |row| {
142            Ok((
143                row.get::<_, i64>(0)?,
144                row.get::<_, String>(1)?,
145                row.get::<_, Option<Vec<u8>>>(2)?,
146            ))
147        })?;
148        let mut selected = BTreeMap::new();
149        for row in rows {
150            let (id, name, bytes) = row?;
151            let Ok(displays) = room_displays(bytes.as_deref().unwrap_or_default()) else {
152                continue;
153            };
154            selected.insert(
155                id,
156                PreviewRoom {
157                    name,
158                    displays,
159                    first: Vec::new(),
160                    last: None,
161                    count: 0,
162                    valid: true,
163                },
164            );
165        }
166        if selected.is_empty() {
167            return Ok(BTreeMap::new());
168        }
169        let placeholders = vec!["?"; selected.len()].join(",");
170        let ids: Vec<_> = selected.keys().copied().collect();
171        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"))?;
172        let rows = statement.query_map(rusqlite::params_from_iter(ids), |row| {
173            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
174        })?;
175        for row in rows {
176            let (id, username) = row?;
177            let room = selected.get_mut(&id).ok_or(ProfileError::InvalidMetadata)?;
178            if room.last.as_ref() == Some(&username) {
179                continue;
180            }
181            room.last = Some(username.clone());
182            room.count += 1;
183            if room.count > 100_000 {
184                return Err(ProfileError::ResourceLimit);
185            }
186            if !room.displays.is_empty() && !room.displays.contains_key(&username) {
187                room.valid = false
188            }
189            if room.first.len() < 4 {
190                room.first.push(username)
191            }
192        }
193        selected.retain(|_, room| {
194            room.valid && (room.displays.is_empty() || room.displays.len() == room.count)
195        });
196        let names: BTreeSet<_> = selected
197            .values()
198            .flat_map(|room| room.first.iter())
199            .collect();
200        let mut contacts = BTreeMap::new();
201        if !names.is_empty() {
202            let placeholders = vec!["?"; names.len()].join(",");
203            let mut statement = c.prepare(&format!(
204                "SELECT * FROM contact WHERE username IN ({placeholders})"
205            ))?;
206            let rows = statement.query_map(rusqlite::params_from_iter(names), raw)?;
207            for row in rows {
208                let value = contact(row?)?;
209                contacts.insert(value.username.clone(), value);
210            }
211        }
212        Ok(selected
213            .into_values()
214            .map(|room| {
215                let members = room
216                    .first
217                    .into_iter()
218                    .filter_map(|name| {
219                        contacts.get(&name).cloned().map(|contact| {
220                            group_member(contact, room.displays.get(&name).cloned().flatten())
221                        })
222                    })
223                    .collect();
224                (room.name, members)
225            })
226            .collect())
227    }
228}