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/// Interpret common escape sequences in CLI message content.
30///
31/// When users pass `\n`, `\t`, or `\\` in shell arguments, these arrive as literal
32/// two-character sequences (backslash + letter). This function converts them to the
33/// actual characters so messages render correctly in the TUI.
34fn unescape_content(input: &str) -> String {
35    let mut out = String::with_capacity(input.len());
36    let mut chars = input.chars();
37    while let Some(c) = chars.next() {
38        if c == '\\' {
39            match chars.next() {
40                Some('n') => out.push('\n'),
41                Some('t') => out.push('\t'),
42                Some('\\') => out.push('\\'),
43                Some('r') => out.push('\r'),
44                Some(other) => {
45                    out.push('\\');
46                    out.push(other);
47                }
48                None => out.push('\\'),
49            }
50        } else {
51            out.push(c);
52        }
53    }
54    out
55}
56
57/// One-shot send subcommand: connect, send, print echo JSON to stdout, exit.
58///
59/// Authenticates via `token` (from `room join`). The broker resolves the sender's
60/// username from the token — no username arg required. When `to` is `Some(recipient)`,
61/// the message is sent as a DM routed only to sender, recipient, and host.
62///
63/// Slash commands (e.g. `/who`, `/dm user msg`) are automatically converted to the
64/// appropriate JSON envelope, matching TUI behaviour.
65///
66/// `socket` overrides the default socket path (auto-discovered if `None`).
67pub async fn cmd_send(
68    room_id: &str,
69    token: &str,
70    to: Option<&str>,
71    content: &str,
72    socket: Option<&std::path::Path>,
73) -> anyhow::Result<()> {
74    let target = resolve_socket_target(room_id, socket);
75    let unescaped = unescape_content(content);
76    let wire = match to {
77        Some(recipient) => {
78            serde_json::json!({"type": "dm", "to": recipient, "content": unescaped}).to_string()
79        }
80        None => build_wire_payload(&unescaped),
81    };
82    let msg = transport_send_target(&target, token, &wire)
83        .await
84        .map_err(|e| {
85            if e.to_string().contains("invalid token") {
86                anyhow::anyhow!("invalid token — run: room join <username>")
87            } else {
88                e
89            }
90        })?;
91    println!("{}", serde_json::to_string(&msg)?);
92    Ok(())
93}
94
95/// One-shot DM subcommand: compute canonical DM room ID, send message, exit.
96///
97/// Resolves the caller's username from the token file, then computes the
98/// deterministic DM room ID (`dm-<sorted_a>-<sorted_b>`). Sends the message
99/// to that room's broker socket. The DM room must already exist (room creation
100/// will be handled by E1-6 dynamic room creation).
101///
102/// Returns an error if the caller tries to DM themselves or if the DM room
103/// broker is not running.
104///
105/// `socket` overrides the default socket path (auto-discovered if `None`).
106pub async fn cmd_dm(
107    recipient: &str,
108    token: &str,
109    content: &str,
110    socket: Option<&std::path::Path>,
111) -> anyhow::Result<()> {
112    // Resolve the caller's username from the token
113    let caller = username_from_token(token)?;
114
115    // Compute canonical DM room ID
116    let dm_id = dm_room_id(&caller, recipient).map_err(|e| anyhow::anyhow!("{e}"))?;
117
118    // Build the wire payload as a DM message
119    let unescaped = unescape_content(content);
120    let wire = serde_json::json!({"type": "dm", "to": recipient, "content": unescaped}).to_string();
121
122    // Resolve socket target for the DM room.
123    let target = resolve_socket_target(&dm_id, socket);
124    let msg = transport_send_target(&target, token, &wire)
125        .await
126        .map_err(|e| {
127            if e.to_string().contains("No such file")
128                || e.to_string().contains("Connection refused")
129            {
130                anyhow::anyhow!(
131                    "DM room '{dm_id}' is not running — start it or use a daemon with the room pre-created"
132                )
133            } else if e.to_string().contains("invalid token") {
134                anyhow::anyhow!(
135                    "invalid token for DM room '{dm_id}' — you may need to join it first"
136                )
137            } else {
138                e
139            }
140        })?;
141    println!("{}", serde_json::to_string(&msg)?);
142    Ok(())
143}
144
145/// One-shot create subcommand: connect to daemon, create a room, print result.
146///
147/// Sends a `CREATE:<room_id>` request to the daemon socket with the given
148/// visibility and invite list. The daemon creates the room immediately and
149/// returns a `room_created` envelope.
150///
151/// `socket` overrides the default daemon socket path (auto-discovered if `None`).
152/// `token` is required for authentication — the daemon validates it against the
153/// global UserRegistry.
154pub async fn cmd_create(
155    room_id: &str,
156    socket: Option<&std::path::Path>,
157    visibility: &str,
158    invite: &[String],
159    token: &str,
160) -> anyhow::Result<()> {
161    let daemon_socket = socket
162        .map(|p| p.to_owned())
163        .unwrap_or_else(crate::paths::room_socket_path);
164
165    if !daemon_socket.exists() {
166        anyhow::bail!(
167            "daemon socket not found at {} — is the daemon running?",
168            daemon_socket.display()
169        );
170    }
171
172    let config = serde_json::json!({
173        "visibility": visibility,
174        "invite": invite,
175        "token": token,
176    });
177
178    let result = transport::create_room(&daemon_socket, room_id, &config.to_string()).await?;
179    println!("{}", serde_json::to_string(&result)?);
180    Ok(())
181}
182
183/// One-shot destroy subcommand: connect to daemon, destroy a room, print result.
184///
185/// Sends a `DESTROY:<room_id>` request to the daemon socket. The daemon
186/// validates the token, signals shutdown to connected clients, removes the
187/// room from its map, and preserves the chat file on disk.
188///
189/// `socket` overrides the default daemon socket path (auto-discovered if `None`).
190/// `token` is required for authentication — the daemon validates it against the
191/// global UserRegistry.
192pub async fn cmd_destroy(
193    room_id: &str,
194    socket: Option<&std::path::Path>,
195    token: &str,
196) -> anyhow::Result<()> {
197    let daemon_socket = socket
198        .map(|p| p.to_owned())
199        .unwrap_or_else(crate::paths::room_socket_path);
200
201    if !daemon_socket.exists() {
202        anyhow::bail!(
203            "daemon socket not found at {} — is the daemon running?",
204            daemon_socket.display()
205        );
206    }
207
208    let result = transport::destroy_room(&daemon_socket, room_id, token).await?;
209    println!("{}", serde_json::to_string(&result)?);
210    Ok(())
211}
212
213/// Convert user input into a JSON wire envelope, routing slash commands to the
214/// appropriate message type. Mirrors `tui::input::build_payload` for parity.
215fn build_wire_payload(input: &str) -> String {
216    // `/dm <user> <message>`
217    if let Some(rest) = input.strip_prefix("/dm ") {
218        let mut parts = rest.splitn(2, ' ');
219        let to = parts.next().unwrap_or("");
220        let content = parts.next().unwrap_or("");
221        return serde_json::json!({"type": "dm", "to": to, "content": content}).to_string();
222    }
223    // Any other slash command: `/who`, `/kick user`, etc.
224    if let Some(rest) = input.strip_prefix('/') {
225        let mut parts = rest.splitn(2, ' ');
226        let cmd = parts.next().unwrap_or("");
227        let params: Vec<&str> = parts.next().unwrap_or("").split_whitespace().collect();
228        return serde_json::json!({"type": "command", "cmd": cmd, "params": params}).to_string();
229    }
230    // Plain message
231    serde_json::json!({"type": "message", "content": input}).to_string()
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn plain_message() {
240        let wire = build_wire_payload("hello world");
241        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
242        assert_eq!(v["type"], "message");
243        assert_eq!(v["content"], "hello world");
244    }
245
246    #[test]
247    fn who_command() {
248        let wire = build_wire_payload("/who");
249        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
250        assert_eq!(v["type"], "command");
251        assert_eq!(v["cmd"], "who");
252        let params = v["params"].as_array().unwrap();
253        assert!(params.is_empty());
254    }
255
256    #[test]
257    fn command_with_params() {
258        let wire = build_wire_payload("/kick alice");
259        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
260        assert_eq!(v["type"], "command");
261        assert_eq!(v["cmd"], "kick");
262        let params: Vec<&str> = v["params"]
263            .as_array()
264            .unwrap()
265            .iter()
266            .map(|p| p.as_str().unwrap())
267            .collect();
268        assert_eq!(params, vec!["alice"]);
269    }
270
271    #[test]
272    fn command_with_multiple_params() {
273        let wire = build_wire_payload("/set_status away brb");
274        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
275        assert_eq!(v["type"], "command");
276        assert_eq!(v["cmd"], "set_status");
277        let params: Vec<&str> = v["params"]
278            .as_array()
279            .unwrap()
280            .iter()
281            .map(|p| p.as_str().unwrap())
282            .collect();
283        assert_eq!(params, vec!["away", "brb"]);
284    }
285
286    #[test]
287    fn dm_via_slash() {
288        let wire = build_wire_payload("/dm bob hey there");
289        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
290        assert_eq!(v["type"], "dm");
291        assert_eq!(v["to"], "bob");
292        assert_eq!(v["content"], "hey there");
293    }
294
295    #[test]
296    fn dm_slash_no_message() {
297        let wire = build_wire_payload("/dm bob");
298        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
299        assert_eq!(v["type"], "dm");
300        assert_eq!(v["to"], "bob");
301        assert_eq!(v["content"], "");
302    }
303
304    #[test]
305    fn slash_only() {
306        let wire = build_wire_payload("/");
307        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
308        assert_eq!(v["type"], "command");
309        assert_eq!(v["cmd"], "");
310    }
311
312    #[test]
313    fn message_starting_with_slash_like_path() {
314        // Only exact slash-prefix triggers command routing — `/tmp/foo` is a command named `tmp/foo`
315        // This matches TUI behaviour: any `/` prefix is a command
316        let wire = build_wire_payload("/tmp/foo");
317        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
318        assert_eq!(v["type"], "command");
319        assert_eq!(v["cmd"], "tmp/foo");
320    }
321
322    #[test]
323    fn empty_string() {
324        let wire = build_wire_payload("");
325        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
326        assert_eq!(v["type"], "message");
327        assert_eq!(v["content"], "");
328    }
329
330    // ── unescape_content ─────────────────────────────────────────────────────
331
332    #[test]
333    fn unescape_newline() {
334        assert_eq!(unescape_content(r"hello\nworld"), "hello\nworld");
335    }
336
337    #[test]
338    fn unescape_tab() {
339        assert_eq!(unescape_content(r"col1\tcol2"), "col1\tcol2");
340    }
341
342    #[test]
343    fn unescape_carriage_return() {
344        assert_eq!(unescape_content(r"line\r"), "line\r");
345    }
346
347    #[test]
348    fn unescape_backslash() {
349        assert_eq!(unescape_content(r"path\\to\\file"), r"path\to\file");
350    }
351
352    #[test]
353    fn unescape_multiple_sequences() {
354        assert_eq!(
355            unescape_content(r"line1\nline2\nline3"),
356            "line1\nline2\nline3"
357        );
358    }
359
360    #[test]
361    fn unescape_mixed_sequences() {
362        assert_eq!(unescape_content(r"a\tb\nc\\d"), "a\tb\nc\\d");
363    }
364
365    #[test]
366    fn unescape_unknown_sequence_preserved() {
367        assert_eq!(unescape_content(r"hello\xworld"), r"hello\xworld");
368    }
369
370    #[test]
371    fn unescape_trailing_backslash() {
372        assert_eq!(unescape_content(r"trailing\"), "trailing\\");
373    }
374
375    #[test]
376    fn unescape_no_sequences() {
377        assert_eq!(unescape_content("plain text"), "plain text");
378    }
379
380    #[test]
381    fn unescape_empty() {
382        assert_eq!(unescape_content(""), "");
383    }
384
385    #[test]
386    fn unescape_only_backslash_n() {
387        assert_eq!(unescape_content(r"\n"), "\n");
388    }
389
390    /// Regression test for #742: escape sequences in wire payload via cmd_send path.
391    #[test]
392    fn wire_payload_contains_real_newline_after_unescape() {
393        let unescaped = unescape_content(r"line1\nline2");
394        let wire = build_wire_payload(&unescaped);
395        let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
396        assert_eq!(v["content"].as_str().unwrap(), "line1\nline2");
397    }
398}