qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
//! Validate cached message receipts before reporting acceptance from disk.
use crate::core::domain::{Conversation, identifier};
use crate::core::error::{AppError, Result};
use serde_json::Value;

pub fn validate(value: &Value, expected: Option<&Conversation>) -> Result<()> {
    let fields = value.as_object().ok_or_else(invalid)?;
    if fields.keys().any(|key| {
        !matches!(
            key.as_str(),
            "messageId" | "conversation" | "attachments" | "fileTransfer"
        )
    }) {
        return Err(invalid());
    }
    let id = value
        .get("messageId")
        .filter(|v| v.is_string())
        .and_then(message_id)
        .ok_or_else(invalid)?;
    if id.is_empty() {
        return Err(invalid());
    }
    let target = value
        .get("conversation")
        .and_then(Value::as_str)
        .ok_or_else(invalid)?
        .parse::<Conversation>()
        .map_err(|_| invalid())?;
    if expected.is_some_and(|expected| expected != &target) {
        return Err(invalid());
    }
    match value.get("attachments") {
        None if value.get("fileTransfer").is_none() => (),
        Some(Value::Array(files))
            if !files.is_empty()
                && files.len() <= 16
                && value["fileTransfer"] == "not_observable" =>
        {
            for file in files {
                attachment(file)?;
            }
        }
        _ => return Err(invalid()),
    }
    Ok(())
}

pub fn message_id(value: &Value) -> Option<String> {
    let id = identifier(value)?;
    let canonical = if id.starts_with('-') {
        let value = id.parse::<i64>().ok()?;
        if value == 0 {
            return None;
        }
        value.to_string()
    } else {
        let value = id.parse::<u64>().ok()?;
        if value == 0 {
            return None;
        }
        value.to_string()
    };
    (id == canonical).then_some(id)
}

pub fn attachment(value: &Value) -> Result<()> {
    let fields = value.as_object().ok_or_else(invalid)?;
    if fields.len() != 4 || !matches!(value["kind"].as_str(), Some("image" | "file")) {
        return Err(invalid());
    }
    let name = value["name"].as_str().ok_or_else(invalid)?;
    if std::path::Path::new(name).components().count() != 1 {
        return Err(invalid());
    }
    crate::napcat::install::archive::member_path(name).map_err(|_| invalid())?;
    value["sizeBytes"]
        .as_u64()
        .filter(|size| *size > 0 && *size <= crate::messaging::media::MAX_DOWNLOAD_BYTES)
        .ok_or_else(invalid)?;
    let hash = value["sha256"].as_str().ok_or_else(invalid)?;
    if hash.len() != 64
        || !hash
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
    {
        return Err(invalid());
    }
    Ok(())
}

fn invalid() -> AppError {
    AppError::new(
        "REQUEST_RECEIPT_INVALID",
        "Stored message receipt is invalid; inspect the request state without sending again.",
        6,
    )
}