1use 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
38pub struct DiscordSecrets {
46 pub bot_token: String,
47}
48
49#[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 #[arg(long)]
63 pub application_id: Option<String>,
64 #[arg(long)]
66 pub default_guild: Option<String>,
67 #[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
83pub 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
219fn 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
293fn 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
345async 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
383pub 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}