Skip to main content

whatsapp_rust/features/
mex.rs

1//! MEX (Meta Exchange) GraphQL feature.
2//!
3//! Protocol types are defined in `wacore::iq::mex`.
4
5use crate::client::Client;
6use crate::request::IqError;
7use serde::Serialize;
8use thiserror::Error;
9use wacore::iq::mex::MexQuerySpec;
10use wacore::iq::mex_operations::fetch_reachout_timelock;
11use wacore_binary::jid::JidError;
12
13// Re-export types from wacore
14pub use wacore::iq::mex::{MexDoc, MexErrorExtensions, MexGraphQLError, MexResponse};
15pub use wacore::iq::mex_operations::fetch_reachout_timelock::Xwa2FetchAccountReachoutTimelock as ReachoutTimelock;
16
17/// Error types for MEX operations.
18#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum MexError {
21    /// Payload missing or otherwise malformed in a way that has no underlying
22    /// typed source (descriptive message only — e.g. "missing data").
23    #[error("MEX payload parsing error: {0}")]
24    PayloadParsing(String),
25
26    #[error("MEX payload contained an invalid JID")]
27    InvalidJid(#[from] JidError),
28
29    #[error("MEX extension error: code={code}, message='{message}'")]
30    ExtensionError { code: i32, message: String },
31
32    #[error("IQ request failed")]
33    Request(#[from] IqError),
34
35    #[error("JSON error")]
36    Json(#[from] serde_json::Error),
37}
38
39/// MEX request: a persisted-query descriptor plus its typed variables.
40///
41/// Variables are serialized straight to the wire in the IQ spec (no intermediate
42/// `serde_json::Value`). Build one with the `mex_request!` macro, which pulls
43/// `NAME`/`DOC_ID` from a generated [`wacore::iq::mex_operations`] module so the
44/// op is named once.
45#[derive(Debug, Clone)]
46pub struct MexRequest<V> {
47    /// GraphQL persisted-query descriptor (name + id).
48    pub doc: MexDoc,
49    /// Typed query variables: a generated `Variables`, or any `Serialize` value
50    /// (e.g. a `json!` object) for inputs the generated mirror types too loosely.
51    pub variables: V,
52}
53
54impl<V> MexRequest<V> {
55    /// Pair a `(name, id)` from a generated op module with its variables.
56    /// Prefer the `mex_request!` macro, which names the op once.
57    pub fn new(name: &'static str, id: &'static str, variables: V) -> Self {
58        Self {
59            doc: MexDoc { name, id },
60            variables,
61        }
62    }
63}
64
65/// Build a [`MexRequest`] from a generated mex operation module, pulling its
66/// `NAME`/`DOC_ID` so the op is named once. Two forms:
67///
68/// ```ignore
69/// // typed Variables, struct-literal sugar:
70/// mex_request!(join_newsletter { newsletter_id: Some(jid.to_string()) })
71/// // explicit value (typed Variables value, or a json! for loosely-typed inputs):
72/// mex_request!(update_group_property, serde_json::json!({ "group_id": id }))
73/// ```
74macro_rules! mex_request {
75    ($op:path { $($body:tt)* }) => {{
76        use $op as __mex_op;
77        $crate::features::mex::MexRequest::new(
78            __mex_op::NAME,
79            __mex_op::DOC_ID,
80            __mex_op::Variables { $($body)* },
81        )
82    }};
83    ($op:path, $vars:expr $(,)?) => {{
84        use $op as __mex_op;
85        $crate::features::mex::MexRequest::new(__mex_op::NAME, __mex_op::DOC_ID, $vars)
86    }};
87}
88pub(crate) use mex_request;
89
90/// Feature handle for MEX GraphQL operations.
91pub struct Mex<'a> {
92    client: &'a Client,
93}
94
95impl<'a> Mex<'a> {
96    pub(crate) fn new(client: &'a Client) -> Self {
97        Self { client }
98    }
99
100    /// Execute a GraphQL query.
101    #[inline]
102    pub async fn query<V: Serialize>(
103        &self,
104        request: MexRequest<V>,
105    ) -> Result<MexResponse, MexError> {
106        self.execute_request(request).await
107    }
108
109    /// Execute a GraphQL mutation.
110    #[inline]
111    pub async fn mutate<V: Serialize>(
112        &self,
113        request: MexRequest<V>,
114    ) -> Result<MexResponse, MexError> {
115        self.execute_request(request).await
116    }
117
118    /// Fetch the account's current reachout-timelock state.
119    pub async fn fetch_reachout_timelock(&self) -> Result<ReachoutTimelock, MexError> {
120        let response = self.query(mex_request!(fetch_reachout_timelock {})).await?;
121        decode_reachout_timelock(response.data)
122    }
123
124    #[inline]
125    async fn execute_request<V: Serialize>(
126        &self,
127        request: MexRequest<V>,
128    ) -> Result<MexResponse, MexError> {
129        // Serialize the variables here so a caller-side serialization error
130        // surfaces as MexError::Json instead of a malformed empty request.
131        let spec = MexQuerySpec::new(request.doc, &request.variables)?;
132        self.execute_spec(spec).await
133    }
134
135    // Non-generic so the execute/error-handling body instantiates once, not
136    // per variables type.
137    async fn execute_spec(&self, spec: MexQuerySpec) -> Result<MexResponse, MexError> {
138        let response = self.client.execute(spec).await?;
139
140        // Check for fatal errors (the IqSpec already checks, but we want to return our error type)
141        if let Some(fatal) = response.fatal_error() {
142            let code = fatal.error_code().unwrap_or(500);
143            return Err(MexError::ExtensionError {
144                code,
145                message: fatal.message.clone(),
146            });
147        }
148
149        Ok(response)
150    }
151}
152
153fn decode_reachout_timelock(data: Option<serde_json::Value>) -> Result<ReachoutTimelock, MexError> {
154    let data = data.ok_or_else(|| {
155        MexError::PayloadParsing("reachout timelock response missing data".into())
156    })?;
157    let response: fetch_reachout_timelock::Response = serde_json::from_value(data)?;
158    response
159        .xwa2_fetch_account_reachout_timelock
160        .ok_or_else(|| {
161            MexError::PayloadParsing("reachout timelock response missing account state".into())
162        })
163}
164
165impl Client {
166    #[inline]
167    pub fn mex(&self) -> Mex<'_> {
168        Mex::new(self)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use serde_json::json;
176
177    #[test]
178    fn test_mex_request_carries_doc() {
179        const DOC: MexDoc = MexDoc {
180            name: "WAWebMexTestQuery",
181            id: "29829202653362039",
182        };
183        let request = MexRequest {
184            doc: DOC,
185            variables: json!({}),
186        };
187
188        assert_eq!(request.doc.id, "29829202653362039");
189        assert_eq!(request.doc.name, "WAWebMexTestQuery");
190    }
191
192    #[test]
193    fn test_mex_response_deserialization() {
194        let json_str = r#"{
195            "data": {
196                "xwa2_fetch_wa_users": [
197                    {"jid": "1234567890@s.whatsapp.net", "country_code": "1"}
198                ]
199            }
200        }"#;
201
202        let response: MexResponse = serde_json::from_str(json_str).unwrap();
203        assert!(response.has_data());
204        assert!(!response.has_errors());
205        assert!(response.fatal_error().is_none());
206    }
207
208    #[test]
209    fn test_mex_response_with_error_code_is_fatal() {
210        // WhatsApp Web treats any error with error_code as fatal
211        let json_str = r#"{
212            "data": null,
213            "errors": [
214                {
215                    "message": "User not found",
216                    "extensions": {
217                        "error_code": 404,
218                        "is_summary": false,
219                        "is_retryable": false,
220                        "severity": "WARNING"
221                    }
222                }
223            ]
224        }"#;
225
226        let response: MexResponse = serde_json::from_str(json_str).unwrap();
227        assert!(!response.has_data());
228        assert!(response.has_errors());
229
230        let fatal = response.fatal_error();
231        assert!(fatal.is_some());
232        assert_eq!(fatal.unwrap().error_code(), Some(404));
233    }
234
235    #[test]
236    fn test_mex_response_with_fatal_error() {
237        let json_str = r#"{
238            "data": null,
239            "errors": [
240                {
241                    "message": "Fatal server error",
242                    "extensions": {
243                        "error_code": 500,
244                        "is_summary": true,
245                        "severity": "CRITICAL"
246                    }
247                }
248            ]
249        }"#;
250
251        let response: MexResponse = serde_json::from_str(json_str).unwrap();
252        assert!(!response.has_data());
253        assert!(response.has_errors());
254
255        let fatal = response.fatal_error();
256        assert!(fatal.is_some());
257
258        let fatal = fatal.unwrap();
259        assert_eq!(fatal.message, "Fatal server error");
260        assert_eq!(fatal.error_code(), Some(500));
261        assert!(fatal.is_summary());
262    }
263
264    #[test]
265    fn test_mex_response_real_world() {
266        let json_str = r#"{
267            "data": {
268                "xwa2_fetch_wa_users": [
269                    {
270                        "__typename": "XWA2User",
271                        "about_status_info": {
272                            "__typename": "XWA2AboutStatus",
273                            "text": "Hello",
274                            "timestamp": "1766267670"
275                        },
276                        "country_code": "BR",
277                        "id": null,
278                        "jid": "551199887766@s.whatsapp.net",
279                        "username_info": {
280                            "__typename": "XWA2ResponseStatus",
281                            "status": "EMPTY"
282                        }
283                    }
284                ]
285            }
286        }"#;
287
288        let response: MexResponse = serde_json::from_str(json_str).unwrap();
289        assert!(response.has_data());
290        assert!(!response.has_errors());
291
292        let data = response.data.unwrap();
293        let users = data["xwa2_fetch_wa_users"].as_array().unwrap();
294        assert_eq!(users.len(), 1);
295        assert_eq!(users[0]["country_code"], "BR");
296        assert_eq!(users[0]["jid"], "551199887766@s.whatsapp.net");
297    }
298
299    #[test]
300    fn test_reachout_timelock_response() {
301        let result = decode_reachout_timelock(Some(json!({
302            "xwa2_fetch_account_reachout_timelock": {
303                "is_active": true,
304                "time_enforcement_ends": "1770000000",
305                "enforcement_type": "DEFAULT"
306            }
307        })))
308        .expect("reachout payload");
309
310        assert_eq!(result.is_active, Some(true));
311        assert_eq!(result.time_enforcement_ends.as_deref(), Some("1770000000"));
312        assert_eq!(result.enforcement_type.as_deref(), Some("DEFAULT"));
313        assert!(matches!(
314            decode_reachout_timelock(None),
315            Err(MexError::PayloadParsing(_))
316        ));
317        assert!(matches!(
318            decode_reachout_timelock(Some(json!({
319                "xwa2_fetch_account_reachout_timelock": null
320            }))),
321            Err(MexError::PayloadParsing(_))
322        ));
323    }
324
325    #[test]
326    fn test_mex_error_extensions_all_fields() {
327        let json_str = r#"{
328            "error_code": 400,
329            "is_summary": false,
330            "is_retryable": true,
331            "severity": "WARNING"
332        }"#;
333
334        let ext: MexErrorExtensions = serde_json::from_str(json_str).unwrap();
335        assert_eq!(ext.error_code, Some(400));
336        assert_eq!(ext.is_summary, Some(false));
337        assert_eq!(ext.is_retryable, Some(true));
338        assert_eq!(ext.severity, Some("WARNING".to_string()));
339    }
340
341    #[test]
342    fn test_mex_error_extensions_minimal() {
343        let json_str = r#"{}"#;
344
345        let ext: MexErrorExtensions = serde_json::from_str(json_str).unwrap();
346        assert!(ext.error_code.is_none());
347        assert!(ext.is_summary.is_none());
348        assert!(ext.is_retryable.is_none());
349        assert!(ext.severity.is_none());
350    }
351
352    #[test]
353    fn invalid_jid_preserves_jid_error_source() {
354        let raw: Result<wacore_binary::Jid, JidError> = "not-a-valid-jid".parse();
355        let jid_err = raw.unwrap_err();
356        let me: MexError = jid_err.into();
357        let src = std::error::Error::source(&me).expect("source preserved");
358        let inner = src
359            .downcast_ref::<JidError>()
360            .expect("downcasts to JidError");
361        assert!(matches!(inner, JidError::InvalidFormat(_)));
362    }
363
364    #[test]
365    fn request_preserves_iq_error_source() {
366        let iq = IqError::ServerError {
367            code: 404,
368            text: "not-found".into(),
369            error_type: None,
370            backoff: None,
371        };
372        let me: MexError = iq.into();
373        let src = std::error::Error::source(&me).expect("source preserved");
374        let inner = src.downcast_ref::<IqError>().expect("downcasts to IqError");
375        assert!(matches!(inner, IqError::ServerError { code: 404, .. }));
376    }
377}