Skip to main content

wechat_protocol/
quote_send.rs

1//! Quoted app-message metadata. Callers resolve the selected message and persist
2//! this reference and the final XML before submitting a send attempt.
3use crate::{protobuf as p, wire::base_request, AccountState};
4use anyhow::{ensure, Result};
5use serde::{Deserialize, Serialize};
6
7pub const MAX_QUOTE_CONTENT_BYTES: usize = 128 * 1024;
8pub const MAX_QUOTE_XML_BYTES: usize = 256 * 1024;
9
10/// A reference to the immediate selected message. App messages retain raw type
11/// 49 here; their application subtype belongs in their serialized content.
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct QuoteReference {
15    pub message_type: i32,
16    pub server_id: String,
17    pub from_user: String,
18    pub chat_user: String,
19    pub display_name: String,
20    pub content: String,
21    pub created_at: u32,
22    pub msg_source: Option<String>,
23}
24
25fn valid_text(value: &str, max: usize, empty: bool) -> bool {
26    (empty || !value.is_empty())
27        && value.len() <= max
28        && value.chars().all(|c| {
29            (!c.is_control() || matches!(c, '\t' | '\n' | '\r'))
30                && !matches!(c, '\u{fffe}' | '\u{ffff}')
31        })
32}
33
34fn identity(value: &str, empty: bool) -> bool {
35    valid_text(value, 256, empty)
36        && !value
37            .chars()
38            .any(|character| character.is_control() || character.is_whitespace())
39}
40
41impl QuoteReference {
42    pub fn validate(&self) -> Result<()> {
43        let server_id = self.server_id.parse::<u64>();
44        ensure!(
45            matches!(self.message_type, 1 | 3 | 34 | 42 | 43 | 47 | 48 | 49)
46                && server_id.is_ok_and(|value| value > 0 && value.to_string() == self.server_id)
47                && self.created_at > 0
48                && self.created_at <= i32::MAX as u32
49                && identity(&self.from_user, false)
50                && identity(&self.chat_user, true)
51                && valid_text(&self.display_name, 16 * 1024, true)
52                && valid_text(&self.content, MAX_QUOTE_CONTENT_BYTES, false)
53                && self.msg_source.as_deref().is_none_or(|value| valid_text(
54                    value,
55                    16 * 1024,
56                    true
57                )),
58            "invalid_quote_reference"
59        );
60        Ok(())
61    }
62}
63
64fn tag(out: &mut String, name: &str, value: &str) {
65    out.push('<');
66    out.push_str(name);
67    out.push('>');
68    for character in value.chars() {
69        match character {
70            '&' => out.push_str("&amp;"),
71            '<' => out.push_str("&lt;"),
72            '>' => out.push_str("&gt;"),
73            '"' => out.push_str("&quot;"),
74            '\'' => out.push_str("&apos;"),
75            _ => out.push(character),
76        }
77    }
78    out.push_str("</");
79    out.push_str(name);
80    out.push('>');
81}
82
83/// Build an appmsg fragment, followed by fromusername, as required by the
84/// app-message carrier. Embedded source XML remains text inside content.
85pub fn quote_xml(sender: &str, text: &str, reference: &QuoteReference) -> Result<String> {
86    reference.validate()?;
87    ensure!(
88        identity(sender, false) && !text.trim().is_empty() && valid_text(text, 16 * 1024, false),
89        "invalid_quote_request"
90    );
91    let mut out = String::from("<appmsg appid=\"\" sdkver=\"0\">");
92    for (name, value) in [
93        ("title", text),
94        ("des", ""),
95        ("action", ""),
96        ("type", "57"),
97        ("showtype", "0"),
98        ("url", ""),
99        ("lowurl", ""),
100    ] {
101        tag(&mut out, name, value);
102    }
103    out.push_str("<refermsg>");
104    tag(&mut out, "type", &reference.message_type.to_string());
105    tag(&mut out, "svrid", &reference.server_id);
106    tag(&mut out, "fromusr", &reference.from_user);
107    tag(&mut out, "chatusr", &reference.chat_user);
108    tag(&mut out, "displayname", &reference.display_name);
109    tag(&mut out, "content", &reference.content);
110    tag(&mut out, "createtime", &reference.created_at.to_string());
111    if let Some(source) = reference
112        .msg_source
113        .as_deref()
114        .filter(|value| !value.is_empty())
115    {
116        tag(&mut out, "msgsource", source);
117    }
118    out.push_str("</refermsg></appmsg>");
119    tag(&mut out, "fromusername", sender);
120    ensure!(out.len() <= MAX_QUOTE_XML_BYTES, "invalid_quote_request");
121    Ok(out)
122}
123
124/// Submit the persisted quote XML with the reply subtype in AppMsg.Type.
125/// Reference MsgSource inside the XML is separate from outgoing MsgSource.
126pub fn quote_send_request(
127    state: &AccountState,
128    destination: &str,
129    client_id: &str,
130    created_at: u32,
131    content: &str,
132) -> Result<Vec<u8>> {
133    quote_send_request_with_source(state, destination, client_id, created_at, content, None)
134}
135
136/// Outgoing source belongs to this reply; embedded reference source stays in XML.
137pub fn quote_send_request_with_source(
138    state: &AccountState,
139    destination: &str,
140    client_id: &str,
141    created_at: u32,
142    content: &str,
143    source: Option<&str>,
144) -> Result<Vec<u8>> {
145    ensure!(
146        identity(&state.own_username, false)
147            && identity(destination, false)
148            && valid_text(client_id, 1024, false)
149            && !client_id.chars().any(char::is_control)
150            && created_at > 0
151            && valid_text(content, MAX_QUOTE_XML_BYTES, false)
152            && source.is_none_or(|value| {
153                destination.ends_with("@chatroom") && valid_text(value, 64 * 1024, false)
154            }),
155        "invalid_quote_request"
156    );
157    let mut fields = vec![
158        p::bytes(1, state.own_username.as_bytes()),
159        p::bytes(2, &[]),
160        p::uint(3, 0),
161        p::bytes(4, destination.as_bytes()),
162        p::uint(5, 57),
163        p::bytes(6, content.as_bytes()),
164        p::uint(7, u64::from(created_at)),
165        p::bytes(8, client_id.as_bytes()),
166        p::int32(10, 1),
167    ];
168    if let Some(source) = source {
169        fields.push(p::bytes(12, source.as_bytes()));
170    }
171    fields.extend([p::bytes(13, &[]), p::bytes(14, &[]), p::bytes(15, &[])]);
172    let app = p::message(&fields)?;
173    p::message(&[
174        p::bytes(1, &base_request(&state.device, state.uin, 0)?),
175        p::bytes(2, &app),
176        // The supported AppMsg builder includes this field even when no
177        // attachment checksum is present.
178        p::bytes(5, &[]),
179        p::uint(9, 0),
180        p::uint(10, 0),
181        p::uint(11, 2),
182    ])
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn reference() -> QuoteReference {
190        QuoteReference {
191            message_type: 49,
192            server_id: u64::MAX.to_string(),
193            from_user: "example@chatroom".into(),
194            chat_user: "quoted-user".into(),
195            display_name: "Someone <A>".into(),
196            content: "<msg><appmsg><type>6</type><title>report & plan</title></appmsg></msg>"
197                .into(),
198            created_at: 1234566,
199            msg_source: Some("<msgsource><flag>1</flag></msgsource>".into()),
200        }
201    }
202
203    fn state() -> AccountState {
204        AccountState {
205            schema: 1,
206            device: crate::DeviceProfile {
207                device_id: vec![1; 16],
208                device_type: "UnifiedPCLinux synthetic x86_64".into(),
209                client_version: -227271398,
210            },
211            uin: 123,
212            own_username: "synthetic-user".into(),
213            cookie: vec![],
214            session_key: vec![1; 16],
215            request_key: vec![],
216            response_key: vec![],
217            auto_auth_key: vec![],
218            key_info: vec![],
219            database_password: vec![],
220            auth_snapshot: Default::default(),
221            long_host: "long.weixin.qq.com".into(),
222            short_host: "short.weixin.qq.com".into(),
223        }
224    }
225
226    #[test]
227    fn quote_fields_and_wire_match_independent_fixture() {
228        let fixture: serde_json::Value =
229            serde_json::from_str(include_str!("../fixtures/synthetic-quote-wire.json")).unwrap();
230        let xml = quote_xml("synthetic-user", "Reply <&> \"yes\"\n世界", &reference()).unwrap();
231        assert_eq!(xml, fixture["xml"]);
232        let request =
233            quote_send_request(&state(), "example@chatroom", "quote-client", 1234567, &xml)
234                .unwrap();
235        assert_eq!(hex::encode(&request), fixture["requestHex"]);
236        let outer = p::decode(&request).unwrap();
237        assert_eq!(
238            outer.iter().map(|field| field.number).collect::<Vec<_>>(),
239            [1, 2, 5, 9, 10, 11]
240        );
241        assert_eq!(p::data(&outer, 5).unwrap(), b"");
242        let app = p::decode(p::data(&outer, 2).unwrap()).unwrap();
243        assert_eq!(p::scalar(&app, 5).unwrap(), 57);
244        assert_eq!(p::data(&app, 6).unwrap(), xml.as_bytes());
245        assert!(p::optional_data(&app, 12).unwrap().is_none());
246        assert!(xml.contains("<msgsource>&lt;msgsource&gt;"));
247        assert!(xml.contains("<refermsg><type>49</type>"));
248        assert!(!xml.contains("<refermsg><type>6</type>"));
249    }
250
251    #[test]
252    fn content_is_encoded_as_text_without_expanding_nested_markup() {
253        let mut selected = reference();
254        selected.message_type = 1;
255        selected.chat_user.clear();
256        selected.display_name.clear();
257        selected.msg_source = Some(String::new());
258        selected.content = "literal </content><type>10002</type> &amp; ]]> ' \"".into();
259        let xml = quote_xml("synthetic-user", "comment", &selected).unwrap();
260        assert!(xml.contains("&lt;/content&gt;&lt;type&gt;10002&lt;/type&gt;"));
261        assert!(xml.contains("&amp;amp; ]]&gt; &apos; &quot;"));
262        assert!(!xml.contains("<type>10002</type>"));
263        assert!(!xml.contains("<msgsource>"));
264        assert!(xml.contains("<chatusr></chatusr><displayname></displayname>"));
265    }
266
267    #[test]
268    fn outgoing_mentions_use_app_source_independently_of_embedded_reference_source() {
269        let fixture: serde_json::Value = serde_json::from_str(include_str!(
270            "../fixtures/synthetic-quoted-mentions-wire.json"
271        ))
272        .unwrap();
273        let mut selected = reference();
274        selected.msg_source =
275            Some("<msgsource><atuserlist>old_member</atuserlist></msgsource>".into());
276        let xml = quote_xml("synthetic-user", "@Current\u{2005}Reply", &selected).unwrap();
277        assert_eq!(xml, fixture["xml"]);
278        let source = "<msgsource><atuserlist>current_member</atuserlist><alnode><fr>1</fr></alnode></msgsource>";
279        let request = quote_send_request_with_source(
280            &state(),
281            "example@chatroom",
282            "quote-client",
283            1234567,
284            &xml,
285            Some(source),
286        )
287        .unwrap();
288        assert_eq!(source, fixture["outgoingSource"]);
289        assert_eq!(hex::encode(&request), fixture["requestHex"]);
290        let outer = p::decode(&request).unwrap();
291        assert_eq!(
292            outer.iter().map(|field| field.number).collect::<Vec<_>>(),
293            [1, 2, 5, 9, 10, 11]
294        );
295        let app = p::decode(p::data(&outer, 2).unwrap()).unwrap();
296        assert_eq!(p::scalar(&app, 5).unwrap(), 57);
297        assert_eq!(p::data(&app, 12).unwrap(), source.as_bytes());
298        let body = std::str::from_utf8(p::data(&app, 6).unwrap()).unwrap();
299        assert!(body.contains("old_member"));
300        assert!(!body.contains("current_member"));
301        assert!(body.contains("<title>@Current\u{2005}Reply</title>"));
302        for invalid in ["", "bad\0source"] {
303            assert!(quote_send_request_with_source(
304                &state(),
305                "example@chatroom",
306                "client",
307                1,
308                &xml,
309                Some(invalid)
310            )
311            .is_err());
312        }
313        assert!(
314            quote_send_request_with_source(&state(), "peer", "client", 1, &xml, Some(source))
315                .is_err()
316        );
317    }
318
319    #[test]
320    fn reference_validation_rejects_lossy_or_unsupported_values() {
321        for id in ["0", "01", "+1", "18446744073709551616", "1\n"] {
322            let mut selected = reference();
323            selected.server_id = id.into();
324            assert!(selected.validate().is_err(), "{id:?}");
325        }
326        for kind in [0, -1, 2, 57, 10002] {
327            let mut selected = reference();
328            selected.message_type = kind;
329            assert!(selected.validate().is_err());
330        }
331        for control in ['\0', '\u{1}', '\u{fffe}', '\u{ffff}'] {
332            let mut selected = reference();
333            selected.content.push(control);
334            assert!(selected.validate().is_err());
335            assert!(quote_xml("synthetic-user", &control.to_string(), &reference()).is_err());
336        }
337        let mut selected = reference();
338        selected.created_at = 0;
339        assert!(selected.validate().is_err());
340        selected = reference();
341        selected.from_user.clear();
342        assert!(selected.validate().is_err());
343        for value in ["user name", "user\u{a0}name"] {
344            selected = reference();
345            selected.from_user = value.into();
346            assert!(selected.validate().is_err());
347            assert!(quote_xml(value, "comment", &reference()).is_err());
348            assert!(quote_send_request(&state(), value, "client", 1, "xml").is_err());
349        }
350        selected = reference();
351        selected.msg_source = Some("x".repeat(16 * 1024 + 1));
352        assert!(selected.validate().is_err());
353    }
354
355    #[test]
356    fn final_escaped_xml_and_request_size_remain_bounded() {
357        let mut selected = reference();
358        selected.content = "x".repeat(MAX_QUOTE_CONTENT_BYTES);
359        assert!(quote_xml("synthetic-user", "comment", &selected).is_ok());
360        selected.content.push('x');
361        assert!(selected.validate().is_err());
362        selected.content = "&".repeat(MAX_QUOTE_CONTENT_BYTES);
363        assert!(selected.validate().is_ok());
364        assert!(quote_xml("synthetic-user", "comment", &selected).is_err());
365        assert!(quote_xml("synthetic-user", " \n\t", &reference()).is_err());
366        assert!(quote_xml("synthetic-user", &"x".repeat(16 * 1024 + 1), &reference()).is_err());
367        assert!(quote_send_request(&state(), "dest\0", "client", 1, "xml").is_err());
368        assert!(quote_send_request(&state(), "dest", "client\n", 1, "xml").is_err());
369        assert!(quote_send_request(&state(), "dest", "client", 0, "xml").is_err());
370        assert!(quote_send_request(
371            &state(),
372            "dest",
373            "client",
374            1,
375            &"x".repeat(MAX_QUOTE_XML_BYTES + 1)
376        )
377        .is_err());
378    }
379}