1use 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
14pub 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 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
38 i += 1;
39 cloudflared_path = Some(args[i].clone());
40 } else {
41 cloudflared_path = Some("cloudflared".to_string());
43 }
44 }
45 "--ngrok" => {
46 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
48 i += 1;
49 ngrok_path = Some(args[i].clone());
50 } else {
51 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 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 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 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 let redirect_uri = "http://127.0.0.1:8765/callback".to_string();
96
97 let base_url = std::env::var("SLACK_OAUTH_BASE_URL").ok();
99
100 if cloudflared_path.is_some() || ngrok_path.is_some() {
102 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 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 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 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 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 auth::login_with_credentials(
173 client_id,
174 profile_name,
175 redirect_uri,
176 vec![], bot_scopes,
178 user_scopes,
179 base_url,
180 non_interactive,
181 )
182 .await
183 .map_err(|e| e.to_string())
184 }
185}
186
187fn should_show_private_channel_guidance(
189 api_args: &ApiCallArgs,
190 token_type: &str,
191 response: &ApiCallResponse,
192) -> bool {
193 if api_args.method != "conversations.list" || token_type != "bot" {
195 return false;
196 }
197
198 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 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
217pub async fn run_api_call(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
219 let api_args = ApiCallArgs::parse(&args)?;
221
222 let profile_name = std::env::var("SLACK_PROFILE").unwrap_or_else(|_| "default".to_string());
224
225 let config_path = default_config_path()?;
227
228 let profile = resolve_profile_full(&config_path, &profile_name)
230 .map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
231
232 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 let resolved_token_type = TokenType::resolve(
241 api_args.token_type,
242 profile.default_token_type,
243 TokenType::Bot,
244 );
245
246 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 let token_key = match resolved_token_type {
253 TokenType::Bot => token_key_bot.clone(),
254 TokenType::User => token_key_user.clone(),
255 };
256
257 let token_store =
260 create_token_store().map_err(|e| format!("Failed to create token store: {}", e))?;
261
262 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 let Ok(env_token) = std::env::var("SLACK_TOKEN") {
271 env_token
272 } else if explicit_request {
273 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 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 let client = ApiClient::new();
305
306 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 crate::api::display_error_guidance(&response);
319
320 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 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
342struct ExportImportArgs {
344 passphrase_env: Option<String>,
345 yes: bool,
346 lang: Option<String>,
347}
348
349impl ExportImportArgs {
350 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 }
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 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 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 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 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 rpassword::prompt_password(messages.get("prompt.passphrase"))
428 .map_err(|e| format!("Error reading passphrase: {}", e))
429 }
430 }
431}
432
433pub async fn handle_export_command(args: &[String]) {
435 let (common_args, remaining) = ExportImportArgs::parse(args);
437
438 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 if idx + 1 < args.len() {
448 profile_name = Some(args[idx + 1].clone());
449 }
450 }
451 "--all" => {
452 all = true;
453 }
454 "--out" => {
455 if idx + 1 < args.len() {
457 output_path = Some(args[idx + 1].clone());
458 }
459 }
460 _ => {
461 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 continue;
471 }
472 }
473 eprintln!("Unknown option: {}", arg);
474 std::process::exit(1);
475 }
476 }
477 }
478
479 let messages = common_args.get_messages();
481
482 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 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 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
527pub async fn handle_import_command(args: &[String]) {
529 let (common_args, remaining) = ExportImportArgs::parse(args);
531
532 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 if idx + 1 < args.len() {
541 input_path = Some(args[idx + 1].clone());
542 }
543 }
544 "--force" => {
545 force = true;
546 }
547 _ => {
548 if idx > 0 {
550 let prev = &args[idx - 1];
551 if prev == "--in" || prev == "--passphrase-env" || prev == "--lang" {
552 continue;
554 }
555 }
556 eprintln!("Unknown option: {}", arg);
557 std::process::exit(1);
558 }
559 }
560 }
561
562 let messages = common_args.get_messages();
564
565 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 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 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 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 assert!(!should_show_private_channel_guidance(
712 &args, "user", &response
713 ));
714 }
715}