use crate::client::Client;
use crate::request::IqError;
use serde::Serialize;
use thiserror::Error;
use wacore::iq::mex::MexQuerySpec;
use wacore::iq::mex_operations::fetch_reachout_timelock;
use wacore_binary::jid::JidError;
pub use wacore::iq::mex::{MexDoc, MexErrorExtensions, MexGraphQLError, MexResponse};
pub use wacore::iq::mex_operations::fetch_reachout_timelock::Xwa2FetchAccountReachoutTimelock as ReachoutTimelock;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum MexError {
#[error("MEX payload parsing error: {0}")]
PayloadParsing(String),
#[error("MEX payload contained an invalid JID")]
InvalidJid(#[from] JidError),
#[error("MEX extension error: code={code}, message='{message}'")]
ExtensionError { code: i32, message: String },
#[error("IQ request failed")]
Request(#[from] IqError),
#[error("JSON error")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone)]
pub struct MexRequest<V> {
pub doc: MexDoc,
pub variables: V,
}
impl<V> MexRequest<V> {
pub fn new(name: &'static str, id: &'static str, variables: V) -> Self {
Self {
doc: MexDoc { name, id },
variables,
}
}
}
macro_rules! mex_request {
($op:path { $($body:tt)* }) => {{
use $op as __mex_op;
$crate::features::mex::MexRequest::new(
__mex_op::NAME,
__mex_op::DOC_ID,
__mex_op::Variables { $($body)* },
)
}};
($op:path, $vars:expr $(,)?) => {{
use $op as __mex_op;
$crate::features::mex::MexRequest::new(__mex_op::NAME, __mex_op::DOC_ID, $vars)
}};
}
pub(crate) use mex_request;
pub struct Mex<'a> {
client: &'a Client,
}
impl<'a> Mex<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
#[inline]
pub async fn query<V: Serialize>(
&self,
request: MexRequest<V>,
) -> Result<MexResponse, MexError> {
self.execute_request(request).await
}
#[inline]
pub async fn mutate<V: Serialize>(
&self,
request: MexRequest<V>,
) -> Result<MexResponse, MexError> {
self.execute_request(request).await
}
pub async fn fetch_reachout_timelock(&self) -> Result<ReachoutTimelock, MexError> {
let response = self.query(mex_request!(fetch_reachout_timelock {})).await?;
decode_reachout_timelock(response.data)
}
#[inline]
async fn execute_request<V: Serialize>(
&self,
request: MexRequest<V>,
) -> Result<MexResponse, MexError> {
let spec = MexQuerySpec::new(request.doc, &request.variables)?;
self.execute_spec(spec).await
}
async fn execute_spec(&self, spec: MexQuerySpec) -> Result<MexResponse, MexError> {
let response = self.client.execute(spec).await?;
if let Some(fatal) = response.fatal_error() {
let code = fatal.error_code().unwrap_or(500);
return Err(MexError::ExtensionError {
code,
message: fatal.message.clone(),
});
}
Ok(response)
}
}
fn decode_reachout_timelock(data: Option<serde_json::Value>) -> Result<ReachoutTimelock, MexError> {
let data = data.ok_or_else(|| {
MexError::PayloadParsing("reachout timelock response missing data".into())
})?;
let response: fetch_reachout_timelock::Response = serde_json::from_value(data)?;
response
.xwa2_fetch_account_reachout_timelock
.ok_or_else(|| {
MexError::PayloadParsing("reachout timelock response missing account state".into())
})
}
impl Client {
#[inline]
pub fn mex(&self) -> Mex<'_> {
Mex::new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_mex_request_carries_doc() {
const DOC: MexDoc = MexDoc {
name: "WAWebMexTestQuery",
id: "29829202653362039",
};
let request = MexRequest {
doc: DOC,
variables: json!({}),
};
assert_eq!(request.doc.id, "29829202653362039");
assert_eq!(request.doc.name, "WAWebMexTestQuery");
}
#[test]
fn test_mex_response_deserialization() {
let json_str = r#"{
"data": {
"xwa2_fetch_wa_users": [
{"jid": "1234567890@s.whatsapp.net", "country_code": "1"}
]
}
}"#;
let response: MexResponse = serde_json::from_str(json_str).unwrap();
assert!(response.has_data());
assert!(!response.has_errors());
assert!(response.fatal_error().is_none());
}
#[test]
fn test_mex_response_with_error_code_is_fatal() {
let json_str = r#"{
"data": null,
"errors": [
{
"message": "User not found",
"extensions": {
"error_code": 404,
"is_summary": false,
"is_retryable": false,
"severity": "WARNING"
}
}
]
}"#;
let response: MexResponse = serde_json::from_str(json_str).unwrap();
assert!(!response.has_data());
assert!(response.has_errors());
let fatal = response.fatal_error();
assert!(fatal.is_some());
assert_eq!(fatal.unwrap().error_code(), Some(404));
}
#[test]
fn test_mex_response_with_fatal_error() {
let json_str = r#"{
"data": null,
"errors": [
{
"message": "Fatal server error",
"extensions": {
"error_code": 500,
"is_summary": true,
"severity": "CRITICAL"
}
}
]
}"#;
let response: MexResponse = serde_json::from_str(json_str).unwrap();
assert!(!response.has_data());
assert!(response.has_errors());
let fatal = response.fatal_error();
assert!(fatal.is_some());
let fatal = fatal.unwrap();
assert_eq!(fatal.message, "Fatal server error");
assert_eq!(fatal.error_code(), Some(500));
assert!(fatal.is_summary());
}
#[test]
fn test_mex_response_real_world() {
let json_str = r#"{
"data": {
"xwa2_fetch_wa_users": [
{
"__typename": "XWA2User",
"about_status_info": {
"__typename": "XWA2AboutStatus",
"text": "Hello",
"timestamp": "1766267670"
},
"country_code": "BR",
"id": null,
"jid": "551199887766@s.whatsapp.net",
"username_info": {
"__typename": "XWA2ResponseStatus",
"status": "EMPTY"
}
}
]
}
}"#;
let response: MexResponse = serde_json::from_str(json_str).unwrap();
assert!(response.has_data());
assert!(!response.has_errors());
let data = response.data.unwrap();
let users = data["xwa2_fetch_wa_users"].as_array().unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0]["country_code"], "BR");
assert_eq!(users[0]["jid"], "551199887766@s.whatsapp.net");
}
#[test]
fn test_reachout_timelock_response() {
let result = decode_reachout_timelock(Some(json!({
"xwa2_fetch_account_reachout_timelock": {
"is_active": true,
"time_enforcement_ends": "1770000000",
"enforcement_type": "DEFAULT"
}
})))
.expect("reachout payload");
assert_eq!(result.is_active, Some(true));
assert_eq!(result.time_enforcement_ends.as_deref(), Some("1770000000"));
assert_eq!(result.enforcement_type.as_deref(), Some("DEFAULT"));
assert!(matches!(
decode_reachout_timelock(None),
Err(MexError::PayloadParsing(_))
));
assert!(matches!(
decode_reachout_timelock(Some(json!({
"xwa2_fetch_account_reachout_timelock": null
}))),
Err(MexError::PayloadParsing(_))
));
}
#[test]
fn test_mex_error_extensions_all_fields() {
let json_str = r#"{
"error_code": 400,
"is_summary": false,
"is_retryable": true,
"severity": "WARNING"
}"#;
let ext: MexErrorExtensions = serde_json::from_str(json_str).unwrap();
assert_eq!(ext.error_code, Some(400));
assert_eq!(ext.is_summary, Some(false));
assert_eq!(ext.is_retryable, Some(true));
assert_eq!(ext.severity, Some("WARNING".to_string()));
}
#[test]
fn test_mex_error_extensions_minimal() {
let json_str = r#"{}"#;
let ext: MexErrorExtensions = serde_json::from_str(json_str).unwrap();
assert!(ext.error_code.is_none());
assert!(ext.is_summary.is_none());
assert!(ext.is_retryable.is_none());
assert!(ext.severity.is_none());
}
#[test]
fn invalid_jid_preserves_jid_error_source() {
let raw: Result<wacore_binary::Jid, JidError> = "not-a-valid-jid".parse();
let jid_err = raw.unwrap_err();
let me: MexError = jid_err.into();
let src = std::error::Error::source(&me).expect("source preserved");
let inner = src
.downcast_ref::<JidError>()
.expect("downcasts to JidError");
assert!(matches!(inner, JidError::InvalidFormat(_)));
}
#[test]
fn request_preserves_iq_error_source() {
let iq = IqError::ServerError {
code: 404,
text: "not-found".into(),
error_type: None,
backoff: None,
};
let me: MexError = iq.into();
let src = std::error::Error::source(&me).expect("source preserved");
let inner = src.downcast_ref::<IqError>().expect("downcasts to IqError");
assert!(matches!(inner, IqError::ServerError { code: 404, .. }));
}
}