1use 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#[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(SendArgs),
36 Read(ReadArgs),
38 Channels(ChannelsArgs),
40 Join(JoinArgs),
42 Leave(LeaveArgs),
44 Discover(DiscoverArgs),
48 Directory(DirectoryArgs),
50 Permissions(PermissionsArgs),
53 #[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#[derive(Debug, Args)]
81pub struct SendArgs {
82 #[arg(long, conflicts_with = "dm")]
84 pub channel: Option<String>,
85
86 #[arg(long, conflicts_with = "channel")]
89 pub dm: Option<String>,
90
91 #[arg(long, conflicts_with = "body")]
94 pub stdin: bool,
95
96 #[arg(long = "file", value_name = "PATH", action = clap::ArgAction::Append)]
100 pub files: Vec<PathBuf>,
101
102 pub body: Option<String>,
105
106 #[arg(long)]
108 pub json: bool,
109
110 #[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 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#[derive(Debug, Args)]
221pub struct ReadArgs {
222 #[arg(long)]
224 pub channel: String,
225
226 #[arg(long, default_value_t = 20)]
228 pub limit: usize,
229
230 #[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 for m in msgs.iter().rev() {
297 println!("[{}] <{}> {}", m.id.0, m.author.0, m.body);
298 }
299 Ok(())
300}
301
302#[derive(Debug, Args)]
307pub struct ChannelsArgs {
308 #[arg(long)]
311 pub guild: Option<String>,
312
313 #[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#[derive(Debug, Args)]
397pub struct JoinArgs {
398 #[arg(long)]
400 pub channel: String,
401
402 #[arg(long)]
404 pub json: bool,
405
406 #[arg(long)]
409 pub dry_run: bool,
410}
411
412#[derive(Debug, Args)]
413pub struct LeaveArgs {
414 #[arg(long)]
416 pub channel: String,
417
418 #[arg(long)]
420 pub json: bool,
421
422 #[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
494enum 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
544fn 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#[derive(Debug, Args)]
678pub struct DiscoverArgs {
679 #[arg(long)]
682 pub guild: Option<String>,
683
684 #[arg(long)]
688 pub skip_members: bool,
689
690 #[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 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 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 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#[derive(Debug, Args)]
837pub struct DirectoryArgs {
838 #[command(subcommand)]
839 pub action: Option<DirectoryAction>,
840
841 #[arg(long)]
843 pub json: bool,
844}
845
846#[derive(Debug, Subcommand)]
847pub enum DirectoryAction {
848 Set(DirectorySetArgs),
852 Remove(DirectoryRemoveArgs),
854 Clear(DirectoryClearArgs),
856}
857
858#[derive(Debug, Args)]
859pub struct DirectorySetArgs {
860 pub kind: DirectoryKind,
862 pub name: String,
864 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#[derive(Debug, Args)]
1081pub struct PermissionsArgs {
1082 #[command(subcommand)]
1083 pub action: Option<PermissionsAction>,
1084
1085 #[arg(long)]
1087 pub json: bool,
1088}
1089
1090#[derive(Debug, Subcommand)]
1091pub enum PermissionsAction {
1092 Show(PermissionsShowArgs),
1094 Init(PermissionsInitArgs),
1096 Path(PermissionsPathArgs),
1098 Check(PermissionsCheckArgs),
1101 #[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 #[arg(long)]
1117 pub local: bool,
1118
1119 #[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 #[arg(long)]
1138 pub function: String,
1139
1140 #[arg(long, conflicts_with = "user")]
1143 pub channel: Option<String>,
1144
1145 #[arg(long, conflicts_with = "channel")]
1147 pub user: Option<String>,
1148
1149 #[arg(long)]
1152 pub guild: Option<String>,
1153
1154 #[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 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
1436fn 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#[derive(Debug, Args)]
1475pub struct SelfArgs {
1476 #[command(subcommand)]
1477 pub action: Option<SelfAction>,
1478
1479 #[arg(long)]
1481 pub json: bool,
1482}
1483
1484#[derive(Debug, Subcommand)]
1485pub enum SelfAction {
1486 Show(SelfShowArgs),
1488 Set(SelfSetArgs),
1490 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 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}