use crate::{load_env_file, send_cmd_via_socket, send_sock_path, CmdRequest, STORE_ROOT_ENV};
use serde_json::{json, Value};
use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
fn fail(code: i32, msg: &str) -> ! {
eprintln!("m4a: {msg}");
std::process::exit(code);
}
fn hhmm(ts_ms: i64) -> String {
let secs = ts_ms.div_euclid(1000);
let day = secs.rem_euclid(86_400);
format!("{:02}:{:02}", day / 3600, (day % 3600) / 60)
}
fn text_arg(words: &[String]) -> String {
if words.is_empty() || words == ["-"] {
let mut text = String::new();
let _ = std::io::stdin().read_to_string(&mut text);
text.trim_end_matches('\n').to_string()
} else {
words.join(" ")
}
}
fn print_rows(rows: &[Value], with_room: bool) {
for row in rows {
let reply = row["reply_to"].as_str().map(|_| " ↩").unwrap_or("");
let room = if with_room { format!(" {}", row["room"].as_str().unwrap_or("")) } else { String::new() };
let body = match row["kind"].as_str() {
Some("undecryptable") => "[cannot decrypt yet]".to_string(),
_ => row["body"].as_str().unwrap_or("").to_string(),
};
println!(
"[{}]{} {}{}: {} ({})",
hhmm(row["ts"].as_i64().unwrap_or(0)),
room,
row["from"].as_str().unwrap_or("?"),
reply,
body,
row["event_id"].as_str().unwrap_or("")
);
}
}
pub fn run(args: Vec<String>) {
let mut as_nick = std::env::var("M4A_AS").ok().filter(|v| !v.is_empty());
let mut as_json = false;
let mut n: Option<u64> = None;
let mut channel = false;
let mut public = false;
let mut default_level: Option<i64> = None;
let mut invite: Vec<String> = Vec::new();
let mut pos: Vec<String> = Vec::new();
let mut args = args.clone().into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--as" => as_nick = args.next(),
"--json" => as_json = true,
"-n" => n = args.next().and_then(|v| v.parse().ok()),
"--channel" => channel = true,
"--public" => public = true,
"--default" => default_level = args.next().and_then(|v| v.parse().ok()),
"--invite" => invite = args.next().unwrap_or_default().split(',').map(str::to_string).collect(),
"-h" | "--help" => {
println!("usage: m4a [--as NICK] [--json] rooms | read <room> [-n N] | send <room> <text> | reply <room> <event> <text> | mentions | thread <room> <event> | join|leave <room> | create <name> [--channel | --public] [--invite ids] | power <room> [--default N] [<user> <level>] | invite <room> <user>");
return;
}
_ => pos.push(arg),
}
}
let Some(as_nick) = as_nick else { fail(2, "--as NICK (or M4A_AS) is required") };
if pos.is_empty() {
fail(2, "command is required (rooms, read, send, reply, mentions, thread, join, leave, create, power, invite)");
}
let sub = pos.remove(0);
let need = |i: usize, what: &str| -> String {
pos.get(i).cloned().unwrap_or_else(|| fail(2, &format!("{what} is required")))
};
let (cmd, cargs) = match sub.as_str() {
"rooms" | "ls" => ("rooms.list", json!({})),
"read" => ("rooms.read", json!({ "room": need(0, "room"), "limit": n.unwrap_or(20) })),
"send" => ("rooms.send", json!({ "room": need(0, "room"), "text": text_arg(&pos[1.min(pos.len())..]) })),
"reply" => ("rooms.reply", json!({ "room": need(0, "room"), "event": need(1, "event id"), "text": text_arg(&pos[2.min(pos.len())..]) })),
"mentions" => ("mentions", json!({ "limit": n.unwrap_or(20) })),
"thread" => ("threads.read", json!({ "room": need(0, "room"), "event": need(1, "event id") })),
"join" => ("rooms.join", json!({ "room": need(0, "room") })),
"leave" => ("rooms.leave", json!({ "room": need(0, "room") })),
"create" => ("rooms.create", json!({ "name": need(0, "name"), "kind": if public { "public" } else if channel { "channel" } else { "group" }, "invite": invite })),
"power" => {
let mut a = json!({ "room": need(0, "room") });
if let Some(level) = default_level {
a["events_default"] = json!(level);
}
if pos.len() > 1 {
let level: i64 = need(2, "level").parse().unwrap_or_else(|_| fail(2, "level must be a number"));
a["user"] = json!(need(1, "user"));
a["level"] = json!(level);
}
("rooms.power", a)
}
"invite" => ("rooms.invite", json!({ "room": need(0, "room"), "user": need(1, "user") })),
other => fail(2, &format!("unknown command {other}")),
};
load_env_file();
let get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
let Some(store_root) = get(STORE_ROOT_ENV).map(PathBuf::from) else {
fail(1, "M4A_STORE_ROOT is not set (environment or env file)");
};
let sock = send_sock_path(get, &store_root);
let request = CmdRequest { cmd: cmd.to_string(), as_nick, args: cargs };
let reply = send_cmd_via_socket(&sock, &request, Duration::from_secs(90))
.unwrap_or_else(|_| fail(1, "no running m4a-web-client on the socket (run scripts/m4a-boot.sh)"));
if !reply.ok {
fail(1, reply.error.as_deref().unwrap_or("command failed"));
}
if as_json {
println!("{}", serde_json::to_string_pretty(&reply.data).unwrap_or_default());
return;
}
let d = &reply.data;
match cmd {
"rooms.list" => {
for r in d["rooms"].as_array().into_iter().flatten() {
let last = r["last"]["body"].as_str().unwrap_or("");
println!(
"{} {:<24} {}{} {}",
r["room"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["membership"].as_str().unwrap_or(""),
if r["encrypted"].as_bool() == Some(true) { " 🔒" } else { "" },
last.chars().take(50).collect::<String>()
);
}
}
"rooms.read" | "threads.read" => {
if let Some(t) = d["title"].as_str() {
println!("== {t} ==");
}
print_rows(d["messages"].as_array().map(Vec::as_slice).unwrap_or(&[]), false);
}
"mentions" => print_rows(d["mentions"].as_array().map(Vec::as_slice).unwrap_or(&[]), true),
"rooms.send" | "rooms.reply" => println!(
"sent room={} event={}",
d["room"].as_str().unwrap_or(""),
d["event_id"].as_str().unwrap_or("")
),
_ => println!("ok {}", d["room"].as_str().unwrap_or("")),
}
}