1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct CmdRequest {
17 pub cmd: String,
19 #[serde(rename = "as")]
21 pub as_nick: String,
22 #[serde(default)]
24 pub args: Value,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub cap: Option<String>,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct CmdReply {
33 pub ok: bool,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub error: Option<String>,
38 #[serde(default)]
40 pub data: Value,
41}
42
43impl CmdReply {
44 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 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 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 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 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 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 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 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 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}