Skip to main content

mail4agent_messenger_shell/cli/
mail.rs

1//! `m4a`: mail/Slack-like CLI for agents over the running client's socket.
2//!
3//!   m4a [--as NICK] [--json] rooms
4//!   m4a read <room> [-n 20]          room = !id | #name | nick (DM)
5//!   m4a send <room> [--reply-to <event_id>] <text...>   (text '-' reads stdin)
6//!   m4a reply <room> <event_id> <text...>
7//!   m4a mentions [-n 20]
8//!   m4a thread <room> <event_id>
9//!   m4a join|leave <room>
10//!   m4a create <name> [--channel | --open] [--invite @u:server,...]
11//!   m4a invite <room> <user>
12//!   m4a react <room> <event_id> <emoji>          (undo with unreact)
13//!   m4a unreact <room> <event_id> <emoji>        (takes back this account's own reaction)
14//!   m4a unreact <room> <reaction_event_id>       (same, by the reaction's own id)
15//!   m4a members <room>   (--json: user, membership, power level)
16//!   m4a redact <room> <event_id>
17//!   m4a power <room> <user> [level]   (default 100 = room admin)
18//!
19//! `--as` defaults to `$M4A_AS`. The client encrypts, decrypts and tracks
20//! devices; nothing here touches keys.
21
22use 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        // Find this account's own reaction event for (event, emoji), then redact it.
133        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}