keryx 0.1.2

WhatsApp CLI wrapper with contact resolution, dual-JID merging, and daemon-aware send
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

const WACLI: &str = "/opt/homebrew/bin/wacli";
const CACHE_TTL_SECS: u64 = 3600;

// ── CLI ──────────────────────────────────────────────────────────────────────

#[derive(Parser)]
#[command(name = "keryx", about = "WhatsApp via name — resolves contacts, merges dual-JID threads")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Read conversation with a contact (merges phone + LID JIDs)
    Read {
        name: String,
        #[arg(short, long, default_value = "20")]
        limit: usize,
    },
    /// Print daemon-safe send command block (does not execute)
    Send {
        name: String,
        message: String,
        /// Copy command block to clipboard (macOS)
        #[arg(short, long)]
        copy: bool,
    },
    /// List recent chats
    Chats {
        #[arg(short, long, default_value = "20")]
        limit: usize,
    },
}

// ── wacli JSON types ─────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct WacliResponse<T> {
    success: bool,
    data: Option<T>,
    error: Option<String>,
}

#[derive(Deserialize, Serialize, Clone)]
struct Contact {
    #[serde(rename = "JID")]
    jid: String,
    #[serde(rename = "Phone")]
    phone: String,
    #[serde(rename = "Name")]
    name: String,
}

#[derive(Deserialize, Clone)]
struct Message {
    #[serde(rename = "MsgID")]
    msg_id: String,
    #[serde(rename = "Timestamp")]
    timestamp: String,
    #[serde(rename = "FromMe")]
    from_me: bool,
    #[serde(rename = "Text")]
    text: String,
}

#[derive(Deserialize)]
struct MessagesData {
    messages: Vec<Message>,
}

#[derive(Deserialize)]
struct Chat {
    #[serde(rename = "JID")]
    jid: String,
    #[serde(rename = "Kind")]
    kind: String,
    #[serde(rename = "Name")]
    name: String,
    #[serde(rename = "LastMessageTS")]
    last_message_ts: String,
}

// ── Cache ────────────────────────────────────────────────────────────────────

#[derive(Serialize, Deserialize)]
struct CacheEntry {
    contacts: Vec<Contact>,
    cached_at: u64,
}

#[derive(Serialize, Deserialize, Default)]
struct Cache {
    entries: HashMap<String, CacheEntry>,
}

fn cache_path() -> PathBuf {
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from("~/.config"))
        .join("keryx")
        .join("contacts.json")
}

fn load_cache() -> Cache {
    let path = cache_path();
    fs::read_to_string(path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()
}

fn save_cache(cache: &Cache) {
    let path = cache_path();
    if let Some(parent) = path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    if let Ok(data) = serde_json::to_string_pretty(cache) {
        let _ = fs::write(path, data);
    }
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

// ── wacli helpers ────────────────────────────────────────────────────────────

fn wacli_json<T: for<'de> Deserialize<'de>>(args: &[&str]) -> Result<T, String> {
    let mut full_args = vec!["--json"];
    full_args.extend_from_slice(args);

    let output = Command::new(WACLI)
        .args(&full_args)
        .output()
        .map_err(|e| format!("failed to run wacli: {e}"))?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let resp: WacliResponse<T> =
        serde_json::from_str(&stdout).map_err(|e| format!("failed to parse wacli output: {e}"))?;

    if !resp.success {
        return Err(resp.error.unwrap_or_else(|| "wacli returned an error".to_string()));
    }

    resp.data.ok_or_else(|| "wacli returned no data".to_string())
}

fn resolve_contact(name: &str) -> Result<Vec<Contact>, String> {
    let key = name.to_lowercase();
    let mut cache = load_cache();

    if let Some(entry) = cache.entries.get(&key).filter(|e| now_secs() - e.cached_at < CACHE_TTL_SECS) {
        return Ok(entry.contacts.clone());
    }

    let all: Vec<Contact> = wacli_json(&["contacts", "search", name])?;
    let matched: Vec<Contact> = all
        .into_iter()
        .filter(|c| c.name.to_lowercase().contains(&key))
        .collect();

    cache.entries.insert(
        key,
        CacheEntry {
            contacts: matched.clone(),
            cached_at: now_secs(),
        },
    );
    save_cache(&cache);

    Ok(matched)
}

fn chat_jids_for(name: &str) -> Vec<String> {
    let name_lower = name.to_lowercase();
    let chats: Vec<Chat> = wacli_json(&["chats", "list", "--query", name]).unwrap_or_default();
    chats
        .into_iter()
        .filter(|c| c.name.to_lowercase().contains(&name_lower))
        .map(|c| c.jid)
        .collect()
}

fn read_messages(jid: &str, limit: usize) -> Vec<Message> {
    let limit_str = limit.to_string();
    wacli_json::<MessagesData>(&["messages", "list", "--chat", jid, "--limit", &limit_str])
        .map(|d| d.messages)
        .unwrap_or_default()
}

// ── Formatting ───────────────────────────────────────────────────────────────

fn hhmm(ts: &str) -> &str {
    if ts.len() >= 16 { &ts[11..16] } else { ts }
}

fn expand_home(path: &str) -> String {
    if let Some((rest, home)) = path.strip_prefix("~/").zip(dirs::home_dir()) {
        return format!("{}/{rest}", home.display());
    }
    path.to_string()
}

// ── Commands ─────────────────────────────────────────────────────────────────

fn cmd_read(name: &str, limit: usize) -> Result<(), String> {
    let contacts = resolve_contact(name)?;

    if contacts.is_empty() {
        return Err(format!("no contact found matching '{name}'"));
    }

    // Group by display name — multiple JIDs for the same name = same person (dual-JID case)
    let unique_names: std::collections::HashSet<String> =
        contacts.iter().map(|c| c.name.to_lowercase()).collect();
    if unique_names.len() > 1 {
        eprintln!("Multiple contacts match '{name}' — be more specific:");
        for name in &unique_names {
            eprintln!("  {name}");
        }
        std::process::exit(1);
    }

    let contact = &contacts[0];

    // Collect all JIDs: from contacts search + chats list (catches LID entries)
    let mut jids: Vec<String> = contacts.iter().map(|c| c.jid.clone()).collect();
    for jid in chat_jids_for(name) {
        if !jids.contains(&jid) {
            jids.push(jid);
        }
    }

    // Query all JIDs and merge
    let mut all_msgs: Vec<Message> = jids
        .iter()
        .flat_map(|jid| read_messages(jid, limit))
        .collect();

    all_msgs.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
    all_msgs.dedup_by(|a, b| a.msg_id == b.msg_id);

    let start = all_msgs.len().saturating_sub(limit);
    for msg in &all_msgs[start..] {
        let sender = if msg.from_me { "Me" } else { contact.name.as_str() };
        if !msg.text.is_empty() {
            println!("[{}] {}: {}", hhmm(&msg.timestamp), sender, msg.text);
        }
    }

    Ok(())
}

fn cmd_send(name: &str, message: &str, copy: bool) -> Result<(), String> {
    let contacts = resolve_contact(name)?;

    if contacts.is_empty() {
        return Err(format!("no contact found matching '{name}'"));
    }

    // Multiple JIDs for the same name = same person — OK for send
    let unique_names: std::collections::HashSet<String> =
        contacts.iter().map(|c| c.name.to_lowercase()).collect();
    if unique_names.len() > 1 {
        eprintln!("Multiple contacts match '{name}' — be more specific:");
        for n in &unique_names {
            eprintln!("  {n}");
        }
        std::process::exit(1);
    }

    let contact = &contacts[0];
    let jid = if contact.jid.ends_with("@s.whatsapp.net") {
        contact.jid.clone()
    } else if !contact.phone.is_empty() {
        format!("{}@s.whatsapp.net", contact.phone)
    } else {
        contact.jid.clone()
    };

    let plist = expand_home("~/Library/LaunchAgents/com.terry.wacli-sync.plist");

    let block = format!(
        "# Send to {} ({})\nlaunchctl unload {plist}\nwacli send text --to \"{jid}\" --message \"{message}\"\nlaunchctl load {plist}",
        contact.name, jid
    );

    if copy {
        let status = Command::new("pbcopy")
            .stdin(std::process::Stdio::piped())
            .spawn()
            .and_then(|mut child| {
                use std::io::Write;
                child.stdin.as_mut().unwrap().write_all(block.as_bytes())?;
                child.wait()
            });
        match status {
            Ok(_) => eprintln!("copied to clipboard"),
            Err(e) => eprintln!("clipboard failed: {e}"),
        }
    }

    println!("{block}");

    Ok(())
}

fn cmd_chats(limit: usize) -> Result<(), String> {
    let limit_str = limit.to_string();
    let chats: Vec<Chat> = wacli_json(&["chats", "list", "--limit", &limit_str])?;

    for chat in &chats {
        println!("[{}] {:8}  {}", hhmm(&chat.last_message_ts), chat.kind, chat.name);
    }

    Ok(())
}

// ── Entry point ───────────────────────────────────────────────────────────────

fn main() {
    let cli = Cli::parse();

    let result = match &cli.command {
        Commands::Read { name, limit } => cmd_read(name, *limit),
        Commands::Send { name, message, copy } => cmd_send(name, message, *copy),
        Commands::Chats { limit } => cmd_chats(*limit),
    };

    if let Err(e) = result {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}