choreo-daemon 0.1.0

Agentic coding assistant — daemon, TUI, and bridges
use choreo_keystore::{ServiceCredential, XCredentialView};

mod post;
mod search_recent;
mod user_lookup;

pub(crate) use post::XPost;
pub(crate) use search_recent::XSearchRecent;
pub(crate) use user_lookup::XUserLookup;

fn get_x_credentials(x_credentials: Option<&ServiceCredential>) -> Option<XCredentialView<'_>> {
    x_credentials.and_then(ServiceCredential::as_x)
}

const X_API_BASE: &str = "https://api.twitter.com";

fn urlencode(s: &str) -> String {
    let mut result = String::with_capacity(s.len() * 3);
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                result.push(byte as char);
            }
            _ => {
                result.push_str(&format!("%{:02X}", byte));
            }
        }
    }
    result
}

fn hmac_sha1(key: &[u8], data: &str) -> String {
    use hmac::{Hmac, KeyInit, Mac};
    use sha1::Sha1;
    let mut key_buf = [0u8; 64];
    if !key.is_empty() {
        let len = key.len().min(64);
        key_buf[..len].copy_from_slice(&key[..len]);
    }
    let mut mac = Hmac::<Sha1>::new((&key_buf).into());
    mac.update(data.as_bytes());
    let result = mac.finalize();
    base64::Engine::encode(
        &base64::engine::general_purpose::STANDARD,
        result.into_bytes().as_slice(),
    )
}

fn build_oauth1_header(
    method: &str,
    url: &str,
    creds: &XCredentialView<'_>,
    params: &[(&str, &str)],
) -> String {
    use std::time::{SystemTime, UNIX_EPOCH};

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
        .to_string();

    let nonce: String = (0..32)
        .map(|_| {
            let b: u8 = rand::random();
            format!("{:02x}", b)
        })
        .collect();

    let mut oauth_params: Vec<(String, String)> = vec![
        ("oauth_consumer_key".to_string(), creds.api_key.to_string()),
        ("oauth_nonce".to_string(), nonce),
        (
            "oauth_signature_method".to_string(),
            "HMAC-SHA1".to_string(),
        ),
        ("oauth_timestamp".to_string(), timestamp),
        ("oauth_token".to_string(), creds.access_token.to_string()),
        ("oauth_version".to_string(), "1.0".to_string()),
    ];

    let mut all_params: Vec<(String, String)> = oauth_params.clone();
    for (k, v) in params {
        all_params.push((k.to_string(), v.to_string()));
    }

    all_params.sort_by(|a, b| {
        let key_cmp = a.0.cmp(&b.0);
        if key_cmp.is_eq() {
            a.1.cmp(&b.1)
        } else {
            key_cmp
        }
    });

    let param_string = all_params
        .iter()
        .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v)))
        .collect::<Vec<_>>()
        .join("&");

    let signature_base = format!(
        "{}&{}&{}",
        method.to_uppercase(),
        urlencode(url),
        urlencode(&param_string)
    );

    let signing_key = format!(
        "{}&{}",
        urlencode(creds.api_key_secret),
        urlencode(creds.access_token_secret)
    );

    let signature = hmac_sha1(signing_key.as_bytes(), &signature_base);

    oauth_params.push(("oauth_signature".to_string(), signature));
    oauth_params.sort_by(|a, b| {
        let key_cmp = a.0.cmp(&b.0);
        if key_cmp.is_eq() {
            a.1.cmp(&b.1)
        } else {
            key_cmp
        }
    });

    let header_value = oauth_params
        .iter()
        .map(|(k, v)| format!("{}=\"{}\"", urlencode(k), urlencode(v)))
        .collect::<Vec<_>>()
        .join(", ");

    format!("OAuth {header_value}")
}

fn x_api_get(
    path: &str,
    params: &[(&str, &str)],
    x_credentials: Option<&ServiceCredential>,
) -> Result<String, String> {
    let creds = get_x_credentials(x_credentials).ok_or("X credentials not configured")?;
    let url = format!("{X_API_BASE}{path}");
    let auth_header = build_oauth1_header("GET", &url, &creds, params);

    let agent = ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .http_status_as_error(false)
            .build(),
    );
    let response = agent
        .get(&url)
        .header("Authorization", &auth_header)
        .call()
        .map_err(|e| format!("X API request failed: {e}"))?;

    let status = response.status().as_u16();
    let body = response
        .into_body()
        .read_to_string()
        .map_err(|e| format!("failed to read response: {e}"))?;

    if !(200..300).contains(&status) {
        return Err(format!("X API error (status {status}): {body}"));
    }

    Ok(body)
}

fn x_api_post(
    path: &str,
    body_json: &str,
    x_credentials: Option<&ServiceCredential>,
) -> Result<String, String> {
    let creds = get_x_credentials(x_credentials).ok_or("X credentials not configured")?;
    let url = format!("{X_API_BASE}{path}");
    let auth_header = build_oauth1_header("POST", &url, &creds, &[]);

    let agent = ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .http_status_as_error(false)
            .build(),
    );
    let response = agent
        .post(&url)
        .header("Authorization", &auth_header)
        .header("Content-Type", "application/json")
        .send(body_json)
        .map_err(|e| format!("X API request failed: {e}"))?;

    let status = response.status().as_u16();
    let body = response
        .into_body()
        .read_to_string()
        .map_err(|e| format!("failed to read response: {e}"))?;

    if !(200..300).contains(&status) {
        return Err(format!("X API error (status {status}): {body}"));
    }

    Ok(body)
}

fn format_x_api_response(response: &str) -> String {
    match serde_json::from_str::<serde_json::Value>(response) {
        Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| response.to_string()),
        Err(_) => response.to_string(),
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn urlencode_preserves_unreserved_chars() {
        assert_eq!(super::urlencode("ABCabc123-_."), "ABCabc123-_.");
        assert_eq!(super::urlencode("~"), "~");
    }

    #[test]
    fn urlencode_encodes_space_as_percent_20() {
        assert_eq!(super::urlencode("hello world"), "hello%20world");
    }

    #[test]
    fn urlencode_encodes_special_chars() {
        assert_eq!(super::urlencode("a&b=c+d/e"), "a%26b%3Dc%2Bd%2Fe");
    }

    #[test]
    fn urlencode_handles_empty_string() {
        assert_eq!(super::urlencode(""), "");
    }

    #[test]
    fn hmac_sha1_is_deterministic() {
        let key = b"sekret";
        let data = "hello";
        let a = super::hmac_sha1(key, data);
        let b = super::hmac_sha1(key, data);
        assert_eq!(a, b);
    }

    #[test]
    fn hmac_sha1_different_keys_produce_different_output() {
        let a = super::hmac_sha1(b"key1", "hello");
        let b = super::hmac_sha1(b"key2", "hello");
        assert_ne!(a, b);
    }

    #[test]
    fn hmac_sha1_returns_base64_encoded_string() {
        let result = super::hmac_sha1(b"key", "data");
        assert!(
            result
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
        );
        assert!(!result.is_empty());
    }
}