Skip to main content

zad_cli/cli/
discord.rs

1//! `zad discord <verb>` — runtime commands against a configured Discord
2//! bot. Credential resolution mirrors `zad service enable discord`: the
3//! project-local config wins over the global one, and the matching
4//! keychain entry holds the bot token. The project must already have
5//! enabled the Discord service.
6
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use clap::{Args, Subcommand};
11use serde::Serialize;
12
13use zad::config::directory::{self as dir, Directory};
14use zad::config::{self, DiscordServiceCfg};
15use zad::error::{Result, ZadError};
16use zad::permissions::attachments::AttachmentInfo;
17use zad::secrets::{self, Scope};
18use zad::service::discord::permissions::{self as perms, DiscordFunction};
19use zad::service::discord::{DiscordHttp, DiscordTransport, DryRunDiscordTransport};
20use zad::service::{ChannelId, Target, UserId, default_dry_run_sink};
21
22// ---------------------------------------------------------------------------
23// subcommand plumbing
24// ---------------------------------------------------------------------------
25
26#[derive(Debug, Args)]
27pub struct DiscordArgs {
28    #[command(subcommand)]
29    pub action: Option<Action>,
30}
31
32#[derive(Debug, Subcommand)]
33pub enum Action {
34    /// Send a message to a channel or DM.
35    Send(SendArgs),
36    /// Read recent messages from a channel.
37    Read(ReadArgs),
38    /// List channels in a guild.
39    Channels(ChannelsArgs),
40    /// Join a thread channel (Discord only allows explicit joins on threads).
41    Join(JoinArgs),
42    /// Leave a thread channel.
43    Leave(LeaveArgs),
44    /// Best-effort walk of the bot's visible guilds, channels, and
45    /// members, writing a name -> snowflake map to this project's
46    /// `directory.toml`. Safe to re-run; preserves hand-authored entries.
47    Discover(DiscoverArgs),
48    /// Inspect or hand-edit the name -> snowflake directory.
49    Directory(DirectoryArgs),
50    /// Inspect, scaffold, or dry-run the permissions policy that narrows
51    /// what this service may actually do.
52    Permissions(PermissionsArgs),
53    /// Manage the Discord user ID resolved from the literal `@me` in
54    /// `--dm` targets. Show, set (with API validation), or clear.
55    #[command(name = "self")]
56    SelfCmd(SelfArgs),
57}
58
59pub async fn run(args: DiscordArgs) -> Result<()> {
60    let action = args
61        .action
62        .ok_or_else(|| ZadError::Invalid("missing subcommand. Run `zad discord --help`.".into()))?;
63    match action {
64        Action::Send(a) => run_send(a).await,
65        Action::Read(a) => run_read(a).await,
66        Action::Channels(a) => run_channels(a).await,
67        Action::Join(a) => run_join(a).await,
68        Action::Leave(a) => run_leave(a).await,
69        Action::Discover(a) => run_discover(a).await,
70        Action::Directory(a) => run_directory(a),
71        Action::Permissions(a) => run_permissions(a),
72        Action::SelfCmd(a) => run_self(a).await,
73    }
74}
75
76// ---------------------------------------------------------------------------
77// send
78// ---------------------------------------------------------------------------
79
80#[derive(Debug, Args)]
81pub struct SendArgs {
82    /// Destination channel ID (snowflake). Mutually exclusive with `--dm`.
83    #[arg(long, conflicts_with = "dm")]
84    pub channel: Option<String>,
85
86    /// Destination user ID (snowflake) for a direct message. Mutually
87    /// exclusive with `--channel`.
88    #[arg(long, conflicts_with = "channel")]
89    pub dm: Option<String>,
90
91    /// Read the message body from standard input instead of the positional
92    /// argument.
93    #[arg(long, conflicts_with = "body")]
94    pub stdin: bool,
95
96    /// Attach a file to the message. Repeat up to Discord's per-message
97    /// cap of 10 to attach multiple files. When at least one `--file` is
98    /// given the message body may be empty.
99    #[arg(long = "file", value_name = "PATH", action = clap::ArgAction::Append)]
100    pub files: Vec<PathBuf>,
101
102    /// Message body. Required unless `--stdin` is set or at least one
103    /// `--file` is attached.
104    pub body: Option<String>,
105
106    /// Emit machine-readable JSON instead of human-readable text.
107    #[arg(long)]
108    pub json: bool,
109
110    /// Preview the outgoing call without contacting Discord. Scope and
111    /// permission checks still run; no bot token is loaded. Prints what
112    /// would have been sent as JSON on stdout.
113    #[arg(long)]
114    pub dry_run: bool,
115}
116
117#[derive(Debug, Serialize)]
118struct SendOutput {
119    command: &'static str,
120    target: &'static str,
121    target_id: String,
122    message_id: String,
123}
124
125async fn run_send(args: SendArgs) -> Result<()> {
126    let (cfg, _scope) = effective_config()?;
127    let directory = dir::load().unwrap_or_default();
128    let context_guild = default_guild_name(&cfg, &directory);
129    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
130    permissions.check_time(DiscordFunction::Send)?;
131    let target = match (&args.channel, &args.dm) {
132        (Some(c), None) => {
133            let id = resolve_channel(c, &directory, context_guild.as_deref())?;
134            permissions.check_send_channel(c, id, &directory)?;
135            Target::Channel(ChannelId(id))
136        }
137        (None, Some(u)) => {
138            let id = resolve_user_or_self(u, cfg.self_user_id.as_deref(), &directory)?;
139            permissions.check_send_dm(u, id, &directory)?;
140            Target::Dm(UserId(id))
141        }
142        (None, None) => {
143            return Err(ZadError::Invalid(
144                "missing destination: pass --channel <ID> or --dm <USER_ID>".into(),
145            ));
146        }
147        (Some(_), Some(_)) => unreachable!("clap enforces mutual exclusion"),
148    };
149
150    let body = if args.files.is_empty() {
151        resolve_body(args.body.as_deref(), args.stdin)?
152    } else {
153        resolve_body_or_empty(args.body.as_deref(), args.stdin)?
154    };
155    let len = body.chars().count();
156    if len > zad::service::discord::client::DISCORD_MAX_MESSAGE_LEN {
157        return Err(ZadError::Invalid(format!(
158            "message body is {len} characters; Discord's hard limit is {}",
159            zad::service::discord::client::DISCORD_MAX_MESSAGE_LEN
160        )));
161    }
162    if args.files.len() > zad::service::discord::client::DISCORD_MAX_ATTACHMENTS {
163        return Err(ZadError::Invalid(format!(
164            "{} attachments is above Discord's per-message cap of {}",
165            args.files.len(),
166            zad::service::discord::client::DISCORD_MAX_ATTACHMENTS
167        )));
168    }
169    permissions.check_send_body(&body)?;
170
171    let infos: Vec<AttachmentInfo> = args
172        .files
173        .iter()
174        .map(|p| {
175            AttachmentInfo::probe(p).map_err(|e| {
176                ZadError::Invalid(format!("attachment `{}` not readable: {e}", p.display()))
177            })
178        })
179        .collect::<Result<_>>()?;
180    permissions.check_send_attachments(&infos)?;
181
182    let http = discord_http_for("messages.send", args.dry_run)?;
183    let msg_id = http.send(target.clone(), &body, &args.files).await?;
184
185    // When --dry-run is active the transport already emitted a preview
186    // record (human summary via `tracing::info!`, JSON payload on
187    // stdout). Skip the trailing "Sent …" / SendOutput print so we
188    // never claim success for an operation we didn't actually perform.
189    if args.dry_run {
190        return Ok(());
191    }
192    if crate::cli::echo::echo_active() {
193        crate::cli::echo::render_and_clear(args.json);
194        return Ok(());
195    }
196
197    let (kind, tid) = match &target {
198        Target::Channel(ChannelId(id)) => ("channel", id.to_string()),
199        Target::Dm(UserId(id)) => ("dm", id.to_string()),
200    };
201
202    if args.json {
203        let out = SendOutput {
204            command: "discord.send",
205            target: kind,
206            target_id: tid,
207            message_id: msg_id.0.to_string(),
208        };
209        println!("{}", serde_json::to_string_pretty(&out).unwrap());
210    } else {
211        println!("Sent message {} to {kind} {tid}.", msg_id.0);
212    }
213    Ok(())
214}
215
216// ---------------------------------------------------------------------------
217// read
218// ---------------------------------------------------------------------------
219
220#[derive(Debug, Args)]
221pub struct ReadArgs {
222    /// Channel ID (snowflake) to read from.
223    #[arg(long)]
224    pub channel: String,
225
226    /// Maximum number of messages to fetch (1–100). Defaults to 20.
227    #[arg(long, default_value_t = 20)]
228    pub limit: usize,
229
230    /// Emit machine-readable JSON instead of human-readable text.
231    #[arg(long)]
232    pub json: bool,
233}
234
235#[derive(Debug, Serialize)]
236struct ReadOutput {
237    command: &'static str,
238    channel: String,
239    count: usize,
240    messages: Vec<ReadMessage>,
241}
242
243#[derive(Debug, Serialize)]
244struct ReadMessage {
245    id: String,
246    author: String,
247    body: String,
248}
249
250async fn run_read(args: ReadArgs) -> Result<()> {
251    if args.limit == 0 || args.limit > 100 {
252        return Err(ZadError::Invalid(
253            "--limit must be between 1 and 100 (Discord API maximum)".into(),
254        ));
255    }
256    let (cfg, _scope) = effective_config()?;
257    let directory = dir::load().unwrap_or_default();
258    let context_guild = default_guild_name(&cfg, &directory);
259    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
260    permissions.check_time(DiscordFunction::Read)?;
261    let id = resolve_channel(&args.channel, &directory, context_guild.as_deref())?;
262    permissions.check_read_channel(&args.channel, id, &directory)?;
263    let channel_id = ChannelId(id);
264    let http = discord_http_for("messages.read", false)?;
265    let msgs = http.history(channel_id.clone(), args.limit).await?;
266
267    if crate::cli::echo::echo_active() {
268        crate::cli::echo::render_and_clear(args.json);
269        return Ok(());
270    }
271
272    if args.json {
273        let out = ReadOutput {
274            command: "discord.read",
275            channel: channel_id.0.to_string(),
276            count: msgs.len(),
277            messages: msgs
278                .iter()
279                .map(|m| ReadMessage {
280                    id: m.id.0.to_string(),
281                    author: m.author.0.to_string(),
282                    body: m.body.clone(),
283                })
284                .collect(),
285        };
286        println!("{}", serde_json::to_string_pretty(&out).unwrap());
287        return Ok(());
288    }
289
290    if msgs.is_empty() {
291        println!("(no messages)");
292        return Ok(());
293    }
294    // Discord returns newest-first; print oldest-first so a human reads
295    // top-to-bottom in chronological order.
296    for m in msgs.iter().rev() {
297        println!("[{}] <{}> {}", m.id.0, m.author.0, m.body);
298    }
299    Ok(())
300}
301
302// ---------------------------------------------------------------------------
303// channels
304// ---------------------------------------------------------------------------
305
306#[derive(Debug, Args)]
307pub struct ChannelsArgs {
308    /// Guild (server) ID. Defaults to the configured `default_guild` if
309    /// unset.
310    #[arg(long)]
311    pub guild: Option<String>,
312
313    /// Emit machine-readable JSON instead of human-readable text.
314    #[arg(long)]
315    pub json: bool,
316}
317
318#[derive(Debug, Serialize)]
319struct ChannelsOutput {
320    command: &'static str,
321    guild: String,
322    count: usize,
323    channels: Vec<ChannelRow>,
324}
325
326#[derive(Debug, Serialize)]
327struct ChannelRow {
328    id: String,
329    name: String,
330    kind: String,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    parent: Option<String>,
333    position: u16,
334}
335
336async fn run_channels(args: ChannelsArgs) -> Result<()> {
337    let (cfg, _scope) = effective_config()?;
338    let directory = dir::load().unwrap_or_default();
339    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
340    permissions.check_time(DiscordFunction::Channels)?;
341    let guild = resolve_guild_arg(
342        args.guild.as_deref(),
343        cfg.default_guild.as_deref(),
344        &directory,
345    )?;
346    let guild_input = args
347        .guild
348        .clone()
349        .or_else(|| cfg.default_guild.clone())
350        .unwrap_or_else(|| guild.to_string());
351    permissions.check_channels_guild(&guild_input, guild, &directory)?;
352    let http = discord_http_for("guilds", false)?;
353    let channels = http.list_channels(guild).await?;
354
355    if crate::cli::echo::echo_active() {
356        crate::cli::echo::render_and_clear(args.json);
357        return Ok(());
358    }
359
360    if args.json {
361        let rows: Vec<ChannelRow> = channels
362            .iter()
363            .map(|c| ChannelRow {
364                id: c.id.0.to_string(),
365                name: c.name.clone(),
366                kind: c.kind.clone(),
367                parent: c.parent.as_ref().map(|p| p.0.to_string()),
368                position: c.position,
369            })
370            .collect();
371        let out = ChannelsOutput {
372            command: "discord.channels",
373            guild: guild.to_string(),
374            count: rows.len(),
375            channels: rows,
376        };
377        println!("{}", serde_json::to_string_pretty(&out).unwrap());
378        return Ok(());
379    }
380
381    if channels.is_empty() {
382        println!("(no channels in guild {guild})");
383        return Ok(());
384    }
385    println!("{:<20}  {:<14}  NAME", "ID", "KIND");
386    for c in &channels {
387        println!("{:<20}  {:<14}  {}", c.id.0, c.kind, c.name);
388    }
389    Ok(())
390}
391
392// ---------------------------------------------------------------------------
393// join / leave (thread members)
394// ---------------------------------------------------------------------------
395
396#[derive(Debug, Args)]
397pub struct JoinArgs {
398    /// Channel ID (snowflake). Must refer to a thread channel.
399    #[arg(long)]
400    pub channel: String,
401
402    /// Emit machine-readable JSON instead of human-readable text.
403    #[arg(long)]
404    pub json: bool,
405
406    /// Preview the outgoing call without contacting Discord. Scope and
407    /// permission checks still run; no bot token is loaded.
408    #[arg(long)]
409    pub dry_run: bool,
410}
411
412#[derive(Debug, Args)]
413pub struct LeaveArgs {
414    /// Channel ID (snowflake). Must refer to a thread channel.
415    #[arg(long)]
416    pub channel: String,
417
418    /// Emit machine-readable JSON instead of human-readable text.
419    #[arg(long)]
420    pub json: bool,
421
422    /// Preview the outgoing call without contacting Discord. Scope and
423    /// permission checks still run; no bot token is loaded.
424    #[arg(long)]
425    pub dry_run: bool,
426}
427
428#[derive(Debug, Serialize)]
429struct MembershipOutput {
430    command: &'static str,
431    channel: String,
432}
433
434async fn run_join(args: JoinArgs) -> Result<()> {
435    let (cfg, _scope) = effective_config()?;
436    let directory = dir::load().unwrap_or_default();
437    let context_guild = default_guild_name(&cfg, &directory);
438    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
439    permissions.check_time(DiscordFunction::Join)?;
440    let id = resolve_channel(&args.channel, &directory, context_guild.as_deref())?;
441    permissions.check_join_channel(&args.channel, id, &directory)?;
442    let channel = ChannelId(id);
443    let http = discord_http_for("guilds", args.dry_run)?;
444    http.join_channel(channel.clone()).await?;
445    if args.dry_run {
446        return Ok(());
447    }
448    if crate::cli::echo::echo_active() {
449        crate::cli::echo::render_and_clear(args.json);
450        return Ok(());
451    }
452    if args.json {
453        let out = MembershipOutput {
454            command: "discord.join",
455            channel: channel.0.to_string(),
456        };
457        println!("{}", serde_json::to_string_pretty(&out).unwrap());
458    } else {
459        println!("Joined channel {}.", channel.0);
460    }
461    Ok(())
462}
463
464async fn run_leave(args: LeaveArgs) -> Result<()> {
465    let (cfg, _scope) = effective_config()?;
466    let directory = dir::load().unwrap_or_default();
467    let context_guild = default_guild_name(&cfg, &directory);
468    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
469    permissions.check_time(DiscordFunction::Leave)?;
470    let id = resolve_channel(&args.channel, &directory, context_guild.as_deref())?;
471    permissions.check_leave_channel(&args.channel, id, &directory)?;
472    let channel = ChannelId(id);
473    let http = discord_http_for("guilds", args.dry_run)?;
474    http.leave_channel(channel.clone()).await?;
475    if args.dry_run {
476        return Ok(());
477    }
478    if crate::cli::echo::echo_active() {
479        crate::cli::echo::render_and_clear(args.json);
480        return Ok(());
481    }
482    if args.json {
483        let out = MembershipOutput {
484            command: "discord.leave",
485            channel: channel.0.to_string(),
486        };
487        println!("{}", serde_json::to_string_pretty(&out).unwrap());
488    } else {
489        println!("Left channel {}.", channel.0);
490    }
491    Ok(())
492}
493
494// ---------------------------------------------------------------------------
495// credential / config plumbing
496// ---------------------------------------------------------------------------
497
498enum EffectiveScope {
499    Global,
500    Local(String),
501}
502
503fn effective_config() -> Result<(DiscordServiceCfg, EffectiveScope)> {
504    let project_path = config::path::project_config_path()?;
505    let project_cfg = config::load_from(&project_path)?;
506    if !project_cfg.has_service("discord") {
507        return Err(ZadError::Invalid(format!(
508            "discord is not enabled for this project ({}). \
509             Run `zad service enable discord` first.",
510            project_path.display()
511        )));
512    }
513
514    let slug = config::path::project_slug()?;
515    let local_path = config::path::project_service_config_path_for(&slug, "discord")?;
516    if let Some(cfg) = config::load_flat::<DiscordServiceCfg>(&local_path)? {
517        return Ok((cfg, EffectiveScope::Local(slug)));
518    }
519    let global_path = config::path::global_service_config_path("discord")?;
520    if let Some(cfg) = config::load_flat::<DiscordServiceCfg>(&global_path)? {
521        return Ok((cfg, EffectiveScope::Global));
522    }
523    Err(ZadError::Invalid(format!(
524        "no Discord credentials found for this project.\n\
525         looked in:\n  {}\n  {}",
526        local_path.display(),
527        global_path.display()
528    )))
529}
530
531fn load_token(scope: &EffectiveScope) -> Result<String> {
532    let account = match scope {
533        EffectiveScope::Global => secrets::account("discord", "bot", Scope::Global),
534        EffectiveScope::Local(slug) => secrets::account("discord", "bot", Scope::Project(slug)),
535    };
536    secrets::load(&account)?.ok_or_else(|| {
537        ZadError::Invalid(format!(
538            "bot token missing from keychain (account `{account}`). \
539             Re-run `zad service create discord` to reinstall it."
540        ))
541    })
542}
543
544/// Resolve config + token + scope set into a ready-to-call client, and
545/// fail fast with [`ZadError::ScopeDenied`] if `required` isn't declared.
546/// The fail-fast scope check happens *before* the keychain read so a
547/// denied op never touches secrets; [`DiscordHttp`] still guards the
548/// same scope internally, which covers library callers (`DiscordService`)
549/// that bypass this helper.
550///
551/// When `dry_run` is `true` the scope check still runs (so preview
552/// respects the caller's policy boundary), but the keychain read is
553/// skipped and a [`DryRunDiscordTransport`] is returned instead of a
554/// live client. That lets `--dry-run` work before the operator has
555/// configured a bot, and guarantees no token is ever loaded into memory
556/// for a preview.
557fn discord_http_for(required: &'static str, dry_run: bool) -> Result<Box<dyn DiscordTransport>> {
558    let (cfg, scope) = effective_config()?;
559    let config_path = match &scope {
560        EffectiveScope::Local(slug) => {
561            config::path::project_service_config_path_for(slug, "discord")?
562        }
563        EffectiveScope::Global => config::path::global_service_config_path("discord")?,
564    };
565    let scopes: std::collections::BTreeSet<String> = cfg.scopes.iter().cloned().collect();
566    if !scopes.contains(required) {
567        return Err(ZadError::ScopeDenied {
568            service: "discord",
569            scope: required,
570            config_path,
571        });
572    }
573    if dry_run || crate::cli::echo::echo_active() {
574        let sink = if crate::cli::echo::echo_active() {
575            crate::cli::echo::dry_run_sink_for_echo()
576        } else {
577            default_dry_run_sink()
578        };
579        return Ok(Box::new(DryRunDiscordTransport::new(sink)));
580    }
581    let token = load_token(&scope)?;
582    Ok(Box::new(DiscordHttp::new(&token, scopes, config_path)))
583}
584
585fn resolve_guild_arg(
586    flag: Option<&str>,
587    default: Option<&str>,
588    directory: &Directory,
589) -> Result<u64> {
590    let raw = flag.or(default).ok_or_else(|| {
591        ZadError::Invalid(
592            "no guild specified: pass --guild <ID|name> or set `default_guild` in the config"
593                .into(),
594        )
595    })?;
596    directory.resolve_guild(raw).ok_or_else(|| {
597        ZadError::Invalid(format!(
598            "--guild `{raw}` is neither a numeric snowflake nor a known directory entry. \
599             Run `zad discord discover` or map it manually with \
600             `zad discord directory set guild {raw} <id>`."
601        ))
602    })
603}
604
605fn resolve_channel(input: &str, directory: &Directory, context_guild: Option<&str>) -> Result<u64> {
606    directory
607        .resolve_channel(input, context_guild)
608        .ok_or_else(|| {
609            let key = input.strip_prefix('#').unwrap_or(input);
610            ZadError::Invalid(format!(
611                "--channel `{input}` is neither a numeric snowflake nor a known directory entry. \
612             Run `zad discord discover` or map it manually with \
613             `zad discord directory set channel {key} <id>`."
614            ))
615        })
616}
617
618fn resolve_user(input: &str, directory: &Directory) -> Result<u64> {
619    directory.resolve_user(input).ok_or_else(|| {
620        let key = input.strip_prefix('@').unwrap_or(input);
621        ZadError::Invalid(format!(
622            "--dm `{input}` is neither a numeric snowflake nor a known directory entry. \
623             Run `zad discord discover` or map it manually with \
624             `zad discord directory set user {key} <id>`."
625        ))
626    })
627}
628
629fn resolve_user_or_self(
630    input: &str,
631    self_user_id: Option<&str>,
632    directory: &Directory,
633) -> Result<u64> {
634    if input.eq_ignore_ascii_case("@me") {
635        return match self_user_id {
636            Some(id) => id.parse::<u64>().map_err(|_| {
637                ZadError::Invalid(format!(
638                    "stored self-user id `{id}` is not a numeric snowflake; \
639                     run `zad discord self set <id>` with a valid id"
640                ))
641            }),
642            None => Err(ZadError::Invalid(
643                "`@me` has no self-user configured. Run \
644                 `zad discord self set <id>` with your Discord user ID \
645                 (Settings → Advanced → Developer Mode → right-click \
646                 yourself → \"Copy User ID\")."
647                    .into(),
648            )),
649        };
650    }
651    resolve_user(input, directory)
652}
653
654fn parse_snowflake(v: &str, field: &'static str) -> Result<u64> {
655    v.parse::<u64>().map_err(|_| {
656        ZadError::Invalid(format!(
657            "{field} must be a numeric Discord snowflake, got `{v}`"
658        ))
659    })
660}
661
662fn default_guild_name(cfg: &DiscordServiceCfg, directory: &Directory) -> Option<String> {
663    let raw = cfg.default_guild.as_deref()?;
664    if let Ok(id) = raw.parse::<u64>() {
665        return directory.guild_name_for(id).map(str::to_owned);
666    }
667    if directory.guilds.contains_key(raw) {
668        return Some(raw.to_owned());
669    }
670    None
671}
672
673// ---------------------------------------------------------------------------
674// discover
675// ---------------------------------------------------------------------------
676
677#[derive(Debug, Args)]
678pub struct DiscoverArgs {
679    /// Scope discovery to a single guild (by ID or known name). Without
680    /// this flag, every guild the bot can see is walked.
681    #[arg(long)]
682    pub guild: Option<String>,
683
684    /// Skip the member-listing phase. Use this when the bot doesn't have
685    /// the privileged `GUILD_MEMBERS` intent enabled and you want to
686    /// suppress the warning it would otherwise emit.
687    #[arg(long)]
688    pub skip_members: bool,
689
690    /// Emit machine-readable JSON instead of a human-readable summary.
691    #[arg(long)]
692    pub json: bool,
693}
694
695#[derive(Debug, Serialize)]
696struct DiscoverOutput {
697    command: &'static str,
698    guilds: usize,
699    channels: usize,
700    users: usize,
701    warnings: Vec<String>,
702}
703
704async fn run_discover(args: DiscoverArgs) -> Result<()> {
705    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
706    permissions.check_time(DiscordFunction::Discover)?;
707    // Discover walks the directory by issuing many read calls and writes
708    // to disk afterwards. None of that should happen against a file we
709    // can't trust, so bail out early with the echo envelope before the
710    // first network call.
711    if crate::cli::echo::echo_active() {
712        crate::cli::echo::render_and_clear(args.json);
713        return Ok(());
714    }
715    let http = discord_http_for("guilds", false)?;
716    let mut directory = dir::load().unwrap_or_default();
717    let mut warnings: Vec<String> = vec![];
718
719    let guilds = match http.list_guilds().await {
720        Ok(g) => g,
721        Err(e) => {
722            warnings.push(format!("list guilds: {e}"));
723            vec![]
724        }
725    };
726
727    let scoped: Option<u64> = args
728        .guild
729        .as_deref()
730        .map(|raw| -> Result<u64> {
731            directory.resolve_guild(raw).ok_or_else(|| {
732                ZadError::Invalid(format!(
733                    "--guild `{raw}` is not numeric and not in the directory; \
734                     run `zad discord discover` without --guild first, or pass an ID."
735                ))
736            })
737        })
738        .transpose()?;
739
740    // Filter the walk to guilds the operator actually allowed discovery
741    // into. `guilds.allow`/`guilds.deny` for the `discover` block narrows
742    // the walk; silently skipping a denied guild is the right shape
743    // because `discover` is already best-effort.
744    let targets: Vec<_> = match scoped {
745        Some(id) => guilds.iter().filter(|g| g.id == id).cloned().collect(),
746        None => guilds
747            .iter()
748            .filter(|g| {
749                permissions
750                    .check_discover_guild(&g.name, g.id, &directory)
751                    .is_ok()
752            })
753            .cloned()
754            .collect(),
755    };
756    if let Some(id) = scoped
757        && let Some(g) = guilds.iter().find(|g| g.id == id)
758    {
759        permissions.check_discover_guild(&g.name, g.id, &directory)?;
760    }
761
762    for g in &guilds {
763        directory.guilds.insert(g.name.clone(), g.id.to_string());
764    }
765
766    for g in &targets {
767        match http.list_channels(g.id).await {
768            Ok(chans) => {
769                for c in chans {
770                    let qualified = format!("{}/{}", g.name, c.name);
771                    directory.channels.insert(qualified, c.id.0.to_string());
772                    // Bare-name convenience key. If multiple guilds share
773                    // a channel name (e.g. `general`), the last one
774                    // written wins; the qualified key always
775                    // disambiguates when the caller needs it to.
776                    directory.channels.insert(c.name, c.id.0.to_string());
777                }
778            }
779            Err(e) => warnings.push(format!("channels for `{}`: {e}", g.name)),
780        }
781
782        if args.skip_members {
783            continue;
784        }
785        match http.list_members(g.id, 1000).await {
786            Ok(members) => {
787                for m in members {
788                    directory
789                        .users
790                        .insert(m.display_name.clone(), m.id.0.to_string());
791                }
792            }
793            Err(e) => warnings.push(format!(
794                "members for `{}` (needs GUILD_MEMBERS privileged intent): {e}",
795                g.name
796            )),
797        }
798    }
799
800    directory.generated_at_unix = Some(
801        SystemTime::now()
802            .duration_since(UNIX_EPOCH)
803            .map(|d| d.as_secs())
804            .unwrap_or(0),
805    );
806    dir::save(&directory)?;
807
808    let guilds_n = directory.guilds.len();
809    let channels_n = directory.channels.len();
810    let users_n = directory.users.len();
811
812    if args.json {
813        let out = DiscoverOutput {
814            command: "discord.discover",
815            guilds: guilds_n,
816            channels: channels_n,
817            users: users_n,
818            warnings: warnings.clone(),
819        };
820        println!("{}", serde_json::to_string_pretty(&out).unwrap());
821    } else {
822        println!(
823            "Wrote directory: {guilds_n} guilds, {channels_n} channel entries, {users_n} users."
824        );
825        for w in &warnings {
826            crate::output::warn(w);
827        }
828    }
829    Ok(())
830}
831
832// ---------------------------------------------------------------------------
833// directory
834// ---------------------------------------------------------------------------
835
836#[derive(Debug, Args)]
837pub struct DirectoryArgs {
838    #[command(subcommand)]
839    pub action: Option<DirectoryAction>,
840
841    /// When no subcommand is given, print the directory as JSON.
842    #[arg(long)]
843    pub json: bool,
844}
845
846#[derive(Debug, Subcommand)]
847pub enum DirectoryAction {
848    /// Upsert a name -> snowflake mapping. `<kind>` is one of
849    /// `guild`, `channel`, or `user`. Channel keys may include a
850    /// `guild/channel` qualifier.
851    Set(DirectorySetArgs),
852    /// Remove a single mapping. Silent no-op if the key is absent.
853    Remove(DirectoryRemoveArgs),
854    /// Wipe every entry. Use with `--force`.
855    Clear(DirectoryClearArgs),
856}
857
858#[derive(Debug, Args)]
859pub struct DirectorySetArgs {
860    /// One of `guild`, `channel`, `user`.
861    pub kind: DirectoryKind,
862    /// Human-readable name to map from.
863    pub name: String,
864    /// Numeric snowflake to map to.
865    pub id: String,
866    #[arg(long)]
867    pub json: bool,
868}
869
870#[derive(Debug, Args)]
871pub struct DirectoryRemoveArgs {
872    pub kind: DirectoryKind,
873    pub name: String,
874    #[arg(long)]
875    pub json: bool,
876}
877
878#[derive(Debug, Args)]
879pub struct DirectoryClearArgs {
880    #[arg(long)]
881    pub force: bool,
882    #[arg(long)]
883    pub json: bool,
884}
885
886#[derive(Debug, Clone, Copy, clap::ValueEnum)]
887pub enum DirectoryKind {
888    Guild,
889    Channel,
890    User,
891}
892
893#[derive(Debug, Serialize)]
894struct DirectoryOutput<'a> {
895    command: &'static str,
896    path: String,
897    generated_at_unix: Option<u64>,
898    guilds: &'a std::collections::BTreeMap<String, String>,
899    channels: &'a std::collections::BTreeMap<String, String>,
900    users: &'a std::collections::BTreeMap<String, String>,
901}
902
903#[derive(Debug, Serialize)]
904struct DirectoryMutation {
905    command: &'static str,
906    kind: &'static str,
907    name: String,
908    id: Option<String>,
909    removed: bool,
910}
911
912fn require_discord_enabled() -> Result<()> {
913    let project_path = config::path::project_config_path()?;
914    let project_cfg = config::load_from(&project_path)?;
915    if !project_cfg.has_service("discord") {
916        return Err(ZadError::Invalid(format!(
917            "discord is not enabled for this project ({}). \
918             Run `zad service enable discord` first.",
919            project_path.display()
920        )));
921    }
922    Ok(())
923}
924
925fn kind_as_str(k: DirectoryKind) -> &'static str {
926    match k {
927        DirectoryKind::Guild => "guild",
928        DirectoryKind::Channel => "channel",
929        DirectoryKind::User => "user",
930    }
931}
932
933fn run_directory(args: DirectoryArgs) -> Result<()> {
934    require_discord_enabled()?;
935    match args.action {
936        None => run_directory_list(args.json),
937        Some(DirectoryAction::Set(a)) => run_directory_set(a),
938        Some(DirectoryAction::Remove(a)) => run_directory_remove(a),
939        Some(DirectoryAction::Clear(a)) => run_directory_clear(a),
940    }
941}
942
943fn run_directory_list(json: bool) -> Result<()> {
944    let path = dir::path_current()?;
945    let directory = dir::load_from(&path)?;
946    if json {
947        let out = DirectoryOutput {
948            command: "discord.directory",
949            path: path.display().to_string(),
950            generated_at_unix: directory.generated_at_unix,
951            guilds: &directory.guilds,
952            channels: &directory.channels,
953            users: &directory.users,
954        };
955        println!("{}", serde_json::to_string_pretty(&out).unwrap());
956        return Ok(());
957    }
958    if directory.total() == 0 {
959        println!("(empty) {}", path.display());
960        println!("Run `zad discord discover` to populate it.");
961        return Ok(());
962    }
963    println!("# {}", path.display());
964    if !directory.guilds.is_empty() {
965        println!("\n[guilds]");
966        for (n, id) in &directory.guilds {
967            println!("  {n:<24}  {id}");
968        }
969    }
970    if !directory.channels.is_empty() {
971        println!("\n[channels]");
972        for (n, id) in &directory.channels {
973            println!("  {n:<40}  {id}");
974        }
975    }
976    if !directory.users.is_empty() {
977        println!("\n[users]");
978        for (n, id) in &directory.users {
979            println!("  {n:<24}  {id}");
980        }
981    }
982    Ok(())
983}
984
985fn run_directory_set(args: DirectorySetArgs) -> Result<()> {
986    let id = parse_snowflake(&args.id, "<id>")?;
987    let path = dir::path_current()?;
988    let mut directory = dir::load_from(&path)?;
989    let bucket = match args.kind {
990        DirectoryKind::Guild => &mut directory.guilds,
991        DirectoryKind::Channel => &mut directory.channels,
992        DirectoryKind::User => &mut directory.users,
993    };
994    bucket.insert(args.name.clone(), id.to_string());
995    dir::save_to(&path, &directory)?;
996
997    if args.json {
998        let out = DirectoryMutation {
999            command: "discord.directory.set",
1000            kind: kind_as_str(args.kind),
1001            name: args.name,
1002            id: Some(id.to_string()),
1003            removed: false,
1004        };
1005        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1006    } else {
1007        println!(
1008            "Mapped {} `{}` -> {id} in {}.",
1009            kind_as_str(args.kind),
1010            args.name,
1011            path.display()
1012        );
1013    }
1014    Ok(())
1015}
1016
1017fn run_directory_remove(args: DirectoryRemoveArgs) -> Result<()> {
1018    let path = dir::path_current()?;
1019    let mut directory = dir::load_from(&path)?;
1020    let bucket = match args.kind {
1021        DirectoryKind::Guild => &mut directory.guilds,
1022        DirectoryKind::Channel => &mut directory.channels,
1023        DirectoryKind::User => &mut directory.users,
1024    };
1025    let removed = bucket.remove(&args.name).is_some();
1026    if removed {
1027        dir::save_to(&path, &directory)?;
1028    }
1029
1030    if args.json {
1031        let out = DirectoryMutation {
1032            command: "discord.directory.remove",
1033            kind: kind_as_str(args.kind),
1034            name: args.name,
1035            id: None,
1036            removed,
1037        };
1038        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1039    } else if removed {
1040        println!(
1041            "Removed {} `{}` from {}.",
1042            kind_as_str(args.kind),
1043            args.name,
1044            path.display()
1045        );
1046    } else {
1047        println!("No {} entry named `{}`.", kind_as_str(args.kind), args.name);
1048    }
1049    Ok(())
1050}
1051
1052fn run_directory_clear(args: DirectoryClearArgs) -> Result<()> {
1053    if !args.force {
1054        return Err(ZadError::Invalid(
1055            "refusing to clear the directory without --force".into(),
1056        ));
1057    }
1058    let path = dir::path_current()?;
1059    let directory = Directory::default();
1060    dir::save_to(&path, &directory)?;
1061    if args.json {
1062        println!(
1063            "{}",
1064            serde_json::to_string_pretty(&serde_json::json!({
1065                "command": "discord.directory.clear",
1066                "path": path.display().to_string(),
1067            }))
1068            .unwrap()
1069        );
1070    } else {
1071        println!("Cleared {}.", path.display());
1072    }
1073    Ok(())
1074}
1075
1076// ---------------------------------------------------------------------------
1077// permissions — inspect / scaffold / dry-run the permissions policy
1078// ---------------------------------------------------------------------------
1079
1080#[derive(Debug, Args)]
1081pub struct PermissionsArgs {
1082    #[command(subcommand)]
1083    pub action: Option<PermissionsAction>,
1084
1085    /// When no subcommand is given, behave like `show`.
1086    #[arg(long)]
1087    pub json: bool,
1088}
1089
1090#[derive(Debug, Subcommand)]
1091pub enum PermissionsAction {
1092    /// Print the effective policy (global + local) for this project.
1093    Show(PermissionsShowArgs),
1094    /// Write a starter `permissions.toml` at the selected scope.
1095    Init(PermissionsInitArgs),
1096    /// Print the paths considered for this project, in precedence order.
1097    Path(PermissionsPathArgs),
1098    /// Dry-run: ask whether a proposed action would be admitted *without*
1099    /// hitting Discord. Useful for agents that want to pre-flight.
1100    Check(PermissionsCheckArgs),
1101    /// Staged-commit workflow: queue mutations in a `.pending` file and
1102    /// only sign on `commit`. See [`cli::permissions`].
1103    #[command(flatten)]
1104    Staging(crate::cli::permissions::StagingAction),
1105}
1106
1107#[derive(Debug, Args)]
1108pub struct PermissionsShowArgs {
1109    #[arg(long)]
1110    pub json: bool,
1111}
1112
1113#[derive(Debug, Args)]
1114pub struct PermissionsInitArgs {
1115    /// Write to the project-local `permissions.toml`. Default is global.
1116    #[arg(long)]
1117    pub local: bool,
1118
1119    /// Overwrite any existing file at that scope.
1120    #[arg(long)]
1121    pub force: bool,
1122
1123    #[arg(long)]
1124    pub json: bool,
1125}
1126
1127#[derive(Debug, Args)]
1128pub struct PermissionsPathArgs {
1129    #[arg(long)]
1130    pub json: bool,
1131}
1132
1133#[derive(Debug, Args)]
1134pub struct PermissionsCheckArgs {
1135    /// Function to check: `send`, `read`, `channels`, `join`, `leave`,
1136    /// `discover`, `manage`.
1137    #[arg(long)]
1138    pub function: String,
1139
1140    /// Channel name or snowflake to test against the channel list for
1141    /// `send` / `read` / `join` / `leave`.
1142    #[arg(long, conflicts_with = "user")]
1143    pub channel: Option<String>,
1144
1145    /// User name or snowflake to test against the DM list for `send`.
1146    #[arg(long, conflicts_with = "channel")]
1147    pub user: Option<String>,
1148
1149    /// Guild name or snowflake to test against the guild list for
1150    /// `channels` / `discover`.
1151    #[arg(long)]
1152    pub guild: Option<String>,
1153
1154    /// Body to test against `content` rules (applies only to `send`).
1155    #[arg(long)]
1156    pub body: Option<String>,
1157
1158    #[arg(long)]
1159    pub json: bool,
1160}
1161
1162fn run_permissions(args: PermissionsArgs) -> Result<()> {
1163    match args.action {
1164        None => run_permissions_show(PermissionsShowArgs { json: args.json }),
1165        Some(PermissionsAction::Show(a)) => run_permissions_show(a),
1166        Some(PermissionsAction::Init(a)) => run_permissions_init(a),
1167        Some(PermissionsAction::Path(a)) => run_permissions_path(a),
1168        Some(PermissionsAction::Check(a)) => run_permissions_check(a),
1169        Some(PermissionsAction::Staging(a)) => {
1170            crate::cli::permissions::run::<perms::PermissionsService>(a)
1171        }
1172    }
1173}
1174
1175#[derive(Debug, Serialize)]
1176struct PermissionsShowOutput {
1177    command: &'static str,
1178    global: PermissionsScopeBlock,
1179    local: PermissionsScopeBlock,
1180}
1181
1182#[derive(Debug, Serialize)]
1183struct PermissionsScopeBlock {
1184    path: String,
1185    present: bool,
1186}
1187
1188fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
1189    let global_p = perms::global_path()?;
1190    let local_p = perms::local_path_current()?;
1191    let global_present = global_p.exists();
1192    let local_present = local_p.exists();
1193
1194    // Pre-load to surface any compile errors up front, before printing.
1195    let effective = perms::load_effective()?;
1196    let _ = effective;
1197
1198    if args.json {
1199        let out = PermissionsShowOutput {
1200            command: "discord.permissions.show",
1201            global: PermissionsScopeBlock {
1202                path: global_p.display().to_string(),
1203                present: global_present,
1204            },
1205            local: PermissionsScopeBlock {
1206                path: local_p.display().to_string(),
1207                present: local_present,
1208            },
1209        };
1210        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1211        return Ok(());
1212    }
1213
1214    println!("# permissions");
1215    println!(
1216        "  global : {} ({})",
1217        global_p.display(),
1218        if global_present {
1219            "present"
1220        } else {
1221            "not present (no restrictions at this scope)"
1222        }
1223    );
1224    println!(
1225        "  local  : {} ({})",
1226        local_p.display(),
1227        if local_present {
1228            "present"
1229        } else {
1230            "not present (no restrictions at this scope)"
1231        }
1232    );
1233    println!();
1234    if !global_present && !local_present {
1235        println!("No permission files found. Every declared scope is currently unrestricted.");
1236        println!("Run `zad discord permissions init` to scaffold a starter policy.");
1237        return Ok(());
1238    }
1239    for p in [&global_p, &local_p] {
1240        if !p.exists() {
1241            continue;
1242        }
1243        println!("## {}", p.display());
1244        match std::fs::read_to_string(p) {
1245            Ok(body) => {
1246                for line in body.lines() {
1247                    println!("  {line}");
1248                }
1249            }
1250            Err(e) => println!("  (failed to read: {e})"),
1251        }
1252        println!();
1253    }
1254    Ok(())
1255}
1256
1257#[derive(Debug, Serialize)]
1258struct PermissionsInitOutput {
1259    command: &'static str,
1260    scope: &'static str,
1261    path: String,
1262    written: bool,
1263}
1264
1265fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
1266    let (path, scope) = if args.local {
1267        (perms::local_path_current()?, "local")
1268    } else {
1269        (perms::global_path()?, "global")
1270    };
1271    if path.exists() && !args.force {
1272        return Err(ZadError::Invalid(format!(
1273            "permissions file already exists at {}. Pass --force to overwrite.",
1274            path.display()
1275        )));
1276    }
1277    let template = perms::starter_template();
1278    let key = zad::permissions::signing::load_or_create_from_keychain()?;
1279    zad::permissions::signing::write_public_key_cache(&key)?;
1280    perms::save_file(&path, &template, &key)?;
1281    if args.json {
1282        let out = PermissionsInitOutput {
1283            command: "discord.permissions.init",
1284            scope,
1285            path: path.display().to_string(),
1286            written: true,
1287        };
1288        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1289    } else {
1290        println!("Wrote starter permissions ({scope}): {}", path.display());
1291        println!("Signed with key {}.", key.fingerprint());
1292        println!("Review it; the defaults deny admin-like channels and channels.manage.");
1293    }
1294    Ok(())
1295}
1296
1297#[derive(Debug, Serialize)]
1298struct PermissionsPathOutput {
1299    command: &'static str,
1300    global: String,
1301    local: String,
1302}
1303
1304fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
1305    let global_p = perms::global_path()?;
1306    let local_p = perms::local_path_current()?;
1307    if args.json {
1308        let out = PermissionsPathOutput {
1309            command: "discord.permissions.path",
1310            global: global_p.display().to_string(),
1311            local: local_p.display().to_string(),
1312        };
1313        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1314    } else {
1315        println!("{}", global_p.display());
1316        println!("{}", local_p.display());
1317    }
1318    Ok(())
1319}
1320
1321#[derive(Debug, Serialize)]
1322struct PermissionsCheckOutput {
1323    command: &'static str,
1324    function: String,
1325    allowed: bool,
1326    #[serde(skip_serializing_if = "Option::is_none")]
1327    reason: Option<String>,
1328    #[serde(skip_serializing_if = "Option::is_none")]
1329    config_path: Option<String>,
1330}
1331
1332fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
1333    let function = parse_function(&args.function)?;
1334    let permissions = perms::load_effective()?;
1335    let directory = dir::load().unwrap_or_default();
1336
1337    let mut outcome: Result<()> = Ok(());
1338    outcome = outcome.and_then(|()| permissions.check_time(function));
1339
1340    if outcome.is_ok() {
1341        outcome = match (function, &args.channel, &args.user, &args.guild) {
1342            (DiscordFunction::Send, Some(c), None, _) => {
1343                let id = directory.resolve_channel(c, None).unwrap_or(0);
1344                permissions.check_send_channel(c, id, &directory)
1345            }
1346            (DiscordFunction::Send, None, Some(u), _) => {
1347                let id = directory.resolve_user(u).unwrap_or(0);
1348                permissions.check_send_dm(u, id, &directory)
1349            }
1350            (DiscordFunction::Read, Some(c), None, _) => {
1351                let id = directory.resolve_channel(c, None).unwrap_or(0);
1352                permissions.check_read_channel(c, id, &directory)
1353            }
1354            (DiscordFunction::Channels, _, _, Some(g)) => {
1355                let id = directory.resolve_guild(g).unwrap_or(0);
1356                permissions.check_channels_guild(g, id, &directory)
1357            }
1358            (DiscordFunction::Join, Some(c), None, _) => {
1359                let id = directory.resolve_channel(c, None).unwrap_or(0);
1360                permissions.check_join_channel(c, id, &directory)
1361            }
1362            (DiscordFunction::Leave, Some(c), None, _) => {
1363                let id = directory.resolve_channel(c, None).unwrap_or(0);
1364                permissions.check_leave_channel(c, id, &directory)
1365            }
1366            (DiscordFunction::Discover, _, _, Some(g)) => {
1367                let id = directory.resolve_guild(g).unwrap_or(0);
1368                permissions.check_discover_guild(g, id, &directory)
1369            }
1370            _ => Ok(()),
1371        };
1372    }
1373
1374    if outcome.is_ok()
1375        && function == DiscordFunction::Send
1376        && let Some(body) = &args.body
1377    {
1378        outcome = permissions.check_send_body(body);
1379    }
1380
1381    let (allowed, reason, config_path) = match outcome {
1382        Ok(()) => (true, None, None),
1383        Err(ZadError::PermissionDenied {
1384            reason,
1385            config_path,
1386            ..
1387        }) => (false, Some(reason), Some(config_path.display().to_string())),
1388        Err(e) => return Err(e),
1389    };
1390
1391    if args.json {
1392        let out = PermissionsCheckOutput {
1393            command: "discord.permissions.check",
1394            function: args.function.clone(),
1395            allowed,
1396            reason,
1397            config_path,
1398        };
1399        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1400    } else if allowed {
1401        println!("allow");
1402    } else {
1403        println!(
1404            "deny — {}",
1405            reason.as_deref().unwrap_or("unspecified reason")
1406        );
1407        if let Some(p) = &config_path {
1408            println!("  config: {p}");
1409        }
1410    }
1411    if !allowed {
1412        std::process::exit(1);
1413    }
1414    Ok(())
1415}
1416
1417fn parse_function(name: &str) -> Result<DiscordFunction> {
1418    match name {
1419        "send" => Ok(DiscordFunction::Send),
1420        "read" => Ok(DiscordFunction::Read),
1421        "channels" => Ok(DiscordFunction::Channels),
1422        "join" => Ok(DiscordFunction::Join),
1423        "leave" => Ok(DiscordFunction::Leave),
1424        "discover" => Ok(DiscordFunction::Discover),
1425        "manage" => Ok(DiscordFunction::Manage),
1426        other => Err(ZadError::Invalid(format!(
1427            "unknown function `{other}`. Expected one of: send, read, channels, join, leave, discover, manage."
1428        ))),
1429    }
1430}
1431
1432fn resolve_body(positional: Option<&str>, from_stdin: bool) -> Result<String> {
1433    resolve_body_inner(positional, from_stdin, false)
1434}
1435
1436/// Same as [`resolve_body`] but tolerates an empty result when the
1437/// caller has attachments to send alongside (Discord accepts an empty
1438/// `content` as long as at least one file is attached).
1439fn resolve_body_or_empty(positional: Option<&str>, from_stdin: bool) -> Result<String> {
1440    resolve_body_inner(positional, from_stdin, true)
1441}
1442
1443fn resolve_body_inner(
1444    positional: Option<&str>,
1445    from_stdin: bool,
1446    allow_empty: bool,
1447) -> Result<String> {
1448    if from_stdin {
1449        use std::io::Read;
1450        let mut buf = String::new();
1451        std::io::stdin().read_to_string(&mut buf).map_err(|e| {
1452            ZadError::Invalid(format!("failed to read message body from stdin: {e}"))
1453        })?;
1454        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
1455        if trimmed.is_empty() && !allow_empty {
1456            return Err(ZadError::Invalid("message body is empty (stdin)".into()));
1457        }
1458        return Ok(trimmed);
1459    }
1460    match positional {
1461        Some(b) if !b.is_empty() => Ok(b.to_string()),
1462        Some(_) if allow_empty => Ok(String::new()),
1463        None if allow_empty => Ok(String::new()),
1464        _ => Err(ZadError::Invalid(
1465            "missing message body: pass it as a positional arg, --stdin, or attach at least one --file".into(),
1466        )),
1467    }
1468}
1469
1470// ---------------------------------------------------------------------------
1471// self — manage the `@me` resolution target
1472// ---------------------------------------------------------------------------
1473
1474#[derive(Debug, Args)]
1475pub struct SelfArgs {
1476    #[command(subcommand)]
1477    pub action: Option<SelfAction>,
1478
1479    /// When no subcommand is given, behave like `show`.
1480    #[arg(long)]
1481    pub json: bool,
1482}
1483
1484#[derive(Debug, Subcommand)]
1485pub enum SelfAction {
1486    /// Print the stored self-user ID (or note that it's not set).
1487    Show(SelfShowArgs),
1488    /// Validate the supplied snowflake against Discord and store it.
1489    Set(SelfSetArgs),
1490    /// Clear the stored self-user ID.
1491    Clear(SelfClearArgs),
1492}
1493
1494#[derive(Debug, Args)]
1495pub struct SelfShowArgs {
1496    #[arg(long)]
1497    pub json: bool,
1498}
1499
1500#[derive(Debug, Args)]
1501pub struct SelfSetArgs {
1502    /// Your Discord user ID (numeric snowflake).
1503    pub user_id: String,
1504    #[arg(long)]
1505    pub json: bool,
1506}
1507
1508#[derive(Debug, Args)]
1509pub struct SelfClearArgs {
1510    #[arg(long)]
1511    pub json: bool,
1512}
1513
1514#[derive(Debug, Serialize)]
1515struct SelfOutput {
1516    command: &'static str,
1517    self_user_id: Option<String>,
1518}
1519
1520async fn run_self(args: SelfArgs) -> Result<()> {
1521    match args.action {
1522        None => run_self_show(SelfShowArgs { json: args.json }),
1523        Some(SelfAction::Show(a)) => run_self_show(a),
1524        Some(SelfAction::Set(a)) => run_self_set(a).await,
1525        Some(SelfAction::Clear(a)) => run_self_clear(a),
1526    }
1527}
1528
1529fn run_self_show(args: SelfShowArgs) -> Result<()> {
1530    let (cfg, _scope) = effective_config()?;
1531    emit_self(args.json, "discord.self.show", cfg.self_user_id)
1532}
1533
1534async fn run_self_set(args: SelfSetArgs) -> Result<()> {
1535    let (mut cfg, scope) = effective_config()?;
1536    let token = load_token(&scope)?;
1537    let resolved =
1538        crate::cli::service_discord::validate_self_user(&token, args.user_id.trim()).await?;
1539    cfg.self_user_id = Some(resolved);
1540    save_effective_config(&cfg, &scope)?;
1541    emit_self(args.json, "discord.self.set", cfg.self_user_id)
1542}
1543
1544fn run_self_clear(args: SelfClearArgs) -> Result<()> {
1545    let (mut cfg, scope) = effective_config()?;
1546    cfg.self_user_id = None;
1547    save_effective_config(&cfg, &scope)?;
1548    emit_self(args.json, "discord.self.clear", None)
1549}
1550
1551fn emit_self(json: bool, command: &'static str, self_user_id: Option<String>) -> Result<()> {
1552    if json {
1553        println!(
1554            "{}",
1555            serde_json::to_string_pretty(&SelfOutput {
1556                command,
1557                self_user_id
1558            })
1559            .unwrap()
1560        );
1561    } else {
1562        match self_user_id {
1563            Some(id) => println!("self user id: {id}"),
1564            None => println!("self user id: not configured"),
1565        }
1566    }
1567    Ok(())
1568}
1569
1570fn save_effective_config(cfg: &DiscordServiceCfg, scope: &EffectiveScope) -> Result<()> {
1571    let path = match scope {
1572        EffectiveScope::Local(slug) => {
1573            config::path::project_service_config_path_for(slug, "discord")?
1574        }
1575        EffectiveScope::Global => config::path::global_service_config_path("discord")?,
1576    };
1577    config::save_flat(&path, cfg)
1578}