Skip to main content

room_cli/oneshot/
mod.rs

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