Skip to main content

wechat_protocol/
status_notify.rs

1//! One-attempt native read-session notifications for the supported profile.
2//!
3//! This reports an entire conversation through a caller-frozen boundary. It is
4//! deliberately separate from an exact set of locally read message IDs. The
5//! caller must journal admission and an uncertain outcome before retrying could
6//! be considered; an I/O error is never permission to submit it again.
7use super::*;
8
9const CGI: u16 = 251;
10const PATH: &str = "/cgi-bin/micromsg-bin/statusnotify";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "camelCase", deny_unknown_fields)]
14pub struct SessionReadNotification {
15    pub peer_username: String,
16    pub last_message_server_id: String,
17    pub last_message_create_time: u32,
18    /// A stable request identity retained with the durable frozen boundary.
19    pub client_message_id: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SessionReadReply {
24    pub service_code: i32,
25    pub accepted: bool,
26}
27
28fn bounded_identity(value: &str, max: usize) -> bool {
29    !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
30}
31
32fn request(state: &AccountState, notification: &SessionReadNotification) -> Result<Vec<u8>> {
33    state.validate()?;
34    ensure!(
35        bounded_identity(&state.own_username, 256)
36            && bounded_identity(&notification.peer_username, 512)
37            && bounded_identity(&notification.client_message_id, 256),
38        "Invalid read notification identity"
39    );
40    let id = notification.last_message_server_id.parse::<u64>();
41    ensure!(
42        id.is_ok_and(|id| id != 0 && id.to_string() == notification.last_message_server_id)
43            && notification.last_message_create_time != 0,
44        "Invalid read notification boundary"
45    );
46    let argument = serde_json::to_vec(&serde_json::json!({
47        "messageSvrId": notification.last_message_server_id,
48        "MsgCreateTime": notification.last_message_create_time.to_string(),
49    }))?;
50    p::message(&[
51        p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?),
52        p::uint(2, 1),
53        p::bytes(3, state.own_username.as_bytes()),
54        p::bytes(4, notification.peer_username.as_bytes()),
55        p::bytes(5, notification.client_message_id.as_bytes()),
56        p::bytes(
57            8,
58            &p::message(&[p::bytes(1, b"lastMessage"), p::bytes(2, &argument)])?,
59        ),
60    ])
61}
62
63fn response(bytes: &[u8]) -> Result<SessionReadReply> {
64    let fields = p::decode(bytes)?;
65    let base = p::decode(p::data(&fields, 1)?)?;
66    let raw = p::scalar(&base, 1)?;
67    let code = raw as u32 as i32;
68    ensure!(
69        raw <= u32::MAX as u64 || raw == code as i64 as u64,
70        "Invalid read notification response status"
71    );
72    Ok(SessionReadReply {
73        service_code: code,
74        accepted: code == 0,
75    })
76}
77
78impl NativeClient {
79    /// Submit once, with no renewal, retry, or login fallback. A caller must
80    /// retain the boundary and request identity before invoking this method.
81    pub fn notify_read_session(
82        &self,
83        state: &mut AccountState,
84        notification: &SessionReadNotification,
85    ) -> Result<SessionReadReply> {
86        let body = request(state, notification)?;
87        response(&self.authenticated_rpc(state, CGI, PATH, &body)?)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn state() -> AccountState {
96        AccountState {
97            schema: 1,
98            device: DeviceProfile {
99                device_id: b"0123456789abcdef".to_vec(),
100                device_type: "UnifiedPCLinux x86_64".into(),
101                client_version: -227271398,
102            },
103            uin: 42,
104            own_username: "synthetic_user".into(),
105            cookie: vec![],
106            session_key: vec![1; 16],
107            request_key: vec![2; 24],
108            response_key: vec![3; 24],
109            auto_auth_key: vec![4; 16],
110            key_info: vec![],
111            database_password: vec![],
112            auth_snapshot: Default::default(),
113            long_host: "long.weixin.qq.com".into(),
114            short_host: "short.weixin.qq.com".into(),
115        }
116    }
117
118    fn notification() -> SessionReadNotification {
119        SessionReadNotification {
120            peer_username: "synthetic_room@chatroom".into(),
121            last_message_server_id: u64::MAX.to_string(),
122            last_message_create_time: 1_800_000_000,
123            client_message_id: "read-fixture-stable-id".into(),
124        }
125    }
126
127    #[test]
128    fn read_session_uses_proven_fields_and_lossless_boundary_strings() {
129        assert_eq!(CGI, 251);
130        assert_eq!(PATH, "/cgi-bin/micromsg-bin/statusnotify");
131        let input = notification();
132        let body = request(&state(), &input).unwrap();
133        assert_eq!(body, request(&state(), &input).unwrap());
134        let outer = p::decode(&body).unwrap();
135        assert_eq!(
136            outer.iter().map(|field| field.number).collect::<Vec<_>>(),
137            [1, 2, 3, 4, 5, 8]
138        );
139        let base = p::decode(p::data(&outer, 1).unwrap()).unwrap();
140        assert_eq!(p::scalar(&base, 2).unwrap(), 42);
141        assert_eq!(p::scalar(&outer, 2).unwrap(), 1);
142        assert_eq!(p::data(&outer, 3).unwrap(), b"synthetic_user");
143        assert_eq!(p::data(&outer, 4).unwrap(), b"synthetic_room@chatroom");
144        assert_eq!(p::data(&outer, 5).unwrap(), b"read-fixture-stable-id");
145        let extra = p::decode(p::data(&outer, 8).unwrap()).unwrap();
146        assert_eq!(p::data(&extra, 1).unwrap(), b"lastMessage");
147        let argument: serde_json::Value =
148            serde_json::from_slice(p::data(&extra, 2).unwrap()).unwrap();
149        assert_eq!(argument["messageSvrId"], u64::MAX.to_string());
150        assert_eq!(argument["MsgCreateTime"], "1800000000");
151    }
152
153    #[test]
154    fn malformed_boundaries_and_identities_fail_before_transport() {
155        for id in ["", "0", "-1", "01", " 1", "+1", "18446744073709551616"] {
156            let mut input = notification();
157            input.last_message_server_id = id.into();
158            assert!(request(&state(), &input).is_err());
159        }
160        for value in [String::new(), "peer\nother".into(), "x".repeat(513)] {
161            let mut input = notification();
162            input.peer_username = value;
163            assert!(request(&state(), &input).is_err());
164        }
165        let mut input = notification();
166        input.last_message_create_time = 0;
167        assert!(request(&state(), &input).is_err());
168        let mut input = notification();
169        input.client_message_id.clear();
170        assert!(request(&state(), &input).is_err());
171        let mut invalid = state();
172        invalid.uin = 0;
173        assert!(request(&invalid, &notification()).is_err());
174    }
175
176    #[test]
177    fn reply_preserves_rejection_and_requires_unique_initialized_base_status() {
178        assert!(response(&p::bytes(1, &p::uint(1, 0))).unwrap().accepted);
179        let rejected = response(&p::bytes(1, &p::int32(1, -13))).unwrap();
180        assert_eq!(rejected.service_code, -13);
181        assert!(!rejected.accepted);
182        for invalid in [
183            vec![],
184            p::bytes(1, &[]),
185            p::uint(1, 0),
186            p::bytes(1, &p::uint(1, 1u64 << 32)),
187            [p::bytes(1, &p::uint(1, 0)), p::bytes(1, &p::uint(1, 0))].concat(),
188        ] {
189            assert!(response(&invalid).is_err());
190        }
191    }
192}