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;
#[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 {
name: String,
#[arg(short, long, default_value = "20")]
limit: usize,
},
Send {
name: String,
message: String,
#[arg(short, long)]
copy: bool,
},
Chats {
#[arg(short, long, default_value = "20")]
limit: usize,
},
}
#[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,
}
#[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)
}
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()
}
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()
}
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}'"));
}
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];
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);
}
}
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}'"));
}
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(())
}
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);
}
}