qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::{Duration, Instant};

use qq_cli::storage::config::Config;
use qq_cli::transport::client::{Action, Backend, OneBotClient};
use serde_json::json;

fn read_request(stream: &mut TcpStream) -> Vec<u8> {
    stream
        .set_read_timeout(Some(Duration::from_secs(3)))
        .unwrap();
    let mut request = Vec::new();
    let mut byte = [0_u8; 1];
    while !request.ends_with(b"\r\n\r\n") {
        stream.read_exact(&mut byte).unwrap();
        request.push(byte[0]);
        assert!(request.len() < 64 * 1024);
    }
    let headers = String::from_utf8_lossy(&request);
    let length = headers
        .lines()
        .find_map(|line| {
            let (name, value) = line.split_once(':')?;
            name.eq_ignore_ascii_case("content-length")
                .then(|| value.trim().parse::<usize>().unwrap())
        })
        .unwrap_or(0);
    let mut body = vec![0; length];
    stream.read_exact(&mut body).unwrap();
    request.extend(body);
    request
}

fn serve(response: Vec<u8>) -> (String, thread::JoinHandle<Vec<u8>>) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let endpoint = format!("http://{}", listener.local_addr().unwrap());
    let handle = thread::spawn(move || {
        let mut stream = accept_bounded(&listener);
        let request = read_request(&mut stream);
        let _ = stream.write_all(&response);
        let _ = stream.flush();
        request
    });
    (endpoint, handle)
}

fn accept_bounded(listener: &TcpListener) -> TcpStream {
    listener.set_nonblocking(true).unwrap();
    let deadline = Instant::now() + Duration::from_secs(3);
    loop {
        match listener.accept() {
            Ok((stream, _)) => {
                stream.set_nonblocking(false).unwrap();
                return stream;
            }
            Err(error)
                if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline =>
            {
                thread::sleep(Duration::from_millis(5));
            }
            Err(error) => panic!("local test listener failed: {error}"),
        }
    }
}

fn response(status: &str, body: &str) -> Vec<u8> {
    format!(
        "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    )
    .into_bytes()
}

#[test]
fn authenticated_post_preserves_integer_precision() {
    let (endpoint, server) = serve(response(
        "200 OK",
        r#"{"status":"ok","retcode":0,"data":{"message_id":18446744073709551615}}"#,
    ));
    let client = OneBotClient::new(&endpoint, "synthetic-token", &Config::default()).unwrap();
    let result = client
        .call(
            Action::Message,
            &json!({"message_id":"-123"}),
            Instant::now() + Duration::from_secs(3),
        )
        .unwrap();
    assert_eq!(result["message_id"].as_u64(), Some(u64::MAX));
    let request = String::from_utf8(server.join().unwrap()).unwrap();
    assert!(request.starts_with("POST /get_msg HTTP/1.1"));
    assert!(
        request
            .to_ascii_lowercase()
            .contains("authorization: bearer synthetic-token")
    );
    assert!(request.contains(r#""message_id":"-123""#));
}

#[test]
fn dedicated_media_budget_does_not_relax_normal_json_limits_or_follow_paths() {
    use base64::Engine;
    let encoded = base64::engine::general_purpose::STANDARD.encode(vec![b'x'; 3 * 1024 * 1024 + 3]);
    let body=json!({"status":"ok","retcode":0,"data":{"base64":encoded,"file_size":(3*1024*1024+3).to_string(),"file":"must-not-be-opened","url":"https://invalid.example/private"}}).to_string();
    assert!(body.len() > 4 * 1024 * 1024);
    let (endpoint, server) = serve(response("200 OK", &body));
    let client = OneBotClient::new(&endpoint, "synthetic-token", &Config::default()).unwrap();
    assert_eq!(
        client
            .call(
                Action::Image,
                &json!({"file_id":"fixture"}),
                Instant::now() + Duration::from_secs(5)
            )
            .unwrap_err()
            .code,
        "RESPONSE_LIMIT"
    );
    server.join().unwrap();
    let (endpoint, server) = serve(response("200 OK", &body));
    let client = OneBotClient::new(&endpoint, "synthetic-token", &Config::default()).unwrap();
    let bytes = client
        .media_bytes(
            Action::Image,
            &json!({"file_id":"fixture"}),
            Instant::now() + Duration::from_secs(5),
        )
        .unwrap();
    assert_eq!(bytes.encoded.len(), encoded.len());
    assert_eq!(bytes.size, Some(3 * 1024 * 1024 + 3));
    server.join().unwrap();
}

#[test]
fn media_response_requires_bytes_final_status_and_separate_metadata_bounds() {
    for (body,code) in [
        (json!({"status":"ok","retcode":0,"data":{"file":"must-not-open","url":"https://invalid.example/"}}).to_string(),"MEDIA_BYTES_UNAVAILABLE"),
        (json!({"status":"async","retcode":1,"data":{"base64":"YQ=="}}).to_string(),"BACKEND_ASYNC"),
        (json!({"status":"ok","retcode":0,"data":{"base64":"YQ==","file_size":"bad"}}).to_string(),"BACKEND_PROTOCOL"),
        (json!({"status":"ok","retcode":0,"data":{"base64":"YQ==","ignored":"x".repeat(2048)}}).to_string(),"RESPONSE_LIMIT"),
    ] {
        let (endpoint,server)=serve(response("200 OK",&body));let config=Config {response_bytes:1024,..Config::default()};let client=OneBotClient::new(&endpoint,"synthetic-token",&config).unwrap();
        assert_eq!(client.media_bytes(Action::File,&json!({"file_id":"fixture"}),Instant::now()+Duration::from_secs(3)).err().unwrap().code,code);server.join().unwrap();
    }
}

#[test]
fn http_success_cannot_hide_rejection_or_invalid_protocol() {
    for (body, expected) in [
        (
            r#"{"status":"failed","retcode":1200,"data":null,"wording":"synthetic-private-content"}"#,
            "BACKEND_REJECTED",
        ),
        (
            r#"{"status":"async","retcode":1,"data":null}"#,
            "BACKEND_ASYNC",
        ),
        (r#"{"status":"ok","data":{}}"#, "BACKEND_PROTOCOL"),
        (
            r#"{"status":"ok","retcode":0.25,"data":{}}"#,
            "BACKEND_PROTOCOL",
        ),
        (r#"{"status":"ok","retcode":0}"#, "BACKEND_PROTOCOL"),
        ("<html>synthetic-private-content</html>", "BACKEND_PROTOCOL"),
    ] {
        let (endpoint, server) = serve(response("200 OK", body));
        let client = OneBotClient::new(&endpoint, "synthetic-token", &Config::default()).unwrap();
        let error = client
            .call(
                Action::Status,
                &json!({}),
                Instant::now() + Duration::from_secs(3),
            )
            .unwrap_err();
        assert_eq!(error.code, expected);
        let serialized = serde_json::to_string(&error).unwrap();
        assert!(!serialized.contains("synthetic-private-content"));
        assert!(!serialized.contains("synthetic-token"));
        server.join().unwrap();
    }
}

#[test]
fn redirects_auth_failures_and_oversized_bodies_are_explicit() {
    for (response, expected) in [
        (b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), "BACKEND_HTTP"),
        (response("401 Unauthorized", "synthetic-private-content"), "BACKEND_AUTH"),
        (response("403 Forbidden", "synthetic-private-content"), "BACKEND_AUTH"),
        (response("200 OK", &"x".repeat(1025)), "RESPONSE_LIMIT"),
        (b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort".to_vec(), "BACKEND_TRANSPORT"),
        (b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n401\r\n".iter().copied()
            .chain(vec![b'x';1025]).chain(b"\r\n0\r\n\r\n".iter().copied()).collect(), "RESPONSE_LIMIT"),
    ] {
        let (endpoint, server) = serve(response);
        let config = Config { response_bytes: 1024, ..Config::default() };
        let client = OneBotClient::new(&endpoint, "synthetic-token", &config).unwrap();
        let error = client.call(Action::Status, &json!({}), Instant::now() + Duration::from_secs(3)).unwrap_err();
        assert_eq!(error.code, expected);
        server.join().unwrap();
    }
}

#[test]
fn a_dropped_write_response_is_not_retried() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let endpoint = format!("http://{}", listener.local_addr().unwrap());
    let server = thread::spawn(move || {
        let mut stream = accept_bounded(&listener);
        let request = read_request(&mut stream);
        drop(stream);
        listener.set_nonblocking(true).unwrap();
        let until = Instant::now() + Duration::from_millis(150);
        while Instant::now() < until {
            assert!(listener.accept().is_err(), "transport retried a write");
            thread::sleep(Duration::from_millis(5));
        }
        request
    });
    let client = OneBotClient::new(&endpoint, "synthetic-token", &Config::default()).unwrap();
    assert!(
        client
            .call(
                Action::SendPrivate,
                &json!({"message":[]}),
                Instant::now() + Duration::from_secs(3)
            )
            .is_err()
    );
    assert!(
        String::from_utf8(server.join().unwrap())
            .unwrap()
            .starts_with("POST /send_private_msg")
    );
}

#[test]
fn proxy_environment_does_not_redirect_loopback_credentials() {
    let output = std::process::Command::new(std::env::current_exe().unwrap())
        .args(["--exact", "authenticated_post_preserves_integer_precision"])
        .env("HTTP_PROXY", "http://127.0.0.1:1")
        .env("HTTPS_PROXY", "http://127.0.0.1:1")
        .env("ALL_PROXY", "http://127.0.0.1:1")
        .env("NO_PROXY", "")
        .output()
        .unwrap();
    assert!(output.status.success(), "proxy isolation subprocess failed");
}