Skip to main content

mail4agent_messenger_shell/
cmd.rs

1//! M5: rich agent commands over the client's local socket (mail/Slack-like).
2//! One JSON line `{"cmd":"rooms.list","as":"<nick>", ...}` in, one JSON line
3//! out. No crypto in the request or the answer: the shell encrypts, decrypts
4//! and tracks devices itself. A line without `cmd` is the legacy send request.
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::{
10    mxid_localpart, CreateRoomKind, ItemContent, MessageKind, MessengerCommand, OpenedStore,
11    OutgoingMessage, RoomId, UserId,
12};
13
14/// One command request.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct CmdRequest {
17    /// Command name, e.g. `rooms.list`.
18    pub cmd: String,
19    /// Session nick that runs the command.
20    #[serde(rename = "as")]
21    pub as_nick: String,
22    /// Command arguments (command specific).
23    #[serde(default)]
24    pub args: Value,
25    /// The calling session's capability (see [`crate::SendRequest::cap`]).
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub cap: Option<String>,
28}
29
30/// The answer to one [`CmdRequest`].
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct CmdReply {
33    /// Success flag.
34    pub ok: bool,
35    /// Why it failed. No secrets.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub error: Option<String>,
38    /// Command result.
39    #[serde(default)]
40    pub data: Value,
41}
42
43impl CmdReply {
44    /// A failed reply.
45    pub fn failed(error: impl Into<String>) -> Self {
46        Self { ok: false, error: Some(error.into()), data: Value::Null }
47    }
48    fn ok(data: Value) -> Self {
49        Self { ok: true, error: None, data }
50    }
51}
52
53fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
54    args.get(key).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty())
55}
56
57impl OpenedStore {
58    /// Display title of a room: its name, or the other members for a DM/group.
59    fn room_title(&self, room_id: &RoomId) -> String {
60        let me = self.driver.core.user_id().clone();
61        let Some(state) = self.driver.core.room_state(room_id) else { return String::new() };
62        if let Some(name) = state.name.as_deref().filter(|n| !n.is_empty()) {
63            return name.to_string();
64        }
65        let others: Vec<String> = state
66            .members
67            .iter()
68            .filter(|(id, _)| **id != me)
69            .map(|(id, m)| m.displayname.clone().unwrap_or_else(|| mxid_localpart(id.as_str()).to_string()))
70            .collect();
71        others.join(", ")
72    }
73
74    /// Resolves `room` (id, `#name`, name, or a nick for a DM) to a joined room id.
75    fn resolve_room(&mut self, spec: &str, now_ms: i64) -> Result<RoomId, String> {
76        if spec.starts_with('!') {
77            return RoomId::parse(spec).map_err(|e| e.to_string());
78        }
79        let wanted = spec.trim_start_matches(['#', '@']).to_ascii_lowercase();
80        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
81        let by_name: Vec<RoomId> = ids
82            .iter()
83            .filter(|id| self.room_title(id).to_ascii_lowercase() == wanted)
84            .cloned()
85            .collect();
86        if by_name.len() == 1 {
87            return Ok(by_name[0].clone());
88        }
89        if by_name.len() > 1 {
90            return Err(format!("{spec} matches several rooms; use the room id"));
91        }
92        if spec.starts_with('#') {
93            return Err(format!("no room named {spec}"));
94        }
95        let id = self.ensure_dm(spec, now_ms).map_err(|e| format!("{spec}: {e}"))?;
96        RoomId::parse(&id).map_err(|e| e.to_string())
97    }
98
99    fn settle_cmd(&mut self, now_ms: i64) {
100        for i in 1..=6 {
101            let _ = self.drive(now_ms + i * 1_000, false);
102            std::thread::sleep(std::time::Duration::from_millis(200));
103        }
104    }
105
106    fn rows(&self, room_id: &RoomId, limit: usize) -> Vec<Value> {
107        let Some(timeline) = self.driver.core.timeline(room_id) else { return Vec::new() };
108        let mut out: Vec<Value> = timeline
109            .items()
110            .iter()
111            .filter_map(|item| {
112                let (kind, body) = match &item.content {
113                    ItemContent::Text(t) => ("text", t.body.clone()),
114                    ItemContent::Notice(t) => ("notice", t.body.clone()),
115                    ItemContent::Emote(t) => ("emote", t.body.clone()),
116                    ItemContent::Undecryptable { .. } => ("undecryptable", String::new()),
117                    // Membership: body is "<user> <joined|left|invited|...>"; the subject is in "subject".
118                    ItemContent::StateChange(mail4agent_messenger::room::timeline::StateChangeSummary::Member { user_id, membership, .. }) => {
119                        let verb = match format!("{membership:?}").to_lowercase().as_str() {
120                            "join" => "joined",
121                            "leave" => if user_id.as_str() == item.sender.as_str() { "left" } else { "was removed" },
122                            "invite" => "was invited",
123                            "ban" => "was banned",
124                            other => return Some(json!({ "event_id": item.event_id.as_ref().map(|e| e.as_str()), "from": mxid_localpart(item.sender.as_str()), "ts": item.origin_server_ts, "kind": "member", "subject": mxid_localpart(user_id), "body": format!("{} {other}", mxid_localpart(user_id)) })),
125                        };
126                        return Some(json!({ "event_id": item.event_id.as_ref().map(|e| e.as_str()), "from": mxid_localpart(item.sender.as_str()), "ts": item.origin_server_ts, "kind": "member", "subject": mxid_localpart(user_id), "body": format!("{} {verb}", mxid_localpart(user_id)) }));
127                    }
128                    _ => return None,
129                };
130                Some(json!({
131                    "event_id": item.event_id.as_ref().map(|e| e.as_str()),
132                    "from": mxid_localpart(item.sender.as_str()),
133                    "from_id": item.sender.as_str(),
134                    "ts": item.origin_server_ts,
135                    "kind": kind,
136                    "body": body,
137                    "reply_to": item.relations.reply_to.as_ref().map(|e| e.as_str()),
138                    "thread": item.relations.thread_root.as_ref().map(|e| e.as_str()),
139                    "reactions": item.relations.reactions.iter().map(|(k, who)| {
140                        // This account's own reaction event for `k`, so a client can take it back (redact).
141                        let mine = item.event_id.as_ref().and_then(|t| {
142                            timeline.relating_events_for(t).iter().find(|r| &r.sender == self.driver.core.user_id() && matches!(&r.relation, mail4agent_messenger::wire::events::RelatesTo::Annotation { key, .. } if key == k)).map(|r| r.event_id.as_str().to_string())
143                        });
144                        json!({ "key": k, "count": who.len(), "by": who.iter().map(|u| mxid_localpart(u.as_str())).collect::<Vec<_>>(), "mine": mine })
145                    }).collect::<Vec<_>>(),
146                    "state": crate::outcome_name(&item.send_state),
147                }))
148            })
149            .collect();
150        let skip = out.len().saturating_sub(limit);
151        out.drain(..skip);
152        out
153    }
154
155    fn send_into(&mut self, room: &RoomId, text: &str, reply_to: Option<&str>, interrupt: bool, now_ms: i64) -> CmdReply {
156        let reply_to = match reply_to {
157            Some(id) => match crate::EventId::parse(id) {
158                Ok(id) => Some(id),
159                Err(e) => return CmdReply::failed(format!("event id: {e}")),
160            },
161            None => None,
162        };
163        // Refuse here, not at the server: a send into a room this session is not in only earns a refusal
164        // that would be stored as a failed row.
165        let me = self.driver.core.user_id().as_str().to_string();
166        if !self.member_joined(room.as_str(), &me) {
167            return CmdReply::failed("this session has not joined that room (join it first)");
168        }
169        // `--interrupt`: the letter asks its tagged addressees to cancel their live turn. The sender
170        // may only ask when it is on its own interrupt list (if one is configured here); otherwise
171        // the flag is dropped and the letter goes as an ordinary one. The receiver checks again.
172        let targets = if interrupt { crate::interrupt::mention_tags(text) } else { Vec::new() };
173        if interrupt && targets.is_empty() {
174            return CmdReply::failed("--interrupt needs the addressees tagged in the text (@nick)");
175        }
176        let mut interrupt_note = None;
177        let interrupt = interrupt
178            && match crate::interrupt::InterruptPolicy::load(self.store_dir()) {
179                Some(list) if !list.allows(&me, &me) => {
180                    eprintln!("m4a: {me} is not on the interrupt list: --interrupt ignored, sending an ordinary letter");
181                    interrupt_note = Some("ignored: this sender may not interrupt");
182                    false
183                }
184                _ => true,
185            };
186        let extra_content = interrupt.then(|| {
187            let mut extra = serde_json::Map::new();
188            extra.insert(crate::interrupt::INTERRUPT_KEY.to_string(), Value::Bool(true));
189            extra
190        });
191        let before = self.texts().len();
192        if let Err(e) = self.dispatch_send_with_content(
193            room.clone(),
194            OutgoingMessage { kind: MessageKind::Text, body: text.to_string(), reply_to, edit_of: None },
195            extra_content,
196            now_ms,
197        ) {
198            return CmdReply::failed(format!("send: {e}"));
199        }
200        let mut t = now_ms;
201        for _ in 0..30 {
202            t += 1_000;
203            let _ = self.drive(t, false);
204            std::thread::sleep(std::time::Duration::from_millis(200));
205            if let Some(row) = self.texts().into_iter().rev().find(|r| r.room_id == room.as_str() && r.body == text) {
206                if row.outcome == "sent" {
207                    let mut data = json!({ "room": room.as_str(), "event_id": row.event_id });
208                    if !targets.is_empty() {
209                        data["interrupt"] = json!({ "targets": targets, "sent": interrupt, "note": interrupt_note });
210                    }
211                    return CmdReply::ok(data);
212                }
213                if row.outcome.starts_with("failed") {
214                    return CmdReply::failed(format!("send {}", row.outcome));
215                }
216            }
217        }
218        let _ = before;
219        CmdReply::failed("send not confirmed yet (queued)")
220    }
221
222    /// Runs one rich command for this session.
223    pub fn run_command(&mut self, req: &CmdRequest, now_ms: i64) -> CmdReply {
224        let a = &req.args;
225        match req.cmd.as_str() {
226            "rooms.list" => {
227                let rooms: Vec<Value> = self
228                    .rooms()
229                    .into_iter()
230                    .filter(|r| r.membership != "absent" && r.membership != "leave")
231                    .map(|r| {
232                        let id = RoomId::parse(&r.room_id).ok();
233                        let title = id.as_ref().map(|i| self.room_title(i)).unwrap_or_default();
234                        let last = id.as_ref().and_then(|i| self.rows(i, 1).into_iter().next());
235                        json!({ "room": r.room_id, "title": title, "membership": r.membership,
236                                "encrypted": r.encrypted, "last": last })
237                    })
238                    .collect();
239                CmdReply::ok(json!({ "rooms": rooms }))
240            }
241            "rooms.read" => {
242                let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
243                let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
244                match self.resolve_room(spec, now_ms) {
245                    Ok(id) => CmdReply::ok(json!({ "room": id.as_str(), "title": self.room_title(&id), "messages": self.rows(&id, limit) })),
246                    Err(e) => CmdReply::failed(e),
247                }
248            }
249            "rooms.send" | "rooms.reply" => {
250                let (Some(spec), Some(text)) = (arg_str(a, "room"), arg_str(a, "text")) else {
251                    return CmdReply::failed("room and text are required");
252                };
253                let reply_to = if req.cmd == "rooms.reply" {
254                    match arg_str(a, "event") {
255                        Some(e) => Some(e),
256                        None => return CmdReply::failed("event is required for a reply"),
257                    }
258                } else {
259                    None
260                };
261                let interrupt = a.get("interrupt").and_then(Value::as_bool).unwrap_or(false);
262                match self.resolve_room(spec, now_ms) {
263                    Ok(id) => self.send_into(&id, text, reply_to, interrupt, now_ms),
264                    Err(e) => CmdReply::failed(e),
265                }
266            }
267            "rooms.join" | "rooms.leave" => {
268                let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
269                let id = match self.resolve_room(spec, now_ms) {
270                    Ok(id) => id,
271                    Err(e) => return CmdReply::failed(e),
272                };
273                let command = if req.cmd == "rooms.join" {
274                    MessengerCommand::JoinRoom { room_id: id.clone() }
275                } else {
276                    MessengerCommand::LeaveRoom { room_id: id.clone() }
277                };
278                match self.dispatch(command, now_ms) {
279                    Ok(()) => {
280                        self.settle_cmd(now_ms);
281                        CmdReply::ok(json!({ "room": id.as_str() }))
282                    }
283                    Err(e) => CmdReply::failed(e.to_string()),
284                }
285            }
286            "rooms.create" => {
287                let Some(name) = arg_str(a, "name") else { return CmdReply::failed("name is required") };
288                let invite: Vec<UserId> = a
289                    .get("invite")
290                    .and_then(Value::as_array)
291                    .map(|v| v.iter().filter_map(Value::as_str).filter_map(|u| UserId::parse(u).ok()).collect())
292                    .unwrap_or_default();
293                let wanted = arg_str(a, "kind").unwrap_or("group");
294                // `open` is an open room: the server's public plaintext shape (a channel) with the
295                // write level lowered to 0 once the room exists, so everyone who joins can write.
296                let open_for_all = wanted == "open";
297                let kind = match wanted {
298                    "channel" | "open" => CreateRoomKind::Channel { name: name.to_string(), topic: arg_str(a, "topic").map(str::to_string) },
299                    "group" => CreateRoomKind::Group { name: name.to_string(), invite, members_can_invite: true },
300                    other => return CmdReply::failed(format!("kind must be group, channel or open, not {other}")),
301                };
302                match self.dispatch(MessengerCommand::CreateRoom { kind }, now_ms) {
303                    Ok(()) => {
304                        let mut found = None;
305                        for i in 1..=25 {
306                            let _ = self.drive(now_ms + i * 1_000, false);
307                            std::thread::sleep(std::time::Duration::from_millis(200));
308                            found = self.rooms().into_iter().find(|r| {
309                                r.membership == "join"
310                                    && RoomId::parse(&r.room_id).map(|i| self.room_title(&i) == name).unwrap_or(false)
311                            });
312                            if found.is_some() {
313                                break;
314                            }
315                        }
316                        if open_for_all {
317                            let Some(room) = found.as_ref().and_then(|r| RoomId::parse(&r.room_id).ok()) else {
318                                return CmdReply::failed("the room was created but did not show up; its write level is still moderator-only");
319                            };
320                            if let Err(e) = self.dispatch(MessengerCommand::SetEventsDefault { room_id: room, level: 0 }, now_ms) {
321                                return CmdReply::failed(format!("the room was created but its write level was not lowered: {e}"));
322                            }
323                            self.settle_cmd(now_ms);
324                        }
325                        CmdReply::ok(json!({ "room": found.map(|r| r.room_id) }))
326                    }
327                    Err(e) => CmdReply::failed(e.to_string()),
328                }
329            }
330            "rooms.invite" => {
331                let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
332                    return CmdReply::failed("room and user are required");
333                };
334                let id = match self.resolve_room(spec, now_ms) {
335                    Ok(id) => id,
336                    Err(e) => return CmdReply::failed(e),
337                };
338                let user = if who.starts_with('@') && who.contains(':') {
339                    who.to_string()
340                } else {
341                    match self.find_nick(who, now_ms) {
342                        Ok(found) => found.user_id,
343                        Err(e) => return CmdReply::failed(format!("{who}: {e}")),
344                    }
345                };
346                let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
347                match self.dispatch(MessengerCommand::Invite { room_id: id.clone(), user_id }, now_ms) {
348                    Ok(()) => {
349                        self.settle_cmd(now_ms);
350                        CmdReply::ok(json!({ "room": id.as_str() }))
351                    }
352                    Err(e) => CmdReply::failed(e.to_string()),
353                }
354            }
355            "keys.sign" => {
356                let (Some(domain), Some(payload)) = (arg_str(a, "domain"), arg_str(a, "payload")) else {
357                    return CmdReply::failed("domain and payload are required");
358                };
359                if payload.len() > 64 * 1024 {
360                    return CmdReply::failed("payload too large");
361                }
362                match self.driver.core.sign_scoped(domain, payload.as_bytes()) {
363                    Ok((key, sig)) => CmdReply::ok(json!({ "domain": domain, "key": key, "sig": sig })),
364                    Err(e) => CmdReply::failed(e.to_string()),
365                }
366            }
367            "rooms.react" => {
368                let (Some(spec), Some(ev), Some(key)) = (arg_str(a, "room"), arg_str(a, "event"), arg_str(a, "key")) else {
369                    return CmdReply::failed("room, event and key are required");
370                };
371                if key.is_empty() || key.len() > 64 {
372                    return CmdReply::failed("bad key");
373                }
374                let id = match self.resolve_room(spec, now_ms) {
375                    Ok(id) => id,
376                    Err(e) => return CmdReply::failed(e),
377                };
378                let Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
379                match self.dispatch(MessengerCommand::React { room_id: id.clone(), target, key: key.to_string() }, now_ms) {
380                    Ok(()) => {
381                        self.settle_cmd(now_ms);
382                        CmdReply::ok(json!({ "room": id.as_str() }))
383                    }
384                    Err(e) => CmdReply::failed(e.to_string()),
385                }
386            }
387            "rooms.members" => {
388                let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
389                let id = match self.resolve_room(spec, now_ms) {
390                    Ok(id) => id,
391                    Err(e) => return CmdReply::failed(e),
392                };
393                let Some(state) = self.driver.core.room_state(&id) else { return CmdReply::failed("unknown room") };
394                let members: Vec<Value> = state.members.iter().map(|(u, m)| json!({ "user": u.as_str(), "membership": format!("{:?}", m.membership).to_lowercase(), "level": state.power_levels.users.get(u).copied().unwrap_or(state.power_levels.users_default) })).collect();
395                CmdReply::ok(json!({ "room": id.as_str(), "members": members }))
396            }
397            "rooms.redact" => {
398                let (Some(spec), Some(ev)) = (arg_str(a, "room"), arg_str(a, "event")) else {
399                    return CmdReply::failed("room and event are required");
400                };
401                let id = match self.resolve_room(spec, now_ms) {
402                    Ok(id) => id,
403                    Err(e) => return CmdReply::failed(e),
404                };
405                let Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
406                match self.dispatch(MessengerCommand::Redact { room_id: id.clone(), target, reason: None }, now_ms) {
407                    Ok(()) => {
408                        self.settle_cmd(now_ms);
409                        CmdReply::ok(json!({ "room": id.as_str() }))
410                    }
411                    Err(e) => CmdReply::failed(e.to_string()),
412                }
413            }
414            "rooms.power" => {
415                let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
416                    return CmdReply::failed("room and user are required");
417                };
418                let level = a.get("level").and_then(Value::as_i64).unwrap_or(100);
419                let id = match self.resolve_room(spec, now_ms) {
420                    Ok(id) => id,
421                    Err(e) => return CmdReply::failed(e),
422                };
423                let user = if who.starts_with('@') && who.contains(':') {
424                    who.to_string()
425                } else {
426                    match self.find_nick(who, now_ms) {
427                        Ok(found) => found.user_id,
428                        Err(e) => return CmdReply::failed(format!("{who}: {e}")),
429                    }
430                };
431                let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
432                match self.dispatch(MessengerCommand::SetPowerLevel { room_id: id.clone(), user_id, level }, now_ms) {
433                    Ok(()) => {
434                        self.settle_cmd(now_ms);
435                        CmdReply::ok(json!({ "room": id.as_str(), "level": level }))
436                    }
437                    Err(e) => CmdReply::failed(e.to_string()),
438                }
439            }
440            "mentions" => {
441                let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
442                let me = self.nick().unwrap_or("").to_ascii_lowercase();
443                let my_id = self.driver.core.user_id().as_str().to_string();
444                let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
445                let mut hits = Vec::new();
446                for id in ids {
447                    for mut row in self.rows(&id, 200) {
448                        let body = row["body"].as_str().unwrap_or("").to_ascii_lowercase();
449                        let from_me = row["from_id"].as_str() == Some(my_id.as_str());
450                        if !from_me && !me.is_empty() && body.contains(&format!("@{me}")) {
451                            row["room"] = json!(id.as_str());
452                            hits.push(row);
453                        }
454                    }
455                }
456                hits.sort_by_key(|r| r["ts"].as_i64().unwrap_or(0));
457                let skip = hits.len().saturating_sub(limit);
458                hits.drain(..skip);
459                CmdReply::ok(json!({ "mentions": hits }))
460            }
461            "threads.read" => {
462                let (Some(spec), Some(root)) = (arg_str(a, "room"), arg_str(a, "event")) else {
463                    return CmdReply::failed("room and event are required");
464                };
465                match self.resolve_room(spec, now_ms) {
466                    Ok(id) => {
467                        let rows: Vec<Value> = self
468                            .rows(&id, 500)
469                            .into_iter()
470                            .filter(|r| r["event_id"].as_str() == Some(root) || r["thread"].as_str() == Some(root) || r["reply_to"].as_str() == Some(root))
471                            .collect();
472                        CmdReply::ok(json!({ "room": id.as_str(), "messages": rows }))
473                    }
474                    Err(e) => CmdReply::failed(e),
475                }
476            }
477            other => CmdReply::failed(format!("unknown command {other}")),
478        }
479    }
480}