1use crate::cli::DialoguerExt;
9use async_trait::async_trait;
10use clap::Args;
11use dialoguer::{Input, Password, theme::ColorfulTheme};
12
13use crate::cli::lifecycle::{
14 BotTokenArgs, CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg,
15 SecretRef, resolve_bot_token, resolve_scopes,
16};
17use zad::config::{ProjectConfig, SlackServiceCfg};
18use zad::error::{Result, ZadError};
19use zad::secrets::{self, Scope};
20use zad::service::slack::client::SlackHttp;
21
22const DEFAULT_SCOPES: &[&str] = &["chat:write", "channels:history", "channels:read"];
23const ALL_SCOPES: &[&str] = &[
24 "chat:write",
25 "channels:history",
26 "channels:read",
27 "im:write",
28 "im:history",
29 "users:read",
30 "channels:join",
31 "reactions:write",
32 "team:read",
33];
34
35pub struct SlackSecrets {
40 pub bot_token: String,
41 pub app_token: Option<String>,
43}
44
45#[derive(Debug, Args)]
50pub struct CreateArgs {
51 #[command(flatten)]
52 pub base: CreateArgsBase,
53 #[command(flatten)]
54 pub token: BotTokenArgs,
55 #[command(flatten)]
56 pub scopes: ScopesArg,
57 #[arg(long)]
59 pub app_id: Option<String>,
60 #[arg(long)]
62 pub default_channel: Option<String>,
63 #[arg(long)]
67 pub self_user: Option<String>,
68 #[arg(long)]
71 pub app_token: Option<String>,
72}
73
74impl CreateArgsLike for CreateArgs {
75 fn base(&self) -> &CreateArgsBase {
76 &self.base
77 }
78}
79
80pub struct SlackLifecycle;
85
86#[async_trait]
87impl LifecycleService for SlackLifecycle {
88 const NAME: &'static str = "slack";
89 const DISPLAY: &'static str = "Slack";
90 type Cfg = SlackServiceCfg;
91 type Secrets = SlackSecrets;
92
93 fn enable_in_project(cfg: &mut ProjectConfig) {
94 cfg.enable_slack();
95 }
96
97 fn disable_in_project(cfg: &mut ProjectConfig) {
98 cfg.disable_slack();
99 }
100
101 async fn validate(cfg: &SlackServiceCfg, creds: &mut SlackSecrets) -> Result<String> {
102 let info = SlackHttp::unscoped(&creds.bot_token)
103 .auth_test()
104 .await
105 .map_err(|e| ZadError::Service {
106 name: Self::NAME,
107 message: format!("token validation failed: {e}"),
108 })?;
109 let _ = cfg;
113 Ok(format!(
114 "@{} in {} ({})",
115 info.user, info.team, info.team_id
116 ))
117 }
118
119 fn store_secrets(creds: &SlackSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
120 let bot_account = secrets::account(Self::NAME, "bot", scope.clone());
121 secrets::store(&bot_account, &creds.bot_token)?;
122 let mut refs = vec![SecretRef {
123 label: "token",
124 account: bot_account,
125 present: true,
126 }];
127 if let Some(app_tok) = &creds.app_token {
128 let app_account = secrets::account(Self::NAME, "app", scope);
129 secrets::store(&app_account, app_tok)?;
130 refs.push(SecretRef {
131 label: "app-token",
132 account: app_account,
133 present: true,
134 });
135 }
136 Ok(refs)
137 }
138
139 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
140 let bot_account = secrets::account(Self::NAME, "bot", scope.clone());
141 secrets::delete(&bot_account)?;
142 let app_account = secrets::account(Self::NAME, "app", scope);
143 let _ = secrets::delete(&app_account);
145 Ok(vec![
146 SecretRef {
147 label: "token",
148 account: bot_account,
149 present: false,
150 },
151 SecretRef {
152 label: "app-token",
153 account: app_account,
154 present: false,
155 },
156 ])
157 }
158
159 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
160 let bot_account = secrets::account(Self::NAME, "bot", scope.clone());
161 let bot_present = secrets::load(&bot_account)?.is_some();
162 let app_account = secrets::account(Self::NAME, "app", scope);
163 let app_present = secrets::load(&app_account)?.is_some();
164 Ok(vec![
165 SecretRef {
166 label: "token",
167 account: bot_account,
168 present: bot_present,
169 },
170 SecretRef {
171 label: "app-token",
172 account: app_account,
173 present: app_present,
174 },
175 ])
176 }
177
178 fn load_secrets(scope: Scope<'_>) -> Result<Option<SlackSecrets>> {
179 let bot_account = secrets::account(Self::NAME, "bot", scope.clone());
180 let Some(bot_token) = secrets::load(&bot_account)? else {
181 return Ok(None);
182 };
183 let app_account = secrets::account(Self::NAME, "app", scope);
184 let app_token = secrets::load(&app_account)?;
185 Ok(Some(SlackSecrets {
186 bot_token,
187 app_token,
188 }))
189 }
190
191 fn cfg_human(cfg: &SlackServiceCfg) -> Vec<(&'static str, String)> {
192 let mut out = vec![
193 ("app id", cfg.app_id.clone()),
194 ("workspace", cfg.workspace.clone()),
195 ];
196 if let Some(c) = &cfg.default_channel {
197 out.push(("channel", c.clone()));
198 }
199 if let Some(u) = &cfg.self_user_id {
200 out.push(("self", u.clone()));
201 }
202 out
203 }
204
205 fn cfg_json(cfg: &SlackServiceCfg) -> serde_json::Value {
206 serde_json::json!({
207 "app_id": cfg.app_id,
208 "workspace": cfg.workspace,
209 "default_channel": cfg.default_channel,
210 "self_user_id": cfg.self_user_id,
211 })
212 }
213
214 fn scopes_of(cfg: &SlackServiceCfg) -> &[String] {
215 &cfg.scopes
216 }
217
218 fn post_create_hint(cfg: &SlackServiceCfg) -> Option<String> {
219 Some(format!(
220 "https://api.slack.com/apps/{}/install-on-team",
221 cfg.app_id
222 ))
223 }
224}
225
226#[async_trait]
227impl CliLifecycle for SlackLifecycle {
228 type CreateArgs = CreateArgs;
229
230 async fn resolve(
231 args: &CreateArgs,
232 non_interactive: bool,
233 ) -> Result<(SlackServiceCfg, SlackSecrets)> {
234 let open_browser = !args.base.no_browser;
235 let app_id = resolve_app_id(args.app_id.as_deref(), open_browser, non_interactive)?;
236 let scopes = resolve_scopes(
237 args.scopes.scopes.as_deref(),
238 DEFAULT_SCOPES,
239 ALL_SCOPES,
240 non_interactive,
241 )?;
242 let bot_token = resolve_bot_token(
243 args.token.bot_token.as_deref(),
244 args.token.bot_token_env.as_deref(),
245 non_interactive,
246 Self::DISPLAY,
247 )?;
248 let app_token = resolve_app_token(args.app_token.as_deref(), non_interactive)?;
249 let default_channel = args.default_channel.clone();
250 let self_user_id = args.self_user.clone();
251
252 let workspace = String::new();
257
258 Ok((
259 SlackServiceCfg {
260 app_id,
261 workspace,
262 scopes,
263 default_channel,
264 self_user_id,
265 },
266 SlackSecrets {
267 bot_token,
268 app_token,
269 },
270 ))
271 }
272}
273
274fn theme() -> ColorfulTheme {
279 ColorfulTheme::default()
280}
281
282fn resolve_app_id(flag: Option<&str>, open_browser: bool, non_interactive: bool) -> Result<String> {
283 if let Some(v) = flag {
284 return Ok(v.to_string());
285 }
286 if non_interactive {
287 return Err(ZadError::MissingRequired("--app-id"));
288 }
289 let url = "https://api.slack.com/apps";
290 println!();
291 println!("Your Slack apps live at:");
292 println!(" {url}");
293 println!("Create one (or open an existing app) and copy its App ID from Basic Information.");
294 if open_browser {
295 let _ = open::that(url);
296 }
297 let v: String = Input::with_theme(&theme())
298 .with_prompt("Slack App ID")
299 .interact_text()
300 .into_zad()?;
301 Ok(v.trim().to_string())
302}
303
304fn resolve_app_token(flag: Option<&str>, non_interactive: bool) -> Result<Option<String>> {
305 if let Some(v) = flag {
306 return Ok(Some(v.to_string()));
307 }
308 if non_interactive {
309 return Ok(None);
310 }
311 println!();
312 println!("Optional: an App-Level Token (`xapp-...`) enables Socket Mode so `listen` works.");
313 println!("Find it in your Slack app dashboard → Basic Information → App-Level Tokens.");
314 println!("Leave blank to skip — the service works without it for send/read/channels.");
315 let v = Password::with_theme(&theme())
316 .with_prompt("App-Level Token (leave blank to skip)")
317 .allow_empty_password(true)
318 .interact()
319 .into_zad()?;
320 let trimmed = v.trim().to_string();
321 if trimmed.is_empty() {
322 Ok(None)
323 } else {
324 Ok(Some(trimmed))
325 }
326}