1use 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
13pub use wacore::iq::mex::{MexDoc, MexErrorExtensions, MexGraphQLError, MexResponse};
15pub use wacore::iq::mex_operations::fetch_reachout_timelock::Xwa2FetchAccountReachoutTimelock as ReachoutTimelock;
16
17#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum MexError {
21 #[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#[derive(Debug, Clone)]
46pub struct MexRequest<V> {
47 pub doc: MexDoc,
49 pub variables: V,
52}
53
54impl<V> MexRequest<V> {
55 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
65macro_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
90pub 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 #[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 #[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 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 let spec = MexQuerySpec::new(request.doc, &request.variables)?;
132 self.execute_spec(spec).await
133 }
134
135 async fn execute_spec(&self, spec: MexQuerySpec) -> Result<MexResponse, MexError> {
138 let response = self.client.execute(spec).await?;
139
140 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 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}