1use std::time::{SystemTime, UNIX_EPOCH};
4
5use clap::{Args, Subcommand};
6use serde::Serialize;
7
8use zad::config::directory::{self as dir, Directory};
9use zad::config::{self, SlackServiceCfg};
10use zad::error::{Result, ZadError};
11use zad::secrets::{self, Scope};
12use zad::service::default_dry_run_sink;
13use zad::service::slack::permissions::{self as perms, SlackFunction};
14use zad::service::slack::{DryRunSlackTransport, SlackHttp, SlackTransport};
15
16#[derive(Debug, Args)]
21pub struct SlackArgs {
22 #[command(subcommand)]
23 pub action: Option<Action>,
24}
25
26#[derive(Debug, Subcommand)]
27pub enum Action {
28 Send(SendArgs),
30 Read(ReadArgs),
32 Channels(ChannelsArgs),
34 Discover(DiscoverArgs),
37 Directory(DirectoryArgs),
39 Permissions(PermissionsArgs),
41 #[command(name = "self")]
43 SelfCmd(SelfArgs),
44}
45
46pub async fn run(args: SlackArgs) -> Result<()> {
47 let action = args
48 .action
49 .ok_or_else(|| ZadError::Invalid("missing subcommand. Run `zad slack --help`.".into()))?;
50 match action {
51 Action::Send(a) => run_send(a).await,
52 Action::Read(a) => run_read(a).await,
53 Action::Channels(a) => run_channels(a).await,
54 Action::Discover(a) => run_discover(a).await,
55 Action::Directory(a) => run_directory(a),
56 Action::Permissions(a) => run_permissions(a),
57 Action::SelfCmd(a) => run_self(a),
58 }
59}
60
61#[derive(Debug, Args)]
66pub struct SendArgs {
67 #[arg(long, conflicts_with = "dm")]
69 pub channel: Option<String>,
70
71 #[arg(long, conflicts_with = "channel")]
74 pub dm: Option<String>,
75
76 #[arg(long, conflicts_with = "body")]
78 pub stdin: bool,
79
80 pub body: Option<String>,
82
83 #[arg(long)]
85 pub json: bool,
86
87 #[arg(long)]
90 pub dry_run: bool,
91}
92
93#[derive(Debug, Serialize)]
94struct SendOutput {
95 command: &'static str,
96 target: &'static str,
97 target_id: String,
98 ts: String,
99}
100
101async fn run_send(args: SendArgs) -> Result<()> {
102 let (cfg, _scope) = effective_config()?;
103 let directory = dir::load().unwrap_or_default();
104 let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
105 permissions.check_time(SlackFunction::Send)?;
106
107 let body = resolve_body(args.body.as_deref(), args.stdin)?;
108 permissions.check_send_body(&body)?;
109
110 enum SendTarget {
111 Channel(String),
112 Dm(String),
113 }
114
115 let target = match (&args.channel, &args.dm) {
116 (Some(c), None) => {
117 let id = resolve_channel(c, &cfg, &directory)?;
118 permissions.check_send_channel(&id, &directory)?;
119 SendTarget::Channel(id)
120 }
121 (None, Some(u)) => {
122 let id = resolve_user_or_self(u, cfg.self_user_id.as_deref(), &directory)?;
123 permissions.check_send_dm(&id, &directory)?;
124 SendTarget::Dm(id)
125 }
126 (None, None) => {
127 return Err(ZadError::Invalid(
128 "missing destination: pass --channel <ID|name> or --dm <USER_ID|name>".into(),
129 ));
130 }
131 (Some(_), Some(_)) => unreachable!("clap enforces mutual exclusion"),
132 };
133
134 let http = slack_http_for("chat:write", args.dry_run)?;
135 let ts = match &target {
136 SendTarget::Channel(id) => http.send(id, &body).await?,
137 SendTarget::Dm(id) => http.send_dm(id, &body).await?,
138 };
139
140 if args.dry_run {
141 return Ok(());
142 }
143 if crate::cli::echo::echo_active() {
144 crate::cli::echo::render_and_clear(args.json);
145 return Ok(());
146 }
147
148 let (kind, tid) = match &target {
149 SendTarget::Channel(id) => ("channel", id.clone()),
150 SendTarget::Dm(id) => ("dm", id.clone()),
151 };
152
153 if args.json {
154 println!(
155 "{}",
156 serde_json::to_string_pretty(&SendOutput {
157 command: "slack.send",
158 target: kind,
159 target_id: tid,
160 ts,
161 })
162 .unwrap()
163 );
164 } else {
165 println!("Sent message (ts={ts}) to {kind} {tid}.");
166 }
167 Ok(())
168}
169
170#[derive(Debug, Args)]
175pub struct ReadArgs {
176 #[arg(long)]
178 pub channel: String,
179
180 #[arg(long, default_value_t = 20)]
182 pub limit: usize,
183
184 #[arg(long)]
186 pub json: bool,
187}
188
189#[derive(Debug, Serialize)]
190struct ReadOutput {
191 command: &'static str,
192 channel: String,
193 count: usize,
194 messages: Vec<ReadMessage>,
195}
196
197#[derive(Debug, Serialize)]
198struct ReadMessage {
199 ts: String,
200 user: String,
201 text: String,
202}
203
204async fn run_read(args: ReadArgs) -> Result<()> {
205 if args.limit == 0 || args.limit > 200 {
206 return Err(ZadError::Invalid(
207 "--limit must be between 1 and 200".into(),
208 ));
209 }
210 let (cfg, _scope) = effective_config()?;
211 let directory = dir::load().unwrap_or_default();
212 let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
213 permissions.check_time(SlackFunction::Read)?;
214 let channel_id = resolve_channel(&args.channel, &cfg, &directory)?;
215 permissions.check_read_channel(&channel_id, &directory)?;
216 let http = slack_http_for("channels:history", false)?;
217 let msgs = http.history(&channel_id, args.limit).await?;
218
219 if crate::cli::echo::echo_active() {
220 crate::cli::echo::render_and_clear(args.json);
221 return Ok(());
222 }
223
224 if args.json {
225 let out = ReadOutput {
226 command: "slack.read",
227 channel: channel_id,
228 count: msgs.len(),
229 messages: msgs
230 .iter()
231 .map(|m| ReadMessage {
232 ts: m.ts.clone(),
233 user: m.user.clone(),
234 text: m.text.clone(),
235 })
236 .collect(),
237 };
238 println!("{}", serde_json::to_string_pretty(&out).unwrap());
239 return Ok(());
240 }
241
242 if msgs.is_empty() {
243 println!("(no messages)");
244 return Ok(());
245 }
246 for m in msgs.iter().rev() {
248 println!("[{}] <{}> {}", m.ts, m.user, m.text);
249 }
250 Ok(())
251}
252
253#[derive(Debug, Args)]
258pub struct ChannelsArgs {
259 #[arg(long)]
261 pub json: bool,
262}
263
264#[derive(Debug, Serialize)]
265struct ChannelsOutput {
266 command: &'static str,
267 count: usize,
268 channels: Vec<ChannelRow>,
269}
270
271#[derive(Debug, Serialize)]
272struct ChannelRow {
273 id: String,
274 name: String,
275 kind: String,
276}
277
278async fn run_channels(args: ChannelsArgs) -> Result<()> {
279 let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
280 permissions.check_time(SlackFunction::Channels)?;
281 let directory = dir::load().unwrap_or_default();
282 let workspace_input = "workspace";
283 permissions.check_channels_workspace(workspace_input, &directory)?;
284 if crate::cli::echo::echo_active() {
285 crate::cli::echo::render_and_clear(args.json);
286 return Ok(());
287 }
288 let http = slack_http_for("channels:read", false)?;
289
290 let mut all_channels = vec![];
291 let mut cursor: Option<String> = None;
292 loop {
293 let (batch, next) = http.list_channels(cursor.as_deref()).await?;
294 all_channels.extend(batch);
295 if next.is_none() {
296 break;
297 }
298 cursor = next;
299 }
300
301 if args.json {
302 let rows: Vec<ChannelRow> = all_channels
303 .iter()
304 .map(|c| ChannelRow {
305 id: c.id.clone(),
306 name: c.name.clone(),
307 kind: if c.is_private {
308 "private".into()
309 } else {
310 "public".into()
311 },
312 })
313 .collect();
314 println!(
315 "{}",
316 serde_json::to_string_pretty(&ChannelsOutput {
317 command: "slack.channels",
318 count: rows.len(),
319 channels: rows,
320 })
321 .unwrap()
322 );
323 return Ok(());
324 }
325
326 if all_channels.is_empty() {
327 println!("(no channels)");
328 return Ok(());
329 }
330 println!("{:<20} {:<10} NAME", "ID", "KIND");
331 for c in &all_channels {
332 let kind = if c.is_private { "private" } else { "public" };
333 println!("{:<20} {:<10} {}", c.id, kind, c.name);
334 }
335 Ok(())
336}
337
338#[derive(Debug, Args)]
343pub struct DiscoverArgs {
344 #[arg(long)]
346 pub skip_members: bool,
347
348 #[arg(long)]
350 pub json: bool,
351}
352
353#[derive(Debug, Serialize)]
354struct DiscoverOutput {
355 command: &'static str,
356 channels: usize,
357 users: usize,
358 warnings: Vec<String>,
359}
360
361async fn run_discover(args: DiscoverArgs) -> Result<()> {
362 let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
363 permissions.check_time(SlackFunction::Discover)?;
364 if crate::cli::echo::echo_active() {
365 crate::cli::echo::render_and_clear(args.json);
366 return Ok(());
367 }
368 let http = slack_http_for("channels:read", false)?;
369 let mut directory = dir::load().unwrap_or_default();
370 let mut warnings: Vec<String> = vec![];
371
372 let mut cursor: Option<String> = None;
374 loop {
375 match http.list_channels(cursor.as_deref()).await {
376 Ok((batch, next)) => {
377 for c in &batch {
378 directory.channels.insert(c.name.clone(), c.id.clone());
379 }
380 if next.is_none() {
381 break;
382 }
383 cursor = next;
384 }
385 Err(e) => {
386 warnings.push(format!("list channels: {e}"));
387 break;
388 }
389 }
390 }
391
392 if !args.skip_members {
394 let mut user_cursor: Option<String> = None;
395 loop {
396 match http.list_users(user_cursor.as_deref()).await {
397 Ok((batch, next)) => {
398 for u in &batch {
399 directory.users.insert(u.display.clone(), u.id.clone());
400 if u.name != u.display {
401 directory.users.insert(u.name.clone(), u.id.clone());
402 }
403 }
404 if next.is_none() {
405 break;
406 }
407 user_cursor = next;
408 }
409 Err(e) => {
410 warnings.push(format!("list users (needs users:read scope): {e}"));
411 break;
412 }
413 }
414 }
415 }
416
417 directory.generated_at_unix = Some(
418 SystemTime::now()
419 .duration_since(UNIX_EPOCH)
420 .map(|d| d.as_secs())
421 .unwrap_or(0),
422 );
423 dir::save(&directory)?;
424
425 let channels_n = directory.channels.len();
426 let users_n = directory.users.len();
427
428 if args.json {
429 let out = DiscoverOutput {
430 command: "slack.discover",
431 channels: channels_n,
432 users: users_n,
433 warnings: warnings.clone(),
434 };
435 println!("{}", serde_json::to_string_pretty(&out).unwrap());
436 } else {
437 println!("Wrote directory: {channels_n} channel entries, {users_n} users.");
438 for w in &warnings {
439 crate::output::warn(w);
440 }
441 }
442 Ok(())
443}
444
445#[derive(Debug, Args)]
450pub struct DirectoryArgs {
451 #[command(subcommand)]
452 pub action: Option<DirectoryAction>,
453
454 #[arg(long)]
455 pub json: bool,
456}
457
458#[derive(Debug, Subcommand)]
459pub enum DirectoryAction {
460 Set(DirectorySetArgs),
462 Remove(DirectoryRemoveArgs),
464 Clear(DirectoryClearArgs),
466}
467
468#[derive(Debug, Args)]
469pub struct DirectorySetArgs {
470 pub kind: DirectoryKind,
471 pub name: String,
472 pub id: String,
473 #[arg(long)]
474 pub json: bool,
475}
476
477#[derive(Debug, Args)]
478pub struct DirectoryRemoveArgs {
479 pub kind: DirectoryKind,
480 pub name: String,
481 #[arg(long)]
482 pub json: bool,
483}
484
485#[derive(Debug, Args)]
486pub struct DirectoryClearArgs {
487 #[arg(long)]
488 pub force: bool,
489 #[arg(long)]
490 pub json: bool,
491}
492
493#[derive(Debug, Clone, Copy, clap::ValueEnum)]
494pub enum DirectoryKind {
495 Channel,
496 User,
497}
498
499#[derive(Debug, Serialize)]
500struct DirectoryOutput<'a> {
501 command: &'static str,
502 path: String,
503 generated_at_unix: Option<u64>,
504 channels: &'a std::collections::BTreeMap<String, String>,
505 users: &'a std::collections::BTreeMap<String, String>,
506}
507
508#[derive(Debug, Serialize)]
509struct DirectoryMutation {
510 command: &'static str,
511 kind: &'static str,
512 name: String,
513 id: Option<String>,
514 removed: bool,
515}
516
517fn require_slack_enabled() -> Result<()> {
518 let project_path = config::path::project_config_path()?;
519 let project_cfg = config::load_from(&project_path)?;
520 if !project_cfg.has_service("slack") {
521 return Err(ZadError::Invalid(format!(
522 "slack is not enabled for this project ({}). \
523 Run `zad service enable slack` first.",
524 project_path.display()
525 )));
526 }
527 Ok(())
528}
529
530fn kind_as_str(k: DirectoryKind) -> &'static str {
531 match k {
532 DirectoryKind::Channel => "channel",
533 DirectoryKind::User => "user",
534 }
535}
536
537fn run_directory(args: DirectoryArgs) -> Result<()> {
538 require_slack_enabled()?;
539 match args.action {
540 None => run_directory_list(args.json),
541 Some(DirectoryAction::Set(a)) => run_directory_set(a),
542 Some(DirectoryAction::Remove(a)) => run_directory_remove(a),
543 Some(DirectoryAction::Clear(a)) => run_directory_clear(a),
544 }
545}
546
547fn run_directory_list(json: bool) -> Result<()> {
548 let path = dir::path_current()?;
549 let directory = dir::load_from(&path)?;
550 if json {
551 let out = DirectoryOutput {
552 command: "slack.directory",
553 path: path.display().to_string(),
554 generated_at_unix: directory.generated_at_unix,
555 channels: &directory.channels,
556 users: &directory.users,
557 };
558 println!("{}", serde_json::to_string_pretty(&out).unwrap());
559 return Ok(());
560 }
561 if directory.channels.is_empty() && directory.users.is_empty() {
562 println!("(empty) {}", path.display());
563 println!("Run `zad slack discover` to populate it.");
564 return Ok(());
565 }
566 println!("# {}", path.display());
567 if !directory.channels.is_empty() {
568 println!("\n[channels]");
569 for (n, id) in &directory.channels {
570 println!(" {n:<40} {id}");
571 }
572 }
573 if !directory.users.is_empty() {
574 println!("\n[users]");
575 for (n, id) in &directory.users {
576 println!(" {n:<24} {id}");
577 }
578 }
579 Ok(())
580}
581
582fn run_directory_set(args: DirectorySetArgs) -> Result<()> {
583 let path = dir::path_current()?;
584 let mut directory = dir::load_from(&path)?;
585 let bucket = match args.kind {
586 DirectoryKind::Channel => &mut directory.channels,
587 DirectoryKind::User => &mut directory.users,
588 };
589 bucket.insert(args.name.clone(), args.id.clone());
590 dir::save_to(&path, &directory)?;
591 if args.json {
592 let out = DirectoryMutation {
593 command: "slack.directory.set",
594 kind: kind_as_str(args.kind),
595 name: args.name,
596 id: Some(args.id),
597 removed: false,
598 };
599 println!("{}", serde_json::to_string_pretty(&out).unwrap());
600 } else {
601 println!(
602 "Mapped {} `{}` in {}.",
603 kind_as_str(args.kind),
604 args.name,
605 path.display()
606 );
607 }
608 Ok(())
609}
610
611fn run_directory_remove(args: DirectoryRemoveArgs) -> Result<()> {
612 let path = dir::path_current()?;
613 let mut directory = dir::load_from(&path)?;
614 let bucket = match args.kind {
615 DirectoryKind::Channel => &mut directory.channels,
616 DirectoryKind::User => &mut directory.users,
617 };
618 let removed = bucket.remove(&args.name).is_some();
619 if removed {
620 dir::save_to(&path, &directory)?;
621 }
622 if args.json {
623 let out = DirectoryMutation {
624 command: "slack.directory.remove",
625 kind: kind_as_str(args.kind),
626 name: args.name,
627 id: None,
628 removed,
629 };
630 println!("{}", serde_json::to_string_pretty(&out).unwrap());
631 } else if removed {
632 println!(
633 "Removed {} `{}` from {}.",
634 kind_as_str(args.kind),
635 args.name,
636 path.display()
637 );
638 } else {
639 println!("No {} entry named `{}`.", kind_as_str(args.kind), args.name);
640 }
641 Ok(())
642}
643
644fn run_directory_clear(args: DirectoryClearArgs) -> Result<()> {
645 if !args.force {
646 return Err(ZadError::Invalid(
647 "refusing to clear the directory without --force".into(),
648 ));
649 }
650 let path = dir::path_current()?;
651 let directory = Directory::default();
652 dir::save_to(&path, &directory)?;
653 if args.json {
654 println!(
655 "{}",
656 serde_json::to_string_pretty(&serde_json::json!({
657 "command": "slack.directory.clear",
658 "path": path.display().to_string(),
659 }))
660 .unwrap()
661 );
662 } else {
663 println!("Cleared {}.", path.display());
664 }
665 Ok(())
666}
667
668#[derive(Debug, Args)]
673pub struct PermissionsArgs {
674 #[command(subcommand)]
675 pub action: Option<PermissionsAction>,
676
677 #[arg(long)]
678 pub json: bool,
679}
680
681#[derive(Debug, Subcommand)]
682pub enum PermissionsAction {
683 Show(PermissionsShowArgs),
684 Init(PermissionsInitArgs),
685 Path(PermissionsPathArgs),
686 Check(PermissionsCheckArgs),
687 #[command(flatten)]
688 Staging(crate::cli::permissions::StagingAction),
689}
690
691#[derive(Debug, Args)]
692pub struct PermissionsShowArgs {
693 #[arg(long)]
694 pub json: bool,
695}
696
697#[derive(Debug, Args)]
698pub struct PermissionsInitArgs {
699 #[arg(long)]
700 pub local: bool,
701 #[arg(long)]
702 pub force: bool,
703 #[arg(long)]
704 pub json: bool,
705}
706
707#[derive(Debug, Args)]
708pub struct PermissionsPathArgs {
709 #[arg(long)]
710 pub json: bool,
711}
712
713#[derive(Debug, Args)]
714pub struct PermissionsCheckArgs {
715 #[arg(long)]
717 pub function: String,
718
719 #[arg(long, conflicts_with = "user")]
721 pub channel: Option<String>,
722
723 #[arg(long, conflicts_with = "channel")]
725 pub user: Option<String>,
726
727 #[arg(long)]
729 pub body: Option<String>,
730
731 #[arg(long)]
732 pub json: bool,
733}
734
735fn run_permissions(args: PermissionsArgs) -> Result<()> {
736 match args.action {
737 None => run_permissions_show(PermissionsShowArgs { json: args.json }),
738 Some(PermissionsAction::Show(a)) => run_permissions_show(a),
739 Some(PermissionsAction::Init(a)) => run_permissions_init(a),
740 Some(PermissionsAction::Path(a)) => run_permissions_path(a),
741 Some(PermissionsAction::Check(a)) => run_permissions_check(a),
742 Some(PermissionsAction::Staging(a)) => {
743 crate::cli::permissions::run::<perms::PermissionsService>(a)
744 }
745 }
746}
747
748#[derive(Debug, Serialize)]
749struct PermissionsShowOutput {
750 command: &'static str,
751 global: PermissionsScopeBlock,
752 local: PermissionsScopeBlock,
753}
754
755#[derive(Debug, Serialize)]
756struct PermissionsScopeBlock {
757 path: String,
758 present: bool,
759}
760
761fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
762 let global_p = perms::global_path()?;
763 let local_p = perms::local_path_current()?;
764 let global_present = global_p.exists();
765 let local_present = local_p.exists();
766 let effective = perms::load_effective()?;
767 let _ = effective;
768 if args.json {
769 println!(
770 "{}",
771 serde_json::to_string_pretty(&PermissionsShowOutput {
772 command: "slack.permissions.show",
773 global: PermissionsScopeBlock {
774 path: global_p.display().to_string(),
775 present: global_present,
776 },
777 local: PermissionsScopeBlock {
778 path: local_p.display().to_string(),
779 present: local_present,
780 },
781 })
782 .unwrap()
783 );
784 return Ok(());
785 }
786 println!("# permissions");
787 println!(
788 " global : {} ({})",
789 global_p.display(),
790 if global_present {
791 "present"
792 } else {
793 "not present (no restrictions at this scope)"
794 }
795 );
796 println!(
797 " local : {} ({})",
798 local_p.display(),
799 if local_present {
800 "present"
801 } else {
802 "not present (no restrictions at this scope)"
803 }
804 );
805 println!();
806 if !global_present && !local_present {
807 println!("No permission files found. Every declared scope is currently unrestricted.");
808 println!("Run `zad slack permissions init` to scaffold a starter policy.");
809 return Ok(());
810 }
811 for p in [&global_p, &local_p] {
812 if !p.exists() {
813 continue;
814 }
815 println!("## {}", p.display());
816 match std::fs::read_to_string(p) {
817 Ok(body) => {
818 for line in body.lines() {
819 println!(" {line}");
820 }
821 }
822 Err(e) => println!(" (failed to read: {e})"),
823 }
824 println!();
825 }
826 Ok(())
827}
828
829#[derive(Debug, Serialize)]
830struct PermissionsInitOutput {
831 command: &'static str,
832 scope: &'static str,
833 path: String,
834 written: bool,
835}
836
837fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
838 let (path, scope) = if args.local {
839 (perms::local_path_current()?, "local")
840 } else {
841 (perms::global_path()?, "global")
842 };
843 if path.exists() && !args.force {
844 return Err(ZadError::Invalid(format!(
845 "permissions file already exists at {}. Pass --force to overwrite.",
846 path.display()
847 )));
848 }
849 let template = perms::starter_template();
850 let key = zad::permissions::signing::load_or_create_from_keychain()?;
851 zad::permissions::signing::write_public_key_cache(&key)?;
852 perms::save_file(&path, &template, &key)?;
853 if args.json {
854 println!(
855 "{}",
856 serde_json::to_string_pretty(&PermissionsInitOutput {
857 command: "slack.permissions.init",
858 scope,
859 path: path.display().to_string(),
860 written: true,
861 })
862 .unwrap()
863 );
864 } else {
865 println!("Wrote starter permissions ({scope}): {}", path.display());
866 println!("Signed with key {}.", key.fingerprint());
867 println!("Review it; the defaults deny admin-like channels.");
868 }
869 Ok(())
870}
871
872#[derive(Debug, Serialize)]
873struct PermissionsPathOutput {
874 command: &'static str,
875 global: String,
876 local: String,
877}
878
879fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
880 let global_p = perms::global_path()?;
881 let local_p = perms::local_path_current()?;
882 if args.json {
883 println!(
884 "{}",
885 serde_json::to_string_pretty(&PermissionsPathOutput {
886 command: "slack.permissions.path",
887 global: global_p.display().to_string(),
888 local: local_p.display().to_string(),
889 })
890 .unwrap()
891 );
892 } else {
893 println!("{}", global_p.display());
894 println!("{}", local_p.display());
895 }
896 Ok(())
897}
898
899#[derive(Debug, Serialize)]
900struct PermissionsCheckOutput {
901 command: &'static str,
902 function: String,
903 allowed: bool,
904 #[serde(skip_serializing_if = "Option::is_none")]
905 reason: Option<String>,
906 #[serde(skip_serializing_if = "Option::is_none")]
907 config_path: Option<String>,
908}
909
910fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
911 let function = parse_function(&args.function)?;
912 let permissions = perms::load_effective()?;
913 let directory = dir::load().unwrap_or_default();
914
915 let mut outcome: Result<()> = Ok(());
916 outcome = outcome.and_then(|()| permissions.check_time(function));
917
918 if outcome.is_ok() {
919 outcome = match (function, &args.channel, &args.user) {
920 (SlackFunction::Send, Some(c), None) => permissions.check_send_channel(c, &directory),
921 (SlackFunction::Send, None, Some(u)) => permissions.check_send_dm(u, &directory),
922 (SlackFunction::Read, Some(c), _) => permissions.check_read_channel(c, &directory),
923 (SlackFunction::Channels, _, _) => {
924 permissions.check_channels_workspace("workspace", &directory)
925 }
926 (SlackFunction::Discover, _, _) => {
927 permissions.check_discover_workspace("workspace", &directory)
928 }
929 _ => Ok(()),
930 };
931 }
932
933 if outcome.is_ok()
934 && function == SlackFunction::Send
935 && let Some(body) = &args.body
936 {
937 outcome = permissions.check_send_body(body);
938 }
939
940 let (allowed, reason, config_path) = match outcome {
941 Ok(()) => (true, None, None),
942 Err(ZadError::PermissionDenied {
943 reason,
944 config_path,
945 ..
946 }) => (false, Some(reason), Some(config_path.display().to_string())),
947 Err(e) => return Err(e),
948 };
949
950 if args.json {
951 println!(
952 "{}",
953 serde_json::to_string_pretty(&PermissionsCheckOutput {
954 command: "slack.permissions.check",
955 function: args.function.clone(),
956 allowed,
957 reason,
958 config_path,
959 })
960 .unwrap()
961 );
962 } else if allowed {
963 println!("allow");
964 } else {
965 println!(
966 "deny — {}",
967 reason.as_deref().unwrap_or("unspecified reason")
968 );
969 if let Some(p) = &config_path {
970 println!(" config: {p}");
971 }
972 }
973 if !allowed {
974 std::process::exit(1);
975 }
976 Ok(())
977}
978
979fn parse_function(name: &str) -> Result<SlackFunction> {
980 match name {
981 "send" => Ok(SlackFunction::Send),
982 "read" => Ok(SlackFunction::Read),
983 "channels" => Ok(SlackFunction::Channels),
984 "discover" => Ok(SlackFunction::Discover),
985 other => Err(ZadError::Invalid(format!(
986 "unknown function `{other}`. Expected one of: send, read, channels, discover."
987 ))),
988 }
989}
990
991#[derive(Debug, Args)]
996pub struct SelfArgs {
997 #[command(subcommand)]
998 pub action: Option<SelfAction>,
999
1000 #[arg(long)]
1001 pub json: bool,
1002}
1003
1004#[derive(Debug, Subcommand)]
1005pub enum SelfAction {
1006 Show(SelfShowArgs),
1007 Set(SelfSetArgs),
1008 Clear(SelfClearArgs),
1009}
1010
1011#[derive(Debug, Args)]
1012pub struct SelfShowArgs {
1013 #[arg(long)]
1014 pub json: bool,
1015}
1016
1017#[derive(Debug, Args)]
1018pub struct SelfSetArgs {
1019 pub user_id: String,
1021 #[arg(long)]
1022 pub json: bool,
1023}
1024
1025#[derive(Debug, Args)]
1026pub struct SelfClearArgs {
1027 #[arg(long)]
1028 pub json: bool,
1029}
1030
1031#[derive(Debug, Serialize)]
1032struct SelfOutput {
1033 command: &'static str,
1034 self_user_id: Option<String>,
1035}
1036
1037fn run_self(args: SelfArgs) -> Result<()> {
1038 match args.action {
1039 None => run_self_show(SelfShowArgs { json: args.json }),
1040 Some(SelfAction::Show(a)) => run_self_show(a),
1041 Some(SelfAction::Set(a)) => run_self_set(a),
1042 Some(SelfAction::Clear(a)) => run_self_clear(a),
1043 }
1044}
1045
1046fn run_self_show(args: SelfShowArgs) -> Result<()> {
1047 let (cfg, _scope) = effective_config()?;
1048 emit_self(args.json, "slack.self.show", cfg.self_user_id)
1049}
1050
1051fn run_self_set(args: SelfSetArgs) -> Result<()> {
1052 let (mut cfg, scope) = effective_config()?;
1053 cfg.self_user_id = Some(args.user_id.trim().to_string());
1054 save_effective_config(&cfg, &scope)?;
1055 emit_self(args.json, "slack.self.set", cfg.self_user_id)
1056}
1057
1058fn run_self_clear(args: SelfClearArgs) -> Result<()> {
1059 let (mut cfg, scope) = effective_config()?;
1060 cfg.self_user_id = None;
1061 save_effective_config(&cfg, &scope)?;
1062 emit_self(args.json, "slack.self.clear", None)
1063}
1064
1065fn emit_self(json: bool, command: &'static str, self_user_id: Option<String>) -> Result<()> {
1066 if json {
1067 println!(
1068 "{}",
1069 serde_json::to_string_pretty(&SelfOutput {
1070 command,
1071 self_user_id
1072 })
1073 .unwrap()
1074 );
1075 } else {
1076 match self_user_id {
1077 Some(id) => println!("self user id: {id}"),
1078 None => println!("self user id: not configured"),
1079 }
1080 }
1081 Ok(())
1082}
1083
1084enum EffectiveScope {
1089 Global,
1090 Local(String),
1091}
1092
1093fn effective_config() -> Result<(SlackServiceCfg, EffectiveScope)> {
1094 let project_path = config::path::project_config_path()?;
1095 let project_cfg = config::load_from(&project_path)?;
1096 if !project_cfg.has_service("slack") {
1097 return Err(ZadError::Invalid(format!(
1098 "slack is not enabled for this project ({}). \
1099 Run `zad service enable slack` first.",
1100 project_path.display()
1101 )));
1102 }
1103 let slug = config::path::project_slug()?;
1104 let local_path = config::path::project_service_config_path_for(&slug, "slack")?;
1105 if let Some(cfg) = config::load_flat::<SlackServiceCfg>(&local_path)? {
1106 return Ok((cfg, EffectiveScope::Local(slug)));
1107 }
1108 let global_path = config::path::global_service_config_path("slack")?;
1109 if let Some(cfg) = config::load_flat::<SlackServiceCfg>(&global_path)? {
1110 return Ok((cfg, EffectiveScope::Global));
1111 }
1112 Err(ZadError::Invalid(format!(
1113 "no Slack credentials found for this project.\n\
1114 looked in:\n {}\n {}",
1115 local_path.display(),
1116 global_path.display()
1117 )))
1118}
1119
1120fn load_token(scope: &EffectiveScope) -> Result<String> {
1121 let account = match scope {
1122 EffectiveScope::Global => secrets::account("slack", "bot", Scope::Global),
1123 EffectiveScope::Local(slug) => secrets::account("slack", "bot", Scope::Project(slug)),
1124 };
1125 secrets::load(&account)?.ok_or_else(|| {
1126 ZadError::Invalid(format!(
1127 "bot token missing from keychain (account `{account}`). \
1128 Re-run `zad service create slack` to reinstall it."
1129 ))
1130 })
1131}
1132
1133fn slack_http_for(required: &'static str, dry_run: bool) -> Result<Box<dyn SlackTransport>> {
1134 let (cfg, scope) = effective_config()?;
1135 let config_path = match &scope {
1136 EffectiveScope::Local(slug) => {
1137 config::path::project_service_config_path_for(slug, "slack")?
1138 }
1139 EffectiveScope::Global => config::path::global_service_config_path("slack")?,
1140 };
1141 let scopes: std::collections::BTreeSet<String> = cfg.scopes.iter().cloned().collect();
1142 if !scopes.contains(required) {
1143 return Err(ZadError::ScopeDenied {
1144 service: "slack",
1145 scope: required,
1146 config_path,
1147 });
1148 }
1149 if dry_run || crate::cli::echo::echo_active() {
1150 let sink = if crate::cli::echo::echo_active() {
1151 crate::cli::echo::dry_run_sink_for_echo()
1152 } else {
1153 default_dry_run_sink()
1154 };
1155 return Ok(Box::new(DryRunSlackTransport::new(sink)));
1156 }
1157 let token = load_token(&scope)?;
1158 Ok(Box::new(SlackHttp::new(&token, scopes, config_path)))
1159}
1160
1161fn resolve_channel(input: &str, cfg: &SlackServiceCfg, directory: &Directory) -> Result<String> {
1162 if is_slack_id(input) {
1164 return Ok(input.to_string());
1165 }
1166 if input.eq_ignore_ascii_case("default") {
1168 if let Some(ch) = &cfg.default_channel {
1169 return resolve_channel(ch, cfg, directory);
1170 }
1171 return Err(ZadError::Invalid(
1172 "no default_channel configured; pass --channel explicitly".into(),
1173 ));
1174 }
1175 let key = input.strip_prefix('#').unwrap_or(input);
1176 if let Some(id) = directory.channels.get(key) {
1177 return Ok(id.clone());
1178 }
1179 Err(ZadError::Invalid(format!(
1180 "--channel `{input}` is neither a Slack channel ID nor a known directory entry. \
1181 Run `zad slack discover` or map it manually with \
1182 `zad slack directory set channel {key} <ID>`."
1183 )))
1184}
1185
1186fn resolve_user(input: &str, directory: &Directory) -> Result<String> {
1187 if is_slack_id(input) {
1188 return Ok(input.to_string());
1189 }
1190 let key = input.strip_prefix('@').unwrap_or(input);
1191 if let Some(id) = directory.users.get(key) {
1192 return Ok(id.clone());
1193 }
1194 Err(ZadError::Invalid(format!(
1195 "--dm `{input}` is neither a Slack user ID nor a known directory entry. \
1196 Run `zad slack discover` or map it manually with \
1197 `zad slack directory set user {key} <ID>`."
1198 )))
1199}
1200
1201fn resolve_user_or_self(
1202 input: &str,
1203 self_user_id: Option<&str>,
1204 directory: &Directory,
1205) -> Result<String> {
1206 if input.eq_ignore_ascii_case("@me") {
1207 return match self_user_id {
1208 Some(id) => Ok(id.to_string()),
1209 None => Err(ZadError::Invalid(
1210 "`@me` has no self-user configured. Run \
1211 `zad slack self set <U...>` with your Slack user ID."
1212 .into(),
1213 )),
1214 };
1215 }
1216 resolve_user(input, directory)
1217}
1218
1219fn is_slack_id(s: &str) -> bool {
1220 matches!(s.chars().next(), Some('C' | 'D' | 'G' | 'U' | 'W' | 'T'))
1221 && s.len() >= 8
1222 && s.chars().skip(1).all(|c| c.is_ascii_alphanumeric())
1223}
1224
1225fn save_effective_config(cfg: &SlackServiceCfg, scope: &EffectiveScope) -> Result<()> {
1226 let path = match scope {
1227 EffectiveScope::Local(slug) => {
1228 config::path::project_service_config_path_for(slug, "slack")?
1229 }
1230 EffectiveScope::Global => config::path::global_service_config_path("slack")?,
1231 };
1232 config::save_flat(&path, cfg)
1233}
1234
1235fn resolve_body(positional: Option<&str>, from_stdin: bool) -> Result<String> {
1236 if from_stdin {
1237 use std::io::Read;
1238 let mut buf = String::new();
1239 std::io::stdin().read_to_string(&mut buf).map_err(|e| {
1240 ZadError::Invalid(format!("failed to read message body from stdin: {e}"))
1241 })?;
1242 let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
1243 if trimmed.is_empty() {
1244 return Err(ZadError::Invalid("message body is empty (stdin)".into()));
1245 }
1246 return Ok(trimmed);
1247 }
1248 match positional {
1249 Some(b) if !b.is_empty() => Ok(b.to_string()),
1250 _ => Err(ZadError::Invalid(
1251 "missing message body: pass it as a positional arg or --stdin".into(),
1252 )),
1253 }
1254}