Skip to main content

zad_cli/cli/
service_discord.rs

1//! Discord's plug-in to the generic service lifecycle.
2//!
3//! Everything in this file is Discord-specific — scope names, prompts
4//! for application ID and default guild, the shape of the Discord
5//! credential (`DiscordSecrets` = one bot token), and the call that
6//! validates the token against the Discord API. The generic plumbing
7//! (flag parsing, path resolution, JSON envelopes, human banners,
8//! keychain I/O sequencing) lives in `src/cli/lifecycle.rs` and is
9//! shared with every other service.
10//!
11//! See `docs/services.md#adding-a-new-service` for the recipe a new
12//! service would follow. This file is the first — and, until
13//! Telegram/Slack/etc. land, only — implementation of that recipe.
14
15use crate::cli::DialoguerExt;
16use async_trait::async_trait;
17use clap::Args;
18use dialoguer::{Input, Password, theme::ColorfulTheme};
19
20use crate::cli::lifecycle::{
21    BotTokenArgs, CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg,
22    SecretRef, resolve_scopes,
23};
24use zad::config::{DiscordServiceCfg, ProjectConfig};
25use zad::error::{Result, ZadError};
26use zad::secrets::{self, Scope};
27use zad::service::discord::DiscordHttp;
28
29const DEFAULT_SCOPES: &[&str] = &["guilds", "messages.read", "messages.send"];
30const ALL_SCOPES: &[&str] = &[
31    "guilds",
32    "messages.read",
33    "messages.send",
34    "channels.manage",
35    "gateway.listen",
36];
37
38// ---------------------------------------------------------------------------
39// Discord's credential shape
40// ---------------------------------------------------------------------------
41
42/// Discord only uses one secret — the long-lived bot token — so
43/// `Secrets` wraps it in a named struct rather than `String` for
44/// parity with services that need richer shapes.
45pub struct DiscordSecrets {
46    pub bot_token: String,
47}
48
49// ---------------------------------------------------------------------------
50// Discord's `zad service create discord` args
51// ---------------------------------------------------------------------------
52
53#[derive(Debug, Args)]
54pub struct CreateArgs {
55    #[command(flatten)]
56    pub base: CreateArgsBase,
57    #[command(flatten)]
58    pub token: BotTokenArgs,
59    #[command(flatten)]
60    pub scopes: ScopesArg,
61    /// Discord application (bot) ID.
62    #[arg(long)]
63    pub application_id: Option<String>,
64    /// Optional default guild (server) ID.
65    #[arg(long)]
66    pub default_guild: Option<String>,
67    /// Numeric Discord user ID for the human user this bot belongs to.
68    /// Resolved from the literal `@me` in later send targets. Obtain
69    /// from Discord: Settings → Advanced → enable Developer Mode, then
70    /// right-click yourself → "Copy User ID". Leave unset in
71    /// non-interactive mode to skip; fill later via `zad discord self
72    /// set <id>`.
73    #[arg(long)]
74    pub self_user: Option<String>,
75}
76
77impl CreateArgsLike for CreateArgs {
78    fn base(&self) -> &CreateArgsBase {
79        &self.base
80    }
81}
82
83// ---------------------------------------------------------------------------
84// The trait impl — this is the entire Discord-specific surface
85// ---------------------------------------------------------------------------
86
87pub struct DiscordLifecycle;
88
89#[async_trait]
90impl LifecycleService for DiscordLifecycle {
91    const NAME: &'static str = "discord";
92    const DISPLAY: &'static str = "Discord";
93    type Cfg = DiscordServiceCfg;
94    type Secrets = DiscordSecrets;
95
96    fn enable_in_project(cfg: &mut ProjectConfig) {
97        cfg.enable_discord();
98    }
99
100    fn disable_in_project(cfg: &mut ProjectConfig) {
101        cfg.disable_discord();
102    }
103
104    async fn validate(_cfg: &DiscordServiceCfg, creds: &mut DiscordSecrets) -> Result<String> {
105        DiscordHttp::unscoped(&creds.bot_token)
106            .validate_token()
107            .await
108            .map_err(|e| ZadError::Service {
109                name: Self::NAME,
110                message: format!("token validation failed: {e}"),
111            })
112    }
113
114    fn store_secrets(creds: &DiscordSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
115        let account = secrets::account(Self::NAME, "bot", scope);
116        secrets::store(&account, &creds.bot_token)?;
117        Ok(vec![SecretRef {
118            label: "token",
119            account,
120            present: true,
121        }])
122    }
123
124    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
125        let account = secrets::account(Self::NAME, "bot", scope);
126        secrets::delete(&account)?;
127        Ok(vec![SecretRef {
128            label: "token",
129            account,
130            present: false,
131        }])
132    }
133
134    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
135        let account = secrets::account(Self::NAME, "bot", scope);
136        let present = secrets::load(&account)?.is_some();
137        Ok(vec![SecretRef {
138            label: "token",
139            account,
140            present,
141        }])
142    }
143
144    fn load_secrets(scope: Scope<'_>) -> Result<Option<DiscordSecrets>> {
145        let account = secrets::account(Self::NAME, "bot", scope);
146        Ok(secrets::load(&account)?.map(|bot_token| DiscordSecrets { bot_token }))
147    }
148
149    fn cfg_human(cfg: &DiscordServiceCfg) -> Vec<(&'static str, String)> {
150        let mut out = vec![("app id", cfg.application_id.clone())];
151        if let Some(g) = &cfg.default_guild {
152            out.push(("guild", g.clone()));
153        }
154        if let Some(u) = &cfg.self_user_id {
155            out.push(("self", u.clone()));
156        }
157        out
158    }
159
160    fn cfg_json(cfg: &DiscordServiceCfg) -> serde_json::Value {
161        serde_json::json!({
162            "application_id": cfg.application_id,
163            "default_guild": cfg.default_guild,
164            "self_user_id": cfg.self_user_id,
165        })
166    }
167
168    fn scopes_of(cfg: &DiscordServiceCfg) -> &[String] {
169        &cfg.scopes
170    }
171
172    fn post_create_hint(cfg: &DiscordServiceCfg) -> Option<String> {
173        Some(install_url(&cfg.application_id))
174    }
175}
176
177#[async_trait]
178impl CliLifecycle for DiscordLifecycle {
179    type CreateArgs = CreateArgs;
180
181    async fn resolve(
182        args: &CreateArgs,
183        non_interactive: bool,
184    ) -> Result<(DiscordServiceCfg, DiscordSecrets)> {
185        let open_browser = !args.base.no_browser;
186        let application_id = resolve_application_id(
187            args.application_id.as_deref(),
188            open_browser,
189            non_interactive,
190        )?;
191        let default_guild = resolve_default_guild(args.default_guild.as_deref(), non_interactive)?;
192        let scopes = resolve_scopes(
193            args.scopes.scopes.as_deref(),
194            DEFAULT_SCOPES,
195            ALL_SCOPES,
196            non_interactive,
197        )?;
198        let bot_token = resolve_discord_bot_token(
199            args.token.bot_token.as_deref(),
200            args.token.bot_token_env.as_deref(),
201            &application_id,
202            open_browser,
203            non_interactive,
204        )?;
205        let self_user_id =
206            resolve_self_user_id(args.self_user.as_deref(), &bot_token, non_interactive).await?;
207        Ok((
208            DiscordServiceCfg {
209                application_id,
210                scopes,
211                default_guild,
212                self_user_id,
213            },
214            DiscordSecrets { bot_token },
215        ))
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Discord-specific prompt helpers
221// ---------------------------------------------------------------------------
222
223fn theme() -> ColorfulTheme {
224    ColorfulTheme::default()
225}
226
227fn resolve_application_id(
228    flag: Option<&str>,
229    open_browser: bool,
230    non_interactive: bool,
231) -> Result<String> {
232    if let Some(v) = flag {
233        return validate_numeric(v, "application-id").map(|_| v.to_string());
234    }
235    if non_interactive {
236        return Err(ZadError::MissingRequired("--application-id"));
237    }
238
239    let url = PORTAL_APPS_URL;
240    println!();
241    println!("Your Discord applications live at:");
242    println!("  {url}");
243    println!("Create one (or open an existing app) and copy its Application ID.");
244    if open_browser {
245        let _ = open::that(url);
246    }
247
248    let v: String = Input::with_theme(&theme())
249        .with_prompt("Discord application ID")
250        .validate_with(|s: &String| validate_numeric(s, "application-id").map(|_| ()))
251        .interact_text()
252        .into_zad()?;
253    Ok(v)
254}
255
256fn resolve_default_guild(flag: Option<&str>, non_interactive: bool) -> Result<Option<String>> {
257    if let Some(v) = flag {
258        validate_numeric(v, "default-guild")?;
259        return Ok(Some(v.to_string()));
260    }
261    if non_interactive {
262        return Ok(None);
263    }
264
265    println!();
266    println!("To find a guild (server) ID in Discord:");
267    println!("  Settings → Advanced → enable Developer Mode, then");
268    println!("  right-click the server icon → \"Copy Server ID\".");
269    println!("Leave blank to skip — you can set a default guild later.");
270
271    let v: String = Input::with_theme(&theme())
272        .with_prompt("Default guild ID (leave blank for none)")
273        .allow_empty(true)
274        .interact_text()
275        .into_zad()?;
276    if v.trim().is_empty() {
277        Ok(None)
278    } else {
279        validate_numeric(&v, "default-guild").map(|_| Some(v))
280    }
281}
282
283fn validate_numeric(v: &str, field: &'static str) -> Result<()> {
284    if v.chars().all(|c| c.is_ascii_digit()) && !v.is_empty() {
285        Ok(())
286    } else {
287        Err(ZadError::Invalid(format!(
288            "{field} must be a numeric Discord snowflake, got `{v}`"
289        )))
290    }
291}
292
293/// Discord-specific bot-token prompt: same flag/env contract as
294/// the generic `resolve_bot_token`, but the interactive path also
295/// surfaces (and optionally opens) the developer-portal URL where
296/// the token is actually generated. Discord doesn't issue bot
297/// tokens via OAuth — the portal is the only source — so the best
298/// "easy setup" we can offer is dropping the user on the right
299/// page and asking them to paste once.
300fn resolve_discord_bot_token(
301    flag: Option<&str>,
302    env_flag: Option<&str>,
303    application_id: &str,
304    open_browser: bool,
305    non_interactive: bool,
306) -> Result<String> {
307    if let Some(env) = env_flag {
308        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
309    }
310    if let Some(v) = flag {
311        return Ok(v.to_string());
312    }
313    if non_interactive {
314        return Err(ZadError::MissingRequired("--bot-token or --bot-token-env"));
315    }
316
317    let url = portal_bot_url(application_id);
318    println!();
319    println!("Your Discord bot token lives at:");
320    println!("  {url}");
321    println!("Click \"Reset Token\" → \"Copy\", then paste it below.");
322    if open_browser {
323        let _ = open::that(&url);
324    }
325
326    let v = Password::with_theme(&theme())
327        .with_prompt("Discord bot token")
328        .interact()
329        .into_zad()?;
330    Ok(v)
331}
332
333const PORTAL_APPS_URL: &str = "https://discord.com/developers/applications";
334
335fn portal_bot_url(application_id: &str) -> String {
336    format!("https://discord.com/developers/applications/{application_id}/bot")
337}
338
339fn install_url(application_id: &str) -> String {
340    format!(
341        "https://discord.com/api/oauth2/authorize?client_id={application_id}&scope=bot&permissions=0"
342    )
343}
344
345// ---------------------------------------------------------------------------
346// Self-user capture
347// ---------------------------------------------------------------------------
348
349/// Resolve `self_user_id` for `zad service create discord`. The flag
350/// path is non-interactive: validate numeric, validate against the
351/// Discord API, persist. The interactive path prints the Developer
352/// Mode recipe, prompts, and validates.
353async fn resolve_self_user_id(
354    flag: Option<&str>,
355    bot_token: &str,
356    non_interactive: bool,
357) -> Result<Option<String>> {
358    if let Some(raw) = flag {
359        return validate_self_user(bot_token, raw).await.map(Some);
360    }
361    if non_interactive {
362        return Ok(None);
363    }
364
365    println!();
366    println!("Optional: configure `@me` so commands like");
367    println!("  zad discord send --user @me \"hello\"");
368    println!("resolve to your own Discord user.");
369    println!("Find your user ID: Settings → Advanced → enable Developer Mode,");
370    println!("then right-click yourself → \"Copy User ID\".");
371
372    let raw: String = Input::with_theme(&theme())
373        .with_prompt("Your Discord user ID (leave blank to skip)")
374        .allow_empty(true)
375        .interact_text()
376        .into_zad()?;
377    if raw.trim().is_empty() {
378        return Ok(None);
379    }
380    validate_self_user(bot_token, raw.trim()).await.map(Some)
381}
382
383/// Validate a Discord user-ID string: numeric snowflake that resolves
384/// via `GET /users/{id}`. Shared between the create-time path and
385/// `zad discord self set`. Returns the canonical string form (not the
386/// parsed `u64`) because the config field is already a `String`.
387pub async fn validate_self_user(bot_token: &str, raw: &str) -> Result<String> {
388    validate_numeric(raw, "self-user")?;
389    let id: u64 = raw.parse().map_err(|_| {
390        ZadError::Invalid(format!(
391            "self-user `{raw}` doesn't fit in a 64-bit unsigned integer"
392        ))
393    })?;
394    let name = DiscordHttp::unscoped(bot_token)
395        .get_user(id)
396        .await
397        .map_err(|e| ZadError::Service {
398            name: "discord",
399            message: format!("user-id validation failed: {e}"),
400        })?;
401    println!("  ✓ resolved `{raw}` as `{name}`");
402    Ok(raw.to_string())
403}