Skip to main content

slack_rs/cli/
handlers.rs

1//! CLI command handlers
2//!
3//! This module contains handler functions for CLI commands that were extracted from main.rs
4//! to improve code organization and maintainability.
5
6use crate::api::{execute_api_call, ApiCallArgs, ApiCallContext, ApiCallResponse, ApiClient};
7use crate::auth;
8use crate::debug;
9use crate::oauth;
10use crate::profile::{
11    create_token_store, default_config_path, make_token_key, resolve_profile_full, TokenType,
12};
13
14/// Run the auth login command with argument parsing
15pub async fn run_auth_login(args: &[String], non_interactive: bool) -> Result<(), String> {
16    let mut profile_name: Option<String> = None;
17    let mut client_id: Option<String> = None;
18    let mut cloudflared_path: Option<String> = None;
19    let mut ngrok_path: Option<String> = None;
20    let mut bot_scopes: Option<Vec<String>> = None;
21    let mut user_scopes: Option<Vec<String>> = None;
22
23    let mut i = 0;
24    while i < args.len() {
25        if args[i].starts_with("--") {
26            match args[i].as_str() {
27                "--client-id" => {
28                    i += 1;
29                    if i < args.len() {
30                        client_id = Some(args[i].clone());
31                    } else {
32                        return Err("--client-id requires a value".to_string());
33                    }
34                }
35                "--cloudflared" => {
36                    // Check if next arg is a value (not starting with --) or end of args
37                    if i + 1 < args.len() && !args[i + 1].starts_with("--") {
38                        i += 1;
39                        cloudflared_path = Some(args[i].clone());
40                    } else {
41                        // Use default "cloudflared" (PATH resolution)
42                        cloudflared_path = Some("cloudflared".to_string());
43                    }
44                }
45                "--ngrok" => {
46                    // Check if next arg is a value (not starting with --) or end of args
47                    if i + 1 < args.len() && !args[i + 1].starts_with("--") {
48                        i += 1;
49                        ngrok_path = Some(args[i].clone());
50                    } else {
51                        // Use default "ngrok" (PATH resolution)
52                        ngrok_path = Some("ngrok".to_string());
53                    }
54                }
55                "--bot-scopes" => {
56                    i += 1;
57                    if i < args.len() {
58                        let scopes_input: Vec<String> =
59                            args[i].split(',').map(|s| s.trim().to_string()).collect();
60                        // Expand 'all' presets with bot context (true)
61                        bot_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, true));
62                    } else {
63                        return Err("--bot-scopes requires a value".to_string());
64                    }
65                }
66                "--user-scopes" => {
67                    i += 1;
68                    if i < args.len() {
69                        let scopes_input: Vec<String> =
70                            args[i].split(',').map(|s| s.trim().to_string()).collect();
71                        // Expand 'all' presets with user context (false)
72                        user_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, false));
73                    } else {
74                        return Err("--user-scopes requires a value".to_string());
75                    }
76                }
77                _ => {
78                    return Err(format!("Unknown option: {}", args[i]));
79                }
80            }
81        } else if profile_name.is_none() {
82            profile_name = Some(args[i].clone());
83        } else {
84            return Err(format!("Unexpected argument: {}", args[i]));
85        }
86        i += 1;
87    }
88
89    // Check for conflicting options
90    if cloudflared_path.is_some() && ngrok_path.is_some() {
91        return Err("Cannot specify both --cloudflared and --ngrok at the same time".to_string());
92    }
93
94    // Use default redirect_uri
95    let redirect_uri = "http://127.0.0.1:8765/callback".to_string();
96
97    // Keep base_url from environment for testing purposes only
98    let base_url = std::env::var("SLACK_OAUTH_BASE_URL").ok();
99
100    // If cloudflared or ngrok is specified, use extended login flow
101    if cloudflared_path.is_some() || ngrok_path.is_some() {
102        // Collect missing parameters in non-interactive mode
103        if non_interactive {
104            let mut missing = Vec::new();
105            if client_id.is_none() {
106                missing.push("--client-id");
107            }
108            if bot_scopes.is_none() {
109                missing.push("--bot-scopes");
110            }
111            if user_scopes.is_none() {
112                missing.push("--user-scopes");
113            }
114            if !missing.is_empty() {
115                return Err(format!(
116                    "Missing required parameters in non-interactive mode: {}\n\
117                    Provide them via CLI flags:\n\
118                    Example: slack-rs auth login --cloudflared --client-id <id> --bot-scopes <scopes> --user-scopes <scopes>",
119                    missing.join(", ")
120                ));
121            }
122        }
123
124        // Prompt for client_id if not provided (only in interactive mode)
125        let client_id = if let Some(id) = client_id {
126            id
127        } else if non_interactive {
128            return Err(
129                "Client ID is required in non-interactive mode. Use --client-id flag.".to_string(),
130            );
131        } else {
132            use std::io::{self, Write};
133            print!("Enter Slack Client ID: ");
134            io::stdout().flush().unwrap();
135            let mut input = String::new();
136            io::stdin().read_line(&mut input).unwrap();
137            input.trim().to_string()
138        };
139
140        // Use default scopes if not provided
141        let bot_scopes = bot_scopes.unwrap_or_else(oauth::bot_all_scopes);
142        let user_scopes = user_scopes.unwrap_or_else(oauth::user_all_scopes);
143
144        if debug::enabled() {
145            debug::log("Preparing to call login_with_credentials_extended");
146            debug::log(format!("bot_scopes_count={}", bot_scopes.len()));
147            debug::log(format!("user_scopes_count={}", user_scopes.len()));
148        }
149
150        // Prompt for client_secret (only in interactive mode)
151        let client_secret = if non_interactive {
152            return Err("Client secret cannot be provided in non-interactive mode with --cloudflared/--ngrok. Use the standard login flow (without --cloudflared/--ngrok) to save credentials first.".to_string());
153        } else {
154            auth::prompt_for_client_secret()
155                .map_err(|e| format!("Failed to read client secret: {}", e))?
156        };
157
158        // Call extended login with cloudflared support
159        auth::login_with_credentials_extended(
160            client_id,
161            client_secret,
162            bot_scopes,
163            user_scopes,
164            profile_name,
165            cloudflared_path.is_some(),
166        )
167        .await
168        .map_err(|e| e.to_string())
169    } else {
170        // Call standard login with credentials
171        // This will prompt for client_secret and other missing OAuth config
172        auth::login_with_credentials(
173            client_id,
174            profile_name,
175            redirect_uri,
176            vec![], // Legacy scopes parameter (unused)
177            bot_scopes,
178            user_scopes,
179            base_url,
180            non_interactive,
181        )
182        .await
183        .map_err(|e| e.to_string())
184    }
185}
186
187/// Check if we should show private channel guidance
188fn should_show_private_channel_guidance(
189    api_args: &ApiCallArgs,
190    token_type: &str,
191    response: &ApiCallResponse,
192) -> bool {
193    // Only show guidance for conversations.list with private_channel type and bot token
194    if api_args.method != "conversations.list" || token_type != "bot" {
195        return false;
196    }
197
198    // Check if types parameter includes private_channel
199    if let Some(types) = api_args.params.get("types") {
200        if !types.contains("private_channel") {
201            return false;
202        }
203    } else {
204        return false;
205    }
206
207    // Check if response has empty channels array
208    if let Some(channels) = response.response.get("channels") {
209        if let Some(channels_array) = channels.as_array() {
210            return channels_array.is_empty();
211        }
212    }
213
214    false
215}
216
217/// Run the api call command
218pub async fn run_api_call(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
219    // Parse arguments
220    let api_args = ApiCallArgs::parse(&args)?;
221
222    // Determine profile name (from environment or default to "default")
223    let profile_name = std::env::var("SLACK_PROFILE").unwrap_or_else(|_| "default".to_string());
224
225    // Get config path
226    let config_path = default_config_path()?;
227
228    // Resolve profile to get full profile details
229    let profile = resolve_profile_full(&config_path, &profile_name)
230        .map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
231
232    // Create context from resolved profile
233    let context = ApiCallContext {
234        profile_name: Some(profile_name.clone()),
235        team_id: profile.team_id.clone(),
236        user_id: profile.user_id.clone(),
237    };
238
239    // Resolve token type: CLI flag > profile default > fallback to bot
240    let resolved_token_type = TokenType::resolve(
241        api_args.token_type,
242        profile.default_token_type,
243        TokenType::Bot,
244    );
245
246    // Create token key from team_id, user_id, and token type
247    // User token key format: {team_id}:{user_id}:user (matches auth/commands.rs storage format)
248    let token_key_bot = make_token_key(&profile.team_id, &profile.user_id);
249    let token_key_user = format!("{}:{}:user", profile.team_id, profile.user_id);
250
251    // Select the appropriate token key based on resolved token type
252    let token_key = match resolved_token_type {
253        TokenType::Bot => token_key_bot.clone(),
254        TokenType::User => token_key_user.clone(),
255    };
256
257    // Retrieve token from token store
258    // Try token store first, fall back to environment variable only for the requested token type
259    let token_store =
260        create_token_store().map_err(|e| format!("Failed to create token store: {}", e))?;
261
262    // Determine if the token type was explicitly requested via CLI flag OR default_token_type
263    // If either is set, we should NOT fallback to a different token type
264    let explicit_request = api_args.token_type.is_some() || profile.default_token_type.is_some();
265
266    let token = match token_store.get(&token_key) {
267        Ok(t) => t,
268        Err(_) => {
269            // If token not found in store, check environment variable
270            if let Ok(env_token) = std::env::var("SLACK_TOKEN") {
271                env_token
272            } else if explicit_request {
273                // If token type was explicitly requested (via --token-type or default_token_type), fail without fallback
274                return Err(format!(
275                    "No {} token found for profile '{}' ({}:{}). Explicitly requested token type not available. Set SLACK_TOKEN environment variable or run 'slack login' to obtain a {} token.",
276                    resolved_token_type, profile_name, profile.team_id, profile.user_id, resolved_token_type
277                ).into());
278            } else {
279                // If no token type preference was specified at all, try bot token as fallback
280                if resolved_token_type == TokenType::User {
281                    if let Ok(bot_token) = token_store.get(&token_key_bot) {
282                        eprintln!(
283                            "Warning: User token not found, falling back to bot token for profile '{}'",
284                            profile_name
285                        );
286                        bot_token
287                    } else {
288                        return Err(format!(
289                            "No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
290                            resolved_token_type, profile_name, profile.team_id, profile.user_id
291                        ).into());
292                    }
293                } else {
294                    return Err(format!(
295                        "No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
296                        resolved_token_type, profile_name, profile.team_id, profile.user_id
297                    ).into());
298                }
299            }
300        }
301    };
302
303    // Create API client
304    let client = ApiClient::new();
305
306    // Execute API call with token type information and command name
307    let response = execute_api_call(
308        &client,
309        &api_args,
310        &token,
311        &context,
312        resolved_token_type.as_str(),
313        "api call",
314    )
315    .await?;
316
317    // Display error guidance if response contains a known error
318    crate::api::display_error_guidance(&response);
319
320    // Check if we should show guidance for private_channel with bot token
321    if should_show_private_channel_guidance(&api_args, resolved_token_type.as_str(), &response) {
322        eprintln!();
323        eprintln!("Note: The conversation list for private channels is empty.");
324        eprintln!("Bot tokens can only see private channels where the bot is a member.");
325        eprintln!("To list all private channels, use a User Token with appropriate scopes.");
326        eprintln!("Run: slackcli auth login (with user_scopes) or use --token-type user");
327        eprintln!();
328    }
329
330    // Print response as JSON
331    // If --raw flag is set, output only the Slack API response without envelope
332    let json = if api_args.raw {
333        serde_json::to_string_pretty(&response.response)?
334    } else {
335        serde_json::to_string_pretty(&response)?
336    };
337    println!("{}", json);
338
339    Ok(())
340}
341
342/// Common arguments shared between export and import commands
343struct ExportImportArgs {
344    passphrase_env: Option<String>,
345    yes: bool,
346    lang: Option<String>,
347}
348
349impl ExportImportArgs {
350    /// Parse common arguments from command line args
351    /// Returns (ExportImportArgs, remaining_unparsed_args)
352    fn parse(args: &[String]) -> (Self, Vec<(usize, String)>) {
353        let mut passphrase_env: Option<String> = None;
354        let mut yes = false;
355        let mut lang: Option<String> = None;
356        let mut remaining = Vec::new();
357
358        let mut i = 0;
359        while i < args.len() {
360            match args[i].as_str() {
361                "--passphrase-env" => {
362                    i += 1;
363                    if i < args.len() {
364                        passphrase_env = Some(args[i].clone());
365                    }
366                }
367                "--passphrase-prompt" => {
368                    // Ignore this flag - we always prompt if --passphrase-env is not set
369                }
370                "--yes" => {
371                    yes = true;
372                }
373                "--lang" => {
374                    i += 1;
375                    if i < args.len() {
376                        lang = Some(args[i].clone());
377                    }
378                }
379                _ => {
380                    // Not a common argument, save for specific parsing
381                    remaining.push((i, args[i].clone()));
382                }
383            }
384            i += 1;
385        }
386
387        (
388            Self {
389                passphrase_env,
390                yes,
391                lang,
392            },
393            remaining,
394        )
395    }
396
397    /// Get Messages based on language setting
398    fn get_messages(&self) -> auth::Messages {
399        if let Some(ref lang_code) = self.lang {
400            if let Some(language) = auth::Language::from_code(lang_code) {
401                auth::Messages::new(language)
402            } else {
403                auth::Messages::default()
404            }
405        } else {
406            auth::Messages::default()
407        }
408    }
409
410    /// Get passphrase from environment variable or prompt
411    fn get_passphrase(&self, messages: &auth::Messages) -> Result<String, String> {
412        if let Some(ref env_var) = self.passphrase_env {
413            match std::env::var(env_var) {
414                Ok(val) => Ok(val),
415                Err(_) => {
416                    // Fallback to prompt if environment variable is not set
417                    eprintln!(
418                        "Warning: Environment variable {} not found, prompting for passphrase",
419                        env_var
420                    );
421                    rpassword::prompt_password(messages.get("prompt.passphrase"))
422                        .map_err(|e| format!("Error reading passphrase: {}", e))
423                }
424            }
425        } else {
426            // Fallback to prompt mode
427            rpassword::prompt_password(messages.get("prompt.passphrase"))
428                .map_err(|e| format!("Error reading passphrase: {}", e))
429        }
430    }
431}
432
433/// Handle auth export command
434pub async fn handle_export_command(args: &[String]) {
435    // Parse common arguments
436    let (common_args, remaining) = ExportImportArgs::parse(args);
437
438    // Parse export-specific arguments
439    let mut profile_name: Option<String> = None;
440    let mut all = false;
441    let mut output_path: Option<String> = None;
442
443    for (idx, arg) in remaining {
444        match arg.as_str() {
445            "--profile" => {
446                // Next arg should be the profile name
447                if idx + 1 < args.len() {
448                    profile_name = Some(args[idx + 1].clone());
449                }
450            }
451            "--all" => {
452                all = true;
453            }
454            "--out" => {
455                // Next arg should be the output path
456                if idx + 1 < args.len() {
457                    output_path = Some(args[idx + 1].clone());
458                }
459            }
460            _ => {
461                // Check if this is a value for a previous flag
462                if idx > 0 {
463                    let prev = &args[idx - 1];
464                    if prev == "--profile"
465                        || prev == "--out"
466                        || prev == "--passphrase-env"
467                        || prev == "--lang"
468                    {
469                        // This is a value, not an unknown option
470                        continue;
471                    }
472                }
473                eprintln!("Unknown option: {}", arg);
474                std::process::exit(1);
475            }
476        }
477    }
478
479    // Get i18n messages
480    let messages = common_args.get_messages();
481
482    // Show warning and validate --yes
483    if !common_args.yes {
484        eprintln!("{}", messages.get("warn.export_sensitive"));
485        eprintln!("Error: --yes flag is required to confirm this dangerous operation");
486        std::process::exit(1);
487    }
488
489    // Validate required options
490    let output = match output_path {
491        Some(path) => path,
492        None => {
493            eprintln!("Error: --out <file> is required");
494            std::process::exit(1);
495        }
496    };
497
498    // Get passphrase
499    let passphrase = match common_args.get_passphrase(&messages) {
500        Ok(pass) => pass,
501        Err(e) => {
502            eprintln!("{}", e);
503            std::process::exit(1);
504        }
505    };
506
507    let options = auth::ExportOptions {
508        profile_name,
509        all,
510        output_path: output,
511        passphrase,
512        yes: common_args.yes,
513    };
514
515    let token_store = create_token_store().expect("Failed to create token store");
516    match auth::export_profiles(&*token_store, &options) {
517        Ok(_) => {
518            println!("{}", messages.get("success.export"));
519        }
520        Err(e) => {
521            eprintln!("Export failed: {}", e);
522            std::process::exit(1);
523        }
524    }
525}
526
527/// Handle auth import command
528pub async fn handle_import_command(args: &[String]) {
529    // Parse common arguments
530    let (common_args, remaining) = ExportImportArgs::parse(args);
531
532    // Parse import-specific arguments
533    let mut input_path: Option<String> = None;
534    let mut force = false;
535
536    for (idx, arg) in remaining {
537        match arg.as_str() {
538            "--in" => {
539                // Next arg should be the input path
540                if idx + 1 < args.len() {
541                    input_path = Some(args[idx + 1].clone());
542                }
543            }
544            "--force" => {
545                force = true;
546            }
547            _ => {
548                // Check if this is a value for a previous flag
549                if idx > 0 {
550                    let prev = &args[idx - 1];
551                    if prev == "--in" || prev == "--passphrase-env" || prev == "--lang" {
552                        // This is a value, not an unknown option
553                        continue;
554                    }
555                }
556                eprintln!("Unknown option: {}", arg);
557                std::process::exit(1);
558            }
559        }
560    }
561
562    // Get i18n messages
563    let messages = common_args.get_messages();
564
565    // Validate required options
566    let input = match input_path {
567        Some(path) => path,
568        None => {
569            eprintln!("Error: --in <file> is required");
570            std::process::exit(1);
571        }
572    };
573
574    // Get passphrase
575    let passphrase = match common_args.get_passphrase(&messages) {
576        Ok(pass) => pass,
577        Err(e) => {
578            eprintln!("{}", e);
579            std::process::exit(1);
580        }
581    };
582
583    let options = auth::ImportOptions {
584        input_path: input,
585        passphrase,
586        yes: common_args.yes,
587        force,
588    };
589
590    let token_store = create_token_store().expect("Failed to create token store");
591    match auth::import_profiles(&*token_store, &options) {
592        Ok(_) => {
593            println!("{}", messages.get("success.import"));
594        }
595        Err(e) => {
596            eprintln!("Import failed: {}", e);
597            std::process::exit(1);
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::api::call::ApiCallMeta;
606    use serde_json::json;
607    use std::collections::HashMap;
608
609    #[test]
610    fn test_should_show_private_channel_guidance_empty_response() {
611        let mut params = HashMap::new();
612        params.insert("types".to_string(), "private_channel".to_string());
613
614        let args = ApiCallArgs {
615            method: "conversations.list".to_string(),
616            params,
617            use_json: false,
618            use_get: false,
619            token_type: None,
620            raw: false,
621        };
622
623        let response = ApiCallResponse {
624            response: json!({
625                "ok": true,
626                "channels": []
627            }),
628            meta: ApiCallMeta {
629                profile_name: Some("default".to_string()),
630                team_id: "T123".to_string(),
631                user_id: "U123".to_string(),
632                method: "conversations.list".to_string(),
633                command: "api call".to_string(),
634                token_type: "bot".to_string(),
635            },
636        };
637
638        // Should show guidance when bot token returns empty private channels
639        assert!(should_show_private_channel_guidance(
640            &args, "bot", &response
641        ));
642    }
643
644    #[test]
645    fn test_should_show_private_channel_guidance_non_empty_response() {
646        let mut params = HashMap::new();
647        params.insert("types".to_string(), "private_channel".to_string());
648
649        let args = ApiCallArgs {
650            method: "conversations.list".to_string(),
651            params,
652            use_json: false,
653            use_get: false,
654            token_type: None,
655            raw: false,
656        };
657
658        let response = ApiCallResponse {
659            response: json!({
660                "ok": true,
661                "channels": [
662                    {"id": "C123", "name": "private-channel"}
663                ]
664            }),
665            meta: ApiCallMeta {
666                profile_name: Some("default".to_string()),
667                team_id: "T123".to_string(),
668                user_id: "U123".to_string(),
669                method: "conversations.list".to_string(),
670                command: "api call".to_string(),
671                token_type: "bot".to_string(),
672            },
673        };
674
675        // Should not show guidance when channels are returned
676        assert!(!should_show_private_channel_guidance(
677            &args, "bot", &response
678        ));
679    }
680
681    #[test]
682    fn test_should_show_private_channel_guidance_user_token() {
683        let mut params = HashMap::new();
684        params.insert("types".to_string(), "private_channel".to_string());
685
686        let args = ApiCallArgs {
687            method: "conversations.list".to_string(),
688            params,
689            use_json: false,
690            use_get: false,
691            token_type: None,
692            raw: false,
693        };
694
695        let response = ApiCallResponse {
696            response: json!({
697                "ok": true,
698                "channels": []
699            }),
700            meta: ApiCallMeta {
701                profile_name: Some("default".to_string()),
702                team_id: "T123".to_string(),
703                user_id: "U123".to_string(),
704                method: "conversations.list".to_string(),
705                command: "api call".to_string(),
706                token_type: "user".to_string(),
707            },
708        };
709
710        // Should not show guidance when using user token
711        assert!(!should_show_private_channel_guidance(
712            &args, "user", &response
713        ));
714    }
715}