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 _ => return None,
118 };
119 Some(json!({
120 "event_id": item.event_id.as_ref().map(|e| e.as_str()),
121 "from": mxid_localpart(item.sender.as_str()),
122 "from_id": item.sender.as_str(),
123 "ts": item.origin_server_ts,
124 "kind": kind,
125 "body": body,
126 "reply_to": item.relations.reply_to.as_ref().map(|e| e.as_str()),
127 "thread": item.relations.thread_root.as_ref().map(|e| e.as_str()),
128 "reactions": item.relations.reactions.iter().map(|(k, who)| json!({ "key": k, "count": who.len(), "by": who.iter().map(|u| mxid_localpart(u.as_str())).collect::<Vec<_>>() })).collect::<Vec<_>>(),
129 "state": crate::outcome_name(&item.send_state),
130 }))
131 })
132 .collect();
133 let skip = out.len().saturating_sub(limit);
134 out.drain(..skip);
135 out
136 }
137
138 fn send_into(&mut self, room: &RoomId, text: &str, reply_to: Option<&str>, now_ms: i64) -> CmdReply {
139 let reply_to = match reply_to {
140 Some(id) => match crate::EventId::parse(id) {
141 Ok(id) => Some(id),
142 Err(e) => return CmdReply::failed(format!("event id: {e}")),
143 },
144 None => None,
145 };
146 let me = self.driver.core.user_id().as_str().to_string();
149 if !self.member_joined(room.as_str(), &me) {
150 return CmdReply::failed("this session has not joined that room (join it first)");
151 }
152 let before = self.texts().len();
153 if let Err(e) = self.dispatch(
154 MessengerCommand::SendMessage {
155 room_id: room.clone(),
156 message: OutgoingMessage { kind: MessageKind::Text, body: text.to_string(), reply_to, edit_of: None },
157 txn_id: None,
158 },
159 now_ms,
160 ) {
161 return CmdReply::failed(format!("send: {e}"));
162 }
163 let mut t = now_ms;
164 for _ in 0..30 {
165 t += 1_000;
166 let _ = self.drive(t, false);
167 std::thread::sleep(std::time::Duration::from_millis(200));
168 if let Some(row) = self.texts().into_iter().rev().find(|r| r.room_id == room.as_str() && r.body == text) {
169 if row.outcome == "sent" {
170 return CmdReply::ok(json!({ "room": room.as_str(), "event_id": row.event_id }));
171 }
172 if row.outcome.starts_with("failed") {
173 return CmdReply::failed(format!("send {}", row.outcome));
174 }
175 }
176 }
177 let _ = before;
178 CmdReply::failed("send not confirmed yet (queued)")
179 }
180
181 pub fn run_command(&mut self, req: &CmdRequest, now_ms: i64) -> CmdReply {
183 let a = &req.args;
184 match req.cmd.as_str() {
185 "rooms.list" => {
186 let rooms: Vec<Value> = self
187 .rooms()
188 .into_iter()
189 .filter(|r| r.membership != "absent" && r.membership != "leave")
190 .map(|r| {
191 let id = RoomId::parse(&r.room_id).ok();
192 let title = id.as_ref().map(|i| self.room_title(i)).unwrap_or_default();
193 let last = id.as_ref().and_then(|i| self.rows(i, 1).into_iter().next());
194 json!({ "room": r.room_id, "title": title, "membership": r.membership,
195 "encrypted": r.encrypted, "last": last })
196 })
197 .collect();
198 CmdReply::ok(json!({ "rooms": rooms }))
199 }
200 "rooms.read" => {
201 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
202 let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
203 match self.resolve_room(spec, now_ms) {
204 Ok(id) => CmdReply::ok(json!({ "room": id.as_str(), "title": self.room_title(&id), "messages": self.rows(&id, limit) })),
205 Err(e) => CmdReply::failed(e),
206 }
207 }
208 "rooms.send" | "rooms.reply" => {
209 let (Some(spec), Some(text)) = (arg_str(a, "room"), arg_str(a, "text")) else {
210 return CmdReply::failed("room and text are required");
211 };
212 let reply_to = if req.cmd == "rooms.reply" {
213 match arg_str(a, "event") {
214 Some(e) => Some(e),
215 None => return CmdReply::failed("event is required for a reply"),
216 }
217 } else {
218 None
219 };
220 match self.resolve_room(spec, now_ms) {
221 Ok(id) => self.send_into(&id, text, reply_to, now_ms),
222 Err(e) => CmdReply::failed(e),
223 }
224 }
225 "rooms.join" | "rooms.leave" => {
226 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
227 let id = match self.resolve_room(spec, now_ms) {
228 Ok(id) => id,
229 Err(e) => return CmdReply::failed(e),
230 };
231 let command = if req.cmd == "rooms.join" {
232 MessengerCommand::JoinRoom { room_id: id.clone() }
233 } else {
234 MessengerCommand::LeaveRoom { room_id: id.clone() }
235 };
236 match self.dispatch(command, now_ms) {
237 Ok(()) => {
238 self.settle_cmd(now_ms);
239 CmdReply::ok(json!({ "room": id.as_str() }))
240 }
241 Err(e) => CmdReply::failed(e.to_string()),
242 }
243 }
244 "rooms.create" => {
245 let Some(name) = arg_str(a, "name") else { return CmdReply::failed("name is required") };
246 let invite: Vec<UserId> = a
247 .get("invite")
248 .and_then(Value::as_array)
249 .map(|v| v.iter().filter_map(Value::as_str).filter_map(|u| UserId::parse(u).ok()).collect())
250 .unwrap_or_default();
251 let kind = match arg_str(a, "kind").unwrap_or("group") {
252 "channel" => CreateRoomKind::Channel { name: name.to_string(), topic: arg_str(a, "topic").map(str::to_string) },
253 "group" => CreateRoomKind::Group { name: name.to_string(), invite, members_can_invite: true },
254 other => return CmdReply::failed(format!("kind must be channel or group, not {other}")),
255 };
256 match self.dispatch(MessengerCommand::CreateRoom { kind }, now_ms) {
257 Ok(()) => {
258 let mut found = None;
259 for i in 1..=25 {
260 let _ = self.drive(now_ms + i * 1_000, false);
261 std::thread::sleep(std::time::Duration::from_millis(200));
262 found = self.rooms().into_iter().find(|r| {
263 r.membership == "join"
264 && RoomId::parse(&r.room_id).map(|i| self.room_title(&i) == name).unwrap_or(false)
265 });
266 if found.is_some() {
267 break;
268 }
269 }
270 CmdReply::ok(json!({ "room": found.map(|r| r.room_id) }))
271 }
272 Err(e) => CmdReply::failed(e.to_string()),
273 }
274 }
275 "rooms.invite" => {
276 let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
277 return CmdReply::failed("room and user are required");
278 };
279 let id = match self.resolve_room(spec, now_ms) {
280 Ok(id) => id,
281 Err(e) => return CmdReply::failed(e),
282 };
283 let user = if who.starts_with('@') && who.contains(':') {
284 who.to_string()
285 } else {
286 match self.find_nick(who, now_ms) {
287 Ok(found) => found.user_id,
288 Err(e) => return CmdReply::failed(format!("{who}: {e}")),
289 }
290 };
291 let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
292 match self.dispatch(MessengerCommand::Invite { room_id: id.clone(), user_id }, now_ms) {
293 Ok(()) => {
294 self.settle_cmd(now_ms);
295 CmdReply::ok(json!({ "room": id.as_str() }))
296 }
297 Err(e) => CmdReply::failed(e.to_string()),
298 }
299 }
300 "rooms.react" => {
301 let (Some(spec), Some(ev), Some(key)) = (arg_str(a, "room"), arg_str(a, "event"), arg_str(a, "key")) else {
302 return CmdReply::failed("room, event and key are required");
303 };
304 if key.is_empty() || key.len() > 64 {
305 return CmdReply::failed("bad key");
306 }
307 let id = match self.resolve_room(spec, now_ms) {
308 Ok(id) => id,
309 Err(e) => return CmdReply::failed(e),
310 };
311 let Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
312 match self.dispatch(MessengerCommand::React { room_id: id.clone(), target, key: key.to_string() }, now_ms) {
313 Ok(()) => {
314 self.settle_cmd(now_ms);
315 CmdReply::ok(json!({ "room": id.as_str() }))
316 }
317 Err(e) => CmdReply::failed(e.to_string()),
318 }
319 }
320 "rooms.members" => {
321 let Some(spec) = arg_str(a, "room") else { return CmdReply::failed("room is required") };
322 let id = match self.resolve_room(spec, now_ms) {
323 Ok(id) => id,
324 Err(e) => return CmdReply::failed(e),
325 };
326 let Some(state) = self.driver.core.room_state(&id) else { return CmdReply::failed("unknown room") };
327 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();
328 CmdReply::ok(json!({ "room": id.as_str(), "members": members }))
329 }
330 "rooms.redact" => {
331 let (Some(spec), Some(ev)) = (arg_str(a, "room"), arg_str(a, "event")) else {
332 return CmdReply::failed("room and event 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 Ok(target) = crate::EventId::parse(ev) else { return CmdReply::failed("bad event id") };
339 match self.dispatch(MessengerCommand::Redact { room_id: id.clone(), target, reason: None }, now_ms) {
340 Ok(()) => {
341 self.settle_cmd(now_ms);
342 CmdReply::ok(json!({ "room": id.as_str() }))
343 }
344 Err(e) => CmdReply::failed(e.to_string()),
345 }
346 }
347 "rooms.power" => {
348 let (Some(spec), Some(who)) = (arg_str(a, "room"), arg_str(a, "user")) else {
349 return CmdReply::failed("room and user are required");
350 };
351 let level = a.get("level").and_then(Value::as_i64).unwrap_or(100);
352 let id = match self.resolve_room(spec, now_ms) {
353 Ok(id) => id,
354 Err(e) => return CmdReply::failed(e),
355 };
356 let user = if who.starts_with('@') && who.contains(':') {
357 who.to_string()
358 } else {
359 match self.find_nick(who, now_ms) {
360 Ok(found) => found.user_id,
361 Err(e) => return CmdReply::failed(format!("{who}: {e}")),
362 }
363 };
364 let Ok(user_id) = UserId::parse(&user) else { return CmdReply::failed("bad user id") };
365 match self.dispatch(MessengerCommand::SetPowerLevel { room_id: id.clone(), user_id, level }, now_ms) {
366 Ok(()) => {
367 self.settle_cmd(now_ms);
368 CmdReply::ok(json!({ "room": id.as_str(), "level": level }))
369 }
370 Err(e) => CmdReply::failed(e.to_string()),
371 }
372 }
373 "mentions" => {
374 let limit = a.get("limit").and_then(Value::as_u64).unwrap_or(20).min(200) as usize;
375 let me = self.nick().unwrap_or("").to_ascii_lowercase();
376 let my_id = self.driver.core.user_id().as_str().to_string();
377 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
378 let mut hits = Vec::new();
379 for id in ids {
380 for mut row in self.rows(&id, 200) {
381 let body = row["body"].as_str().unwrap_or("").to_ascii_lowercase();
382 let from_me = row["from_id"].as_str() == Some(my_id.as_str());
383 if !from_me && !me.is_empty() && body.contains(&format!("@{me}")) {
384 row["room"] = json!(id.as_str());
385 hits.push(row);
386 }
387 }
388 }
389 hits.sort_by_key(|r| r["ts"].as_i64().unwrap_or(0));
390 let skip = hits.len().saturating_sub(limit);
391 hits.drain(..skip);
392 CmdReply::ok(json!({ "mentions": hits }))
393 }
394 "threads.read" => {
395 let (Some(spec), Some(root)) = (arg_str(a, "room"), arg_str(a, "event")) else {
396 return CmdReply::failed("room and event are required");
397 };
398 match self.resolve_room(spec, now_ms) {
399 Ok(id) => {
400 let rows: Vec<Value> = self
401 .rows(&id, 500)
402 .into_iter()
403 .filter(|r| r["event_id"].as_str() == Some(root) || r["thread"].as_str() == Some(root) || r["reply_to"].as_str() == Some(root))
404 .collect();
405 CmdReply::ok(json!({ "room": id.as_str(), "messages": rows }))
406 }
407 Err(e) => CmdReply::failed(e),
408 }
409 }
410 other => CmdReply::failed(format!("unknown command {other}")),
411 }
412 }
413}