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
29fn 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
57pub 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
95pub async fn cmd_dm(
107 recipient: &str,
108 token: &str,
109 content: &str,
110 socket: Option<&std::path::Path>,
111) -> anyhow::Result<()> {
112 let caller = username_from_token(token)?;
114
115 let dm_id = dm_room_id(&caller, recipient).map_err(|e| anyhow::anyhow!("{e}"))?;
117
118 let unescaped = unescape_content(content);
120 let wire = serde_json::json!({"type": "dm", "to": recipient, "content": unescaped}).to_string();
121
122 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
145pub 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
183pub 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
213fn build_wire_payload(input: &str) -> String {
216 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 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 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 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 #[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 #[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}