Skip to main content

zad_cli/cli/
service_slack.rs

1//! Slack's plug-in to the generic service lifecycle.
2//!
3//! Slack bots use a single long-lived bot token (`xoxb-...`). An optional
4//! App-Level Token (`xapp-...`) enables Socket Mode for real-time events
5//! via `zad slack listen`. Without it, the service still works for all
6//! send/read/channels/discover verbs.
7
8use 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
35// ---------------------------------------------------------------------------
36// Slack's credential shape
37// ---------------------------------------------------------------------------
38
39pub struct SlackSecrets {
40    pub bot_token: String,
41    /// App-Level Token for Socket Mode (`xapp-...`). Optional.
42    pub app_token: Option<String>,
43}
44
45// ---------------------------------------------------------------------------
46// Slack's `zad service create slack` args
47// ---------------------------------------------------------------------------
48
49#[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    /// Slack App ID (found on api.slack.com/apps → Basic Information).
58    #[arg(long)]
59    pub app_id: Option<String>,
60    /// Optional default channel ID or name for verbs that omit `--channel`.
61    #[arg(long)]
62    pub default_channel: Option<String>,
63    /// Your Slack user ID (`U...`). Resolves `@me` in send targets. Find it
64    /// in Slack: click your name → View profile → More → Copy member ID.
65    /// Leave unset to skip; set later via `zad slack self set <id>`.
66    #[arg(long)]
67    pub self_user: Option<String>,
68    /// App-Level Token (`xapp-...`) for Socket Mode real-time events.
69    /// Optional — the service works without it for send/read/channels.
70    #[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
80// ---------------------------------------------------------------------------
81// The trait impl
82// ---------------------------------------------------------------------------
83
84pub 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        // Patch workspace into cfg. Since validate() takes `&SlackServiceCfg`
110        // we can't mutate it here; the driver uses the return string for
111        // display only. Workspace is set during create via a second save.
112        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        // App token may not be present; swallow missing-entry errors.
144        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        // We'll fill workspace from auth.test during validate; store a
253        // placeholder here so the Cfg round-trips through serde correctly.
254        // The lifecycle driver calls validate() after resolve(), so by the
255        // time the file is written `workspace` will be the real value.
256        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
274// ---------------------------------------------------------------------------
275// prompt helpers
276// ---------------------------------------------------------------------------
277
278fn 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}