wechat-ilink 0.2.1

Stateless async WeChat iLink client for Rust: QR login, event-driven polling, automatic context refresh from incoming messages, and typed ret=-2 rate-limit backoff.
Documentation
use std::time::Duration;

use thiserror::Error;

/// Errors that can occur in the SDK.
#[derive(Error, Debug)]
pub enum WechatIlinkError {
    #[error("API error: {message} (http={http_status}, errcode={errcode})")]
    Api {
        message: String,
        http_status: u16,
        errcode: i32,
    },

    #[error("Rate limited: {message} (http={http_status}, errcode={errcode}, retry_after={}s)", retry_after.as_secs())]
    RateLimited {
        retry_after: Duration,
        message: String,
        http_status: u16,
        errcode: i32,
    },

    #[error("Auth error: {0}")]
    Auth(String),

    #[error("No context_token for user {0}")]
    NoContext(String),

    #[error("Media error: {0}")]
    Media(String),

    #[error("Transport error: {0}")]
    Transport(#[from] reqwest::Error),

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("{0}")]
    Other(String),
}

impl WechatIlinkError {
    /// Returns true if this is a session-expired error (errcode -14).
    pub fn is_session_expired(&self) -> bool {
        matches!(self, WechatIlinkError::Api { errcode: -14, .. })
    }

    /// Returns true if this is a bot-wide iLink rate limit response (ret/errcode -2).
    pub fn is_rate_limited(&self) -> bool {
        matches!(self, WechatIlinkError::RateLimited { .. })
    }

    /// Returns the suggested delay before retrying a rate-limited request.
    pub fn retry_after(&self) -> Option<Duration> {
        match self {
            WechatIlinkError::RateLimited { retry_after, .. } => Some(*retry_after),
            _ => None,
        }
    }
}

pub type Result<T> = std::result::Result<T, WechatIlinkError>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_expired_true() {
        let err = WechatIlinkError::Api {
            message: "session expired".to_string(),
            http_status: 200,
            errcode: -14,
        };
        assert!(err.is_session_expired());
    }

    #[test]
    fn session_expired_false() {
        let err = WechatIlinkError::Api {
            message: "other error".to_string(),
            http_status: 400,
            errcode: -1,
        };
        assert!(!err.is_session_expired());
    }

    #[test]
    fn non_api_not_session_expired() {
        let err = WechatIlinkError::Auth("test".to_string());
        assert!(!err.is_session_expired());
    }

    #[test]
    fn error_display() {
        let err = WechatIlinkError::Api {
            message: "bad request".to_string(),
            http_status: 400,
            errcode: -1,
        };
        let msg = format!("{}", err);
        assert!(msg.contains("bad request"));
        assert!(msg.contains("400"));
        assert!(msg.contains("-1"));
    }

    #[test]
    fn no_context_error() {
        let err = WechatIlinkError::NoContext("user123".to_string());
        let msg = format!("{}", err);
        assert!(msg.contains("user123"));
    }
}