theater_cli/commands/channel/
open.rs

1use anyhow::Result;
2use clap::Parser;
3use console::style;
4use rustyline::error::ReadlineError;
5use rustyline::DefaultEditor;
6use std::fs;
7use std::net::SocketAddr;
8use std::path::PathBuf;
9use tokio::sync::mpsc;
10use tracing::{debug, error};
11
12use crate::{error::CliError, output::formatters::ChannelOpened, CommandContext};
13use theater::id::TheaterId;
14
15#[derive(Debug, Parser)]
16pub struct OpenArgs {
17    /// ID of the actor to open a channel with
18    #[arg(required = true)]
19    pub actor_id: String,
20
21    /// Initial message to send when opening the channel
22    #[arg(short, long)]
23    pub message: Option<String>,
24
25    /// File containing initial message to send
26    #[arg(short, long, conflicts_with = "message")]
27    pub file: Option<PathBuf>,
28
29    /// Address of the theater server
30    #[arg(short, long)]
31    pub address: Option<SocketAddr>,
32}
33
34/// Execute the channel open command asynchronously (modernized)
35pub async fn execute_async(args: &OpenArgs, ctx: &CommandContext) -> Result<(), CliError> {
36    debug!("Opening channel to actor: {}", args.actor_id);
37
38    // Get initial message content either from direct argument or file
39    let initial_message = if let Some(message) = &args.message {
40        message.clone().into_bytes()
41    } else if let Some(file_path) = &args.file {
42        debug!("Reading initial message from file: {:?}", file_path);
43        fs::read(file_path).map_err(|e| {
44            CliError::file_operation_failed("read message file", file_path.display().to_string(), e)
45        })?
46    } else {
47        // Default initial message
48        serde_json::to_vec(&serde_json::json!({
49            "message_type": "channel_init",
50            "payload": {
51                "timestamp": chrono::Utc::now().timestamp_millis(),
52            }
53        }))
54        .map_err(|e| {
55            CliError::invalid_input(
56                "initial_message",
57                "json",
58                format!("Failed to create default initial message: {}", e),
59            )
60        })?
61    };
62
63    debug!("Initial message size: {} bytes", initial_message.len());
64
65    // Parse the actor ID
66    let actor_id = TheaterId::parse(&args.actor_id)
67        .map_err(|_e| CliError::invalid_actor_id(&args.actor_id))?;
68
69    // Get server address from args or config
70    let address = ctx.server_address(args.address);
71    debug!("Connecting to server at: {}", address);
72
73    // Run the interactive channel session
74    run_channel_session(address, actor_id, initial_message, ctx).await
75}
76
77async fn run_channel_session(
78    server_addr: SocketAddr,
79    actor_id: TheaterId,
80    initial_message: Vec<u8>,
81    ctx: &CommandContext,
82) -> Result<(), CliError> {
83    // Create client and connect
84    let client = ctx.create_client();
85    client
86        .connect()
87        .await
88        .map_err(|e| CliError::connection_failed(server_addr, e))?;
89
90    // Capture initial message size before move
91    let initial_message_size = initial_message.len();
92
93    // Open a channel to the actor
94    let channel_id = client
95        .open_channel(&actor_id.to_string(), initial_message)
96        .await
97        .map_err(|e| {
98            CliError::actor_not_found(format!(
99                "Failed to open channel to actor {}: {}",
100                actor_id, e
101            ))
102        })?;
103    // Create channel info for output
104    let channel_info = ChannelOpened {
105        actor_id: actor_id.clone(),
106        channel_id: channel_id.clone(),
107        address: server_addr.to_string(),
108        initial_message_size,
109        is_interactive: !ctx.json,
110    };
111
112    // Display channel open info
113    ctx.output.output(&channel_info, None)?;
114
115    // If JSON mode, we don't run interactive mode
116    if ctx.json {
117        return Ok(());
118    }
119
120    // Set up the REPL for interactive mode
121    let mut rl = DefaultEditor::new().map_err(|e| {
122        CliError::invalid_input(
123            "readline",
124            "setup",
125            format!("Failed to setup readline: {}", e),
126        )
127    })?;
128
129    println!(
130        "{} Enter commands ('help' for available commands, 'exit' to quit)",
131        style("i").blue().bold()
132    );
133
134    // Create a task to listen for input
135    let (input_tx, mut input_rx) = mpsc::channel::<String>(32);
136    let input_task = tokio::spawn(async move {
137        loop {
138            let readline = rl.readline("channel> ");
139            match readline {
140                Ok(line) => {
141                    let _ = rl.add_history_entry(line.as_str());
142                    if let Err(_) = input_tx.send(line).await {
143                        break;
144                    }
145                }
146                Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
147                    println!("\rClosing channel and exiting...");
148                    break;
149                }
150                Err(err) => {
151                    println!("\rError: {}", err);
152                    break;
153                }
154            }
155        }
156    });
157
158    // Main event loop using select!
159    let mut running = true;
160
161    while running {
162        tokio::select! {
163            // Handle user input
164            Some(line) = input_rx.recv() => {
165                let trimmed = line.trim();
166                if trimmed.is_empty() {
167                    continue;
168                }
169
170                // Process commands
171                let parts: Vec<&str> = trimmed.split_whitespace().collect();
172                let cmd = parts[0].to_lowercase();
173
174                match cmd.as_str() {
175                    "send" => {
176                        // Handle send command with various formats
177                        if parts.len() < 2 {
178                            println!("Error: send requires a message or --file option");
179                            continue;
180                        }
181
182                        let message = if parts[1] == "--file" || parts[1] == "-f" {
183                            println!("{} Reading message from file...", style(">").green().bold());
184                            if parts.len() < 3 {
185                                println!("Error: --file option requires a file path");
186                                continue;
187                            }
188
189                            let file_path = parts[2];
190                            match fs::read(file_path) {
191                                Ok(content) => {
192                                    println!("{} Read {} bytes from file",
193                                        style("✓").green().bold(), content.len());
194                                    content
195                                }
196                                Err(e) => {
197                                    println!("Error reading file: {}", e);
198                                    continue;
199                                }
200                            }
201                        } else {
202                            // Send the rest of the line as the message
203                            let message_text = trimmed[5..].trim(); // Skip "send "
204
205                            // Check if it's a quoted string and remove the quotes if needed
206                            let text = if message_text.starts_with('"')
207                                && message_text.ends_with('"')
208                                && message_text.len() >= 2
209                            {
210                                &message_text[1..message_text.len() - 1]
211                            } else {
212                                message_text
213                            };
214
215                            text.as_bytes().to_vec()
216                        };
217
218                        debug!("Sending message on channel: {} bytes", message.len());
219
220                        // Send the message
221                        match client.send_on_channel(&channel_id, message).await {
222                            Ok(_) => {
223                                if ctx.verbose {
224                                    println!("{} Message sent", style("✓").green().bold());
225                                }
226                            }
227                            Err(e) => {
228                                println!("{} Error sending message: {}",
229                                    style("✗").red().bold(), e);
230                            }
231                        }
232                    }
233                    "exit" | "quit" => {
234                        running = false;
235                        println!("Closing channel and exiting...");
236                    }
237                    "help" => {
238                        println!("Available commands:");
239                        println!("  send \"message\"    - Send a text message");
240                        println!("  send --file path  - Send contents of a file");
241                        println!("  exit | quit       - Close channel and exit");
242                        println!("  help              - Show this help");
243                    }
244                    _ => {
245                        println!("Unknown command: {}. Type 'help' for available commands.", cmd);
246                    }
247                }
248            },
249            // Handle incoming messages
250            result = client.receive_channel_message() => {
251                match result {
252                    Ok(response) => {
253                        if let Some((id, message)) = response {
254                            // Only process messages for our channel
255                            if id == channel_id {
256                                // Try to pretty print if it looks like JSON
257                                match std::str::from_utf8(&message) {
258                                    Ok(text) => {
259                                        if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
260                                            println!("\r{}", serde_json::to_string_pretty(&json)
261                                                .unwrap_or_else(|_| text.to_string()));
262                                        } else {
263                                            println!("\r{}", text);
264                                        }
265                                    },
266                                    Err(_) => {
267                                        println!("\r[Binary message of {} bytes]", message.len());
268                                        if ctx.verbose {
269                                            println!("\r{:?}", message);
270                                        }
271                                    }
272                                }
273                                // Re-display the prompt
274                                print!("channel> ");
275                                let _ = std::io::Write::flush(&mut std::io::stdout());
276                            }
277                        }
278                    }
279                    Err(e) => {
280                        error!("Error receiving channel message: {}", e);
281                        // Short backoff before retrying
282                        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
283                    }
284                }
285            }
286        }
287    }
288
289    // Clean up
290    input_task.abort();
291
292    // Close the channel
293    client
294        .close_channel(&channel_id)
295        .await
296        .map_err(|e| CliError::actor_not_found(format!("Failed to close channel: {}", e)))?;
297
298    println!("{} Channel closed", style("✓").green().bold());
299
300    Ok(())
301}
302
303/// Legacy wrapper for backward compatibility
304pub fn execute(args: &OpenArgs, verbose: bool, json: bool) -> Result<()> {
305    let runtime = tokio::runtime::Runtime::new()?;
306    runtime.block_on(async {
307        let config = crate::config::Config::load().unwrap_or_default();
308        let output = crate::output::OutputManager::new(config.output.clone());
309        let ctx = crate::CommandContext {
310            config,
311            output,
312            verbose,
313            json,
314        };
315        execute_async(args, &ctx)
316            .await
317            .map_err(|e| anyhow::Error::from(e))
318    })
319}