1use crate::{load_env_file, send_cmd_via_socket, send_sock_path, CmdRequest, STORE_ROOT_ENV};
23use serde_json::{json, Value};
24use std::io::Read;
25use std::path::PathBuf;
26use std::time::Duration;
27
28fn fail(code: i32, msg: &str) -> ! {
29 eprintln!("m4a: {msg}");
30 std::process::exit(code);
31}
32
33fn hhmm(ts_ms: i64) -> String {
34 let secs = ts_ms.div_euclid(1000);
35 let day = secs.rem_euclid(86_400);
36 format!("{:02}:{:02}", day / 3600, (day % 3600) / 60)
37}
38
39fn text_arg(words: &[String]) -> String {
40 if words.is_empty() || words == ["-"] {
41 let mut text = String::new();
42 let _ = std::io::stdin().read_to_string(&mut text);
43 text.trim_end_matches('\n').to_string()
44 } else {
45 words.join(" ")
46 }
47}
48
49fn print_rows(rows: &[Value], with_room: bool) {
50 for row in rows {
51 let reply = row["reply_to"].as_str().map(|_| " ↩").unwrap_or("");
52 let room = if with_room { format!(" {}", row["room"].as_str().unwrap_or("")) } else { String::new() };
53 let body = match row["kind"].as_str() {
54 Some("undecryptable") => "[cannot decrypt yet]".to_string(),
55 _ => row["body"].as_str().unwrap_or("").to_string(),
56 };
57 println!(
58 "[{}]{} {}{}: {} ({})",
59 hhmm(row["ts"].as_i64().unwrap_or(0)),
60 room,
61 row["from"].as_str().unwrap_or("?"),
62 reply,
63 body,
64 row["event_id"].as_str().unwrap_or("")
65 );
66 }
67}
68
69pub fn run(args: Vec<String>) {
70 let mut as_nick = std::env::var("M4A_AS").ok().filter(|v| !v.is_empty());
71 let mut as_json = false;
72 let mut n: Option<u64> = None;
73 let mut channel = false;
74 let mut open = false;
75 let mut invite: Vec<String> = Vec::new();
76 let mut reply_to: Option<String> = None;
77 let mut pos: Vec<String> = Vec::new();
78 let mut args = args.clone().into_iter();
79 while let Some(arg) = args.next() {
80 match arg.as_str() {
81 "--as" => as_nick = args.next(),
82 "--json" => as_json = true,
83 "-n" => n = args.next().and_then(|v| v.parse().ok()),
84 "--channel" => channel = true,
85 "--open" => open = true,
86 "--reply-to" => reply_to = args.next(),
87 "--invite" => invite = args.next().unwrap_or_default().split(',').map(str::to_string).collect(),
88 "-h" | "--help" => {
89 println!("usage: m4a [--as NICK] [--json] rooms | read <room> [-n N] | send <room> [--reply-to <event>] <text> | reply <room> <event> <text> | react <room> <event> <emoji> | unreact <room> <event> <emoji> | unreact <room> <reaction_event> | mentions | thread <room> <event> | join|leave <room> | create <name> [--channel | --open] [--invite ids] | invite <room> <user> | members <room> | redact <room> <event>");
90 return;
91 }
92 _ => pos.push(arg),
93 }
94 }
95 let Some(as_nick) = as_nick else { fail(2, "--as NICK (or M4A_AS) is required") };
96 if pos.is_empty() {
97 fail(2, "command is required (rooms, read, send, reply, mentions, thread, join, leave, create, invite)");
98 }
99 let sub = pos.remove(0);
100 let need = |i: usize, what: &str| -> String {
101 pos.get(i).cloned().unwrap_or_else(|| fail(2, &format!("{what} is required")))
102 };
103 let (cmd, cargs) = match sub.as_str() {
104 "rooms" | "ls" => ("rooms.list", json!({})),
105 "read" => ("rooms.read", json!({ "room": need(0, "room"), "limit": n.unwrap_or(20) })),
106 "send" => match &reply_to {
107 Some(ev) => ("rooms.reply", json!({ "room": need(0, "room"), "event": ev, "text": text_arg(&pos[1.min(pos.len())..]) })),
108 None => ("rooms.send", json!({ "room": need(0, "room"), "text": text_arg(&pos[1.min(pos.len())..]) })),
109 },
110 "reply" => ("rooms.reply", json!({ "room": need(0, "room"), "event": need(1, "event id"), "text": text_arg(&pos[2.min(pos.len())..]) })),
111 "mentions" => ("mentions", json!({ "limit": n.unwrap_or(20) })),
112 "thread" => ("threads.read", json!({ "room": need(0, "room"), "event": need(1, "event id") })),
113 "join" => ("rooms.join", json!({ "room": need(0, "room") })),
114 "leave" => ("rooms.leave", json!({ "room": need(0, "room") })),
115 "create" => ("rooms.create", json!({ "name": need(0, "name"), "kind": if open { "open" } else if channel { "channel" } else { "group" }, "invite": invite })),
116 "react" => ("rooms.react", json!({ "room": need(0, "room"), "event": need(1, "event id"), "key": need(2, "emoji") })),
117 "members" => ("rooms.members", json!({ "room": need(0, "room") })),
118 "unreact" if pos.len() >= 3 => ("unreact", json!({ "room": need(0, "room"), "event": need(1, "event id"), "key": need(2, "emoji") })),
119 "unreact" => ("rooms.redact", json!({ "room": need(0, "room"), "event": need(1, "reaction event id") })),
120 "redact" => ("rooms.redact", json!({ "room": need(0, "room"), "event": need(1, "event id") })),
121 "power" => ("rooms.power", json!({ "room": need(0, "room"), "user": need(1, "user"), "level": pos.get(2).and_then(|v| v.parse::<i64>().ok()).unwrap_or(100) })),
122 "invite" => ("rooms.invite", json!({ "room": need(0, "room"), "user": need(1, "user") })),
123 other => fail(2, &format!("unknown command {other}")),
124 };
125 load_env_file();
126 let get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
127 let Some(store_root) = get(STORE_ROOT_ENV).map(PathBuf::from) else {
128 fail(1, "M4A_STORE_ROOT is not set (environment or env file)");
129 };
130 let sock = send_sock_path(get, &store_root);
131 let (cmd, cargs) = if cmd == "unreact" {
132 let read = CmdRequest { cmd: "rooms.read".into(), as_nick: as_nick.clone(), args: json!({ "room": cargs["room"], "limit": 200 }), cap: crate::send::session_cap_from_env() };
134 let r = send_cmd_via_socket(&sock, &read, Duration::from_secs(90)).unwrap_or_else(|_| fail(1, "no running m4a-web-client on the socket"));
135 if !r.ok {
136 fail(1, r.error.as_deref().unwrap_or("read failed"));
137 }
138 let mine = r.data["messages"].as_array().into_iter().flatten()
139 .filter(|m| m["event_id"] == cargs["event"])
140 .flat_map(|m| m["reactions"].as_array().cloned().unwrap_or_default())
141 .find(|x| x["key"] == cargs["key"])
142 .and_then(|x| x["mine"].as_str().map(str::to_string))
143 .unwrap_or_else(|| fail(1, "no own reaction with that emoji on that event"));
144 ("rooms.redact", json!({ "room": cargs["room"], "event": mine }))
145 } else {
146 (cmd, cargs)
147 };
148 let request = CmdRequest { cmd: cmd.to_string(), as_nick, args: cargs, cap: crate::send::session_cap_from_env() };
149 let reply = send_cmd_via_socket(&sock, &request, Duration::from_secs(90))
150 .unwrap_or_else(|_| fail(1, "no running m4a-web-client on the socket (run scripts/m4a-boot.sh)"));
151 if !reply.ok {
152 fail(1, reply.error.as_deref().unwrap_or("command failed"));
153 }
154 if as_json {
155 println!("{}", serde_json::to_string_pretty(&reply.data).unwrap_or_default());
156 return;
157 }
158 let d = &reply.data;
159 match cmd {
160 "rooms.list" => {
161 for r in d["rooms"].as_array().into_iter().flatten() {
162 let last = r["last"]["body"].as_str().unwrap_or("");
163 println!(
164 "{} {:<24} {}{} {}",
165 r["room"].as_str().unwrap_or(""),
166 r["title"].as_str().unwrap_or(""),
167 r["membership"].as_str().unwrap_or(""),
168 if r["encrypted"].as_bool() == Some(true) { " 🔒" } else { "" },
169 last.chars().take(50).collect::<String>()
170 );
171 }
172 }
173 "rooms.read" | "threads.read" => {
174 if let Some(t) = d["title"].as_str() {
175 println!("== {t} ==");
176 }
177 print_rows(d["messages"].as_array().map(Vec::as_slice).unwrap_or(&[]), false);
178 }
179 "mentions" => print_rows(d["mentions"].as_array().map(Vec::as_slice).unwrap_or(&[]), true),
180 "rooms.send" | "rooms.reply" => println!(
181 "sent room={} event={}",
182 d["room"].as_str().unwrap_or(""),
183 d["event_id"].as_str().unwrap_or("")
184 ),
185 "rooms.react" => println!("reacted room={}", d["room"].as_str().unwrap_or("")),
186 _ => println!("ok {}", d["room"].as_str().unwrap_or("")),
187 }
188}