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>, 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 before = self.texts().len();
170 if let Err(e) = self.dispatch(
171 MessengerCommand::SendMessage {
172 room_id: room.clone(),
173 message: OutgoingMessage { kind: MessageKind::Text, body: text.to_string(), reply_to, edit_of: None },
174 txn_id: None,
175 },
176 now_ms,
177 ) {
178 return CmdReply::failed(format!("send: {e}"));
179 }
180 let mut t = now_ms;
181 for _ in 0..30 {
182 t += 1_000;
183 let _ = self.drive(t, false);
184 std::thread::sleep(std::time::Duration::from_millis(200));
185 if let Some(row) = self.texts().into_iter().rev().find(|r| r.room_id == room.as_str() && r.body == text) {
186 if row.outcome == "sent" {
187 return CmdReply::ok(json!({ "room": room.as_str(), "event_id": row.event_id }));
188 }
189 if row.outcome.starts_with("failed") {
190 return CmdReply::failed(format!("send {}", row.outcome));
191 }
192 }
193 }
194 let _ = before;
195 CmdReply::failed("send not confirmed yet (queued)")
196 }
197
198 pub fn run_command(&mut self, req: &CmdRequest, now_ms: i64) -> CmdReply {
200 let a = &req.args;
201 match req.cmd.as_str() {
202 "rooms.list" => {
203 let rooms: Vec<Value> = self
204 .rooms()
205 .into_iter()
206 .filter(|r| r.membership != "absent" && r.membership != "leave")
207 .map(|r| {
208 let id = RoomId::parse(&r.room_id).ok();
209 let title = id.as_ref().map(|i| self.room_title(i)).unwrap_or_default();
210 let last = id.as_ref().and_then(|i| self.rows(i, 1).into_iter().next());
211 json!({ "room": r.room_id, "title": title, "membership": r.membership,
212 "encrypted": r.encrypted, "last": last })
213 })
214 .collect();
215 CmdReply::ok(json!({ "rooms": rooms }))
216 }
217 "rooms.read" => {
218 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
219 let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
220 match self.resolve_room(spec, now_ms) {
221 Ok(id) => CmdReply::ok(json!({ "room": id.as_str(), "title": self.room_title(&id), "messages": self.rows(&id, limit) })),
222 Err(e) => CmdReply::failed(e),
223 }
224 }
225 "rooms.send" | "rooms.reply" => {
226 let (Some(spec), Some(text)) = (arg_str(a, "room"), arg_str(a, "text")) else {
227 return CmdReply::failed("room and text are required");
228 };
229 let reply_to = if req.cmd == "rooms.reply" {
230 match arg_str(a, "event") {
231 Some(e) => Some(e),
232 None => return CmdReply::failed("event is required for a reply"),
233 }
234 } else {
235 None
236 };
237 match self.resolve_room(spec, now_ms) {
238 Ok(id) => self.send_into(&id, text, reply_to, now_ms),
239 Err(e) => CmdReply::failed(e),
240 }
241 }
242 "rooms.join" | "rooms.leave" => {
243 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
244 let id = match self.resolve_room(spec, now_ms) {
245 Ok(id) => id,
246 Err(e) => return CmdReply::failed(e),
247 };
248 let command = if req.cmd == "rooms.join" {
249 MessengerCommand::JoinRoom { room_id: id.clone() }
250 } else {
251 MessengerCommand::LeaveRoom { room_id: id.clone() }
252 };
253 match self.dispatch(command, now_ms) {
254 Ok(()) => {
255 self.settle_cmd(now_ms);
256 CmdReply::ok(json!({ "room": id.as_str() }))
257 }
258 Err(e) => CmdReply::failed(e.to_string()),
259 }
260 }
261 "rooms.create" => {
262 let Some(name) = arg_str(a, "name") else { return CmdReply::failed("name is required") };
263 let invite: Vec<UserId> = a
264 .get("invite")
265 .and_then(Value::as_array)
266 .map(|v| v.iter().filter_map(Value::as_str).filter_map(|u| UserId::parse(u).ok()).collect())
267 .unwrap_or_default();
268 let wanted = arg_str(a, "kind").unwrap_or("group");
269 let open_for_all = wanted == "open";
272 let kind = match wanted {
273 "channel" | "open" => CreateRoomKind::Channel { name: name.to_string(), topic: arg_str(a, "topic").map(str::to_string) },
274 "group" => CreateRoomKind::Group { name: name.to_string(), invite, members_can_invite: true },
275 other => return CmdReply::failed(format!("kind must be group, channel or open, not {other}")),
276 };
277 match self.dispatch(MessengerCommand::CreateRoom { kind }, now_ms) {
278 Ok(()) => {
279 let mut found = None;
280 for i in 1..=25 {
281 let _ = self.drive(now_ms + i * 1_000, false);
282 std::thread::sleep(std::time::Duration::from_millis(200));
283 found = self.rooms().into_iter().find(|r| {
284 r.membership == "join"
285 && RoomId::parse(&r.room_id).map(|i| self.room_title(&i) == name).unwrap_or(false)
286 });
287 if found.is_some() {
288 break;
289 }
290 }
291 if open_for_all {
292 let Some(room) = found.as_ref().and_then(|r| RoomId::parse(&r.room_id).ok()) else {
293 return CmdReply::failed("the room was created but did not show up; its write level is still moderator-only");
294 };
295 if let Err(e) = self.dispatch(MessengerCommand::SetEventsDefault { room_id: room, level: 0 }, now_ms) {
296 return CmdReply::failed(format!("the room was created but its write level was not lowered: {e}"));
297 }
298 self.settle_cmd(now_ms);
299 }
300 CmdReply::ok(json!({ "room": found.map(|r| r.room_id) }))
301 }
302 Err(e) => CmdReply::failed(e.to_string()),
303 }
304 }
305 "rooms.invite" => {
306 let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
307 return CmdReply::failed("room and user are required");
308 };
309 let id = match self.resolve_room(spec, now_ms) {
310 Ok(id) => id,
311 Err(e) => return CmdReply::failed(e),
312 };
313 let user = if who.starts_with('@') && who.contains(':') {
314 who.to_string()
315 } else {
316 match self.find_nick(who, now_ms) {
317 Ok(found) => found.user_id,
318 Err(e) => return CmdReply::failed(format!("{who}: {e}")),
319 }
320 };
321 let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
322 match self.dispatch(MessengerCommand::Invite { room_id: id.clone(), user_id }, now_ms) {
323 Ok(()) => {
324 self.settle_cmd(now_ms);
325 CmdReply::ok(json!({ "room": id.as_str() }))
326 }
327 Err(e) => CmdReply::failed(e.to_string()),
328 }
329 }
330 "rooms.react" => {
331 let (Some(spec), Some(ev), Some(key)) = (arg_str(a, "room"), arg_str(a, "event"), arg_str(a, "key")) else {
332 return CmdReply::failed("room, event and key are required");
333 };
334 if key.is_empty() || key.len() > 64 {
335 return CmdReply::failed("bad key");
336 }
337 let id = match self.resolve_room(spec, now_ms) {
338 Ok(id) => id,
339 Err(e) => return CmdReply::failed(e),
340 };
341 let Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
342 match self.dispatch(MessengerCommand::React { room_id: id.clone(), target, key: key.to_string() }, now_ms) {
343 Ok(()) => {
344 self.settle_cmd(now_ms);
345 CmdReply::ok(json!({ "room": id.as_str() }))
346 }
347 Err(e) => CmdReply::failed(e.to_string()),
348 }
349 }
350 "rooms.members" => {
351 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
352 let id = match self.resolve_room(spec, now_ms) {
353 Ok(id) => id,
354 Err(e) => return CmdReply::failed(e),
355 };
356 let Some(state) = self.driver.core.room_state(&id) else { return CmdReply::failed("unknown room") };
357 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();
358 CmdReply::ok(json!({ "room": id.as_str(), "members": members }))
359 }
360 "rooms.redact" => {
361 let (Some(spec), Some(ev)) = (arg_str(a, "room"), arg_str(a, "event")) else {
362 return CmdReply::failed("room and event are required");
363 };
364 let id = match self.resolve_room(spec, now_ms) {
365 Ok(id) => id,
366 Err(e) => return CmdReply::failed(e),
367 };
368 let Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
369 match self.dispatch(MessengerCommand::Redact { room_id: id.clone(), target, reason: None }, now_ms) {
370 Ok(()) => {
371 self.settle_cmd(now_ms);
372 CmdReply::ok(json!({ "room": id.as_str() }))
373 }
374 Err(e) => CmdReply::failed(e.to_string()),
375 }
376 }
377 "rooms.power" => {
378 let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
379 return CmdReply::failed("room and user are required");
380 };
381 let level = a.get("level").and_then(Value::as_i64).unwrap_or(100);
382 let id = match self.resolve_room(spec, now_ms) {
383 Ok(id) => id,
384 Err(e) => return CmdReply::failed(e),
385 };
386 let user = if who.starts_with('@') && who.contains(':') {
387 who.to_string()
388 } else {
389 match self.find_nick(who, now_ms) {
390 Ok(found) => found.user_id,
391 Err(e) => return CmdReply::failed(format!("{who}: {e}")),
392 }
393 };
394 let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
395 match self.dispatch(MessengerCommand::SetPowerLevel { room_id: id.clone(), user_id, level }, now_ms) {
396 Ok(()) => {
397 self.settle_cmd(now_ms);
398 CmdReply::ok(json!({ "room": id.as_str(), "level": level }))
399 }
400 Err(e) => CmdReply::failed(e.to_string()),
401 }
402 }
403 "mentions" => {
404 let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
405 let me = self.nick().unwrap_or("").to_ascii_lowercase();
406 let my_id = self.driver.core.user_id().as_str().to_string();
407 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
408 let mut hits = Vec::new();
409 for id in ids {
410 for mut row in self.rows(&id, 200) {
411 let body = row["body"].as_str().unwrap_or("").to_ascii_lowercase();
412 let from_me = row["from_id"].as_str() == Some(my_id.as_str());
413 if !from_me && !me.is_empty() && body.contains(&format!("@{me}")) {
414 row["room"] = json!(id.as_str());
415 hits.push(row);
416 }
417 }
418 }
419 hits.sort_by_key(|r| r["ts"].as_i64().unwrap_or(0));
420 let skip = hits.len().saturating_sub(limit);
421 hits.drain(..skip);
422 CmdReply::ok(json!({ "mentions": hits }))
423 }
424 "threads.read" => {
425 let (Some(spec), Some(root)) = (arg_str(a, "room"), arg_str(a, "event")) else {
426 return CmdReply::failed("room and event are required");
427 };
428 match self.resolve_room(spec, now_ms) {
429 Ok(id) => {
430 let rows: Vec<Value> = self
431 .rows(&id, 500)
432 .into_iter()
433 .filter(|r| r["event_id"].as_str() == Some(root) || r["thread"].as_str() == Some(root) || r["reply_to"].as_str() == Some(root))
434 .collect();
435 CmdReply::ok(json!({ "room": id.as_str(), "messages": rows }))
436 }
437 Err(e) => CmdReply::failed(e),
438 }
439 }
440 other => CmdReply::failed(format!("unknown command {other}")),
441 }
442 }
443}