Skip to main content

room_cli/oneshot/
mod.rs

1pub mod list;
2pub mod poll;
3pub mod subscribe;
4pub mod token;
5pub mod transport;
6pub mod who;
7
8pub use list::{cmd_list, discover_daemon_rooms, discover_joined_rooms};
9pub use poll::{
10    cmd_poll, cmd_poll_multi, cmd_pull, cmd_query, cmd_watch, poll_messages, poll_messages_multi,
11    pull_messages, QueryOptions,
12};
13pub use subscribe::cmd_subscribe;
14pub use token::{cmd_join, username_from_token};
15#[allow(deprecated)]
16pub use transport::send_message;
17pub use transport::{create_room, destroy_room};
18pub use transport::{
19    ensure_daemon_running, global_join_session, join_session, join_session_target,
20    resolve_socket_target, send_message_with_token, send_message_with_token_target, SocketTarget,
21};
22pub use who::cmd_who;
23
24use room_protocol::dm_room_id;
25use transport::send_message_with_token_target as transport_send_target;
26
27/// One-shot send subcommand: connect, send, print echo JSON to stdout, exit.
28///
29/// Authenticates via `token` (from `room join`). The broker resolves the sender's
30/// username from the token — no username arg required. When `to` is `Some(recipient)`,
31/// the message is sent as a DM routed only to sender, recipient, and host.
32///
33/// Slash commands (e.g. `/who`, `/dm user msg`) are automatically converted to the
34/// appropriate JSON envelope, matching TUI behaviour.
35///
36/// `socket` overrides the default socket path (auto-discovered if `None`).
37pub async fn cmd_send(
38    room_id: &str,
39    token: &str,
40    to: Option<&str>,
41    content: &str,
42    socket: Option<&std::path::Path>,
43) -> anyhow::Result<()> {
44    let target = resolve_socket_target(room_id, socket);
45    let wire = match to {
46        Some(recipient) => {
47            serde_json::json!({"type": "dm", "to": recipient, "content": content}).to_string()
48        }
49        None => build_wire_payload(content),
50    };
51    let msg = transport_send_target(&target, token, &wire)
52        .await
53        .map_err(|e| {
54            if e.to_string().contains("invalid token") {
55                anyhow::anyhow!("invalid token — run: room join <username>")
56            } else {
57                e
58            }
59        })?;
60    println!("{}", serde_json::to_string(&msg)?);
61    Ok(())
62}
63
64/// One-shot DM subcommand: compute canonical DM room ID, send message, exit.
65///
66/// Resolves the caller's username from the token file, then computes the
67/// deterministic DM room ID (`dm-<sorted_a>-<sorted_b>`). Sends the message
68/// to that room's broker socket. The DM room must already exist (room creation
69/// will be handled by E1-6 dynamic room creation).
70///
71/// Returns an error if the caller tries to DM themselves or if the DM room
72/// broker is not running.
73///
74/// `socket` overrides the default socket path (auto-discovered if `None`).
75pub async fn cmd_dm(
76    recipient: &str,
77    token: &str,
78    content: &str,
79    socket: Option<&std::path::Path>,
80) -> anyhow::Result<()> {
81    // Resolve the caller's username from the token
82    let caller = username_from_token(token)?;
83
84    // Compute canonical DM room ID
85    let dm_id = dm_room_id(&caller, recipient).map_err(|e| anyhow::anyhow!("{e}"))?;
86
87    // Build the wire payload as a DM message
88    let wire = serde_json::json!({"type": "dm", "to": recipient, "content": content}).to_string();
89
90    // Resolve socket target for the DM room.
91    let target = resolve_socket_target(&dm_id, socket);
92    let msg = transport_send_target(&target, token, &wire)
93        .await
94        .map_err(|e| {
95            if e.to_string().contains("No such file")
96                || e.to_string().contains("Connection refused")
97            {
98                anyhow::anyhow!(
99                    "DM room '{dm_id}' is not running — start it or use a daemon with the room pre-created"
100                )
101            } else if e.to_string().contains("invalid token") {
102                anyhow::anyhow!(
103                    "invalid token for DM room '{dm_id}' — you may need to join it first"
104                )
105            } else {
106                e
107            }
108        })?;
109    println!("{}", serde_json::to_string(&msg)?);
110    Ok(())
111}
112
113/// One-shot create subcommand: connect to daemon, create a room, print result.
114///
115/// Sends a `CREATE:<room_id>` request to the daemon socket with the given
116/// visibility and invite list. The daemon creates the room immediately and
117/// returns a `room_created` envelope.
118///
119/// `socket` overrides the default daemon socket path (auto-discovered if `None`).
120/// `token` is required for authentication — the daemon validates it against the
121/// global UserRegistry.
122pub async fn cmd_create(
123    room_id: &str,
124    socket: Option<&std::path::Path>,
125    visibility: &str,
126    invite: &[String],
127    token: &str,
128) -> anyhow::Result<()> {
129    let daemon_socket = socket
130        .map(|p| p.to_owned())
131        .unwrap_or_else(crate::paths::room_socket_path);
132
133    if !daemon_socket.exists() {
134        anyhow::bail!(
135            "daemon socket not found at {} — is the daemon running?",
136            daemon_socket.display()
137        );
138    }
139
140    let config = serde_json::json!({
141        "visibility": visibility,
142        "invite": invite,
143        "token": token,
144    });
145
146    let result = transport::create_room(&daemon_socket, room_id, &config.to_string()).await?;
147    println!("{}", serde_json::to_string(&result)?);
148    Ok(())
149}
150
151/// One-shot destroy subcommand: connect to daemon, destroy a room, print result.
152///
153/// Sends a `DESTROY:<room_id>` request to the daemon socket. The daemon
154/// validates the token, signals shutdown to connected clients, removes the
155/// room from its map, and preserves the chat file on disk.
156///
157/// `socket` overrides the default daemon socket path (auto-discovered if `None`).
158/// `token` is required for authentication — the daemon validates it against the
159/// global UserRegistry.
160pub async fn cmd_destroy(
161    room_id: &str,
162    socket: Option<&std::path::Path>,
163    token: &str,
164) -> anyhow::Result<()> {
165    let daemon_socket = socket
166        .map(|p| p.to_owned())
167        .unwrap_or_else(crate::paths::room_socket_path);
168
169    if !daemon_socket.exists() {
170        anyhow::bail!(
171            "daemon socket not found at {} — is the daemon running?",
172            daemon_socket.display()
173        );
174    }
175
176    let result = transport::destroy_room(&daemon_socket, room_id, token).await?;
177    println!("{}", serde_json::to_string(&result)?);
178    Ok(())
179}
180
181/// Convert user input into a JSON wire envelope, routing slash commands to the
182/// appropriate message type. Mirrors `tui::input::build_payload` for parity.
183fn build_wire_payload(input: &str) -> String {
184    // `/dm <user> <message>`
185    if let Some(rest) = input.strip_prefix("/dm ") {
186        let mut parts = rest.splitn(2, ' ');
187        let to = parts.next().unwrap_or("");
188        let content = parts.next().unwrap_or("");
189        return serde_json::json!({"type": "dm", "to": to, "content": content}).to_string();
190    }
191    // Any other slash command: `/who`, `/kick user`, etc.
192    if let Some(rest) = input.strip_prefix('/') {
193        let mut parts = rest.splitn(2, ' ');
194        let cmd = parts.next().unwrap_or("");
195        let params: Vec<&str> = parts.next().unwrap_or("").split_whitespace().collect();
196        return serde_json::json!({"type": "command", "cmd": cmd, "params": params}).to_string();
197    }
198    // Plain message
199    serde_json::json!({"type": "message", "content": input}).to_string()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn plain_message() {
208        let wire = build_wire_payload("hello world");
209        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
210        assert_eq!(v["type"], "message");
211        assert_eq!(v["content"], "hello world");
212    }
213
214    #[test]
215    fn who_command() {
216        let wire = build_wire_payload("/who");
217        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
218        assert_eq!(v["type"], "command");
219        assert_eq!(v["cmd"], "who");
220        let params = v["params"].as_array().unwrap();
221        assert!(params.is_empty());
222    }
223
224    #[test]
225    fn command_with_params() {
226        let wire = build_wire_payload("/kick alice");
227        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
228        assert_eq!(v["type"], "command");
229        assert_eq!(v["cmd"], "kick");
230        let params: Vec<&str> = v["params"]
231            .as_array()
232            .unwrap()
233            .iter()
234            .map(|p| p.as_str().unwrap())
235            .collect();
236        assert_eq!(params, vec!["alice"]);
237    }
238
239    #[test]
240    fn command_with_multiple_params() {
241        let wire = build_wire_payload("/set_status away brb");
242        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
243        assert_eq!(v["type"], "command");
244        assert_eq!(v["cmd"], "set_status");
245        let params: Vec<&str> = v["params"]
246            .as_array()
247            .unwrap()
248            .iter()
249            .map(|p| p.as_str().unwrap())
250            .collect();
251        assert_eq!(params, vec!["away", "brb"]);
252    }
253
254    #[test]
255    fn dm_via_slash() {
256        let wire = build_wire_payload("/dm bob hey there");
257        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
258        assert_eq!(v["type"], "dm");
259        assert_eq!(v["to"], "bob");
260        assert_eq!(v["content"], "hey there");
261    }
262
263    #[test]
264    fn dm_slash_no_message() {
265        let wire = build_wire_payload("/dm bob");
266        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
267        assert_eq!(v["type"], "dm");
268        assert_eq!(v["to"], "bob");
269        assert_eq!(v["content"], "");
270    }
271
272    #[test]
273    fn slash_only() {
274        let wire = build_wire_payload("/");
275        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
276        assert_eq!(v["type"], "command");
277        assert_eq!(v["cmd"], "");
278    }
279
280    #[test]
281    fn message_starting_with_slash_like_path() {
282        // Only exact slash-prefix triggers command routing — `/tmp/foo` is a command named `tmp/foo`
283        // This matches TUI behaviour: any `/` prefix is a command
284        let wire = build_wire_payload("/tmp/foo");
285        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
286        assert_eq!(v["type"], "command");
287        assert_eq!(v["cmd"], "tmp/foo");
288    }
289
290    #[test]
291    fn empty_string() {
292        let wire = build_wire_payload("");
293        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
294        assert_eq!(v["type"], "message");
295        assert_eq!(v["content"], "");
296    }
297}