Skip to main content

zad_cli/cli/
slack.rs

1//! `zad slack <verb>` — runtime commands against a configured Slack bot.
2
3use 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// ---------------------------------------------------------------------------
17// subcommand plumbing
18// ---------------------------------------------------------------------------
19
20#[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 a message to a channel or DM.
29    Send(SendArgs),
30    /// Read recent messages from a channel.
31    Read(ReadArgs),
32    /// List channels in the workspace.
33    Channels(ChannelsArgs),
34    /// Best-effort walk of the workspace's channels and members, writing a
35    /// name -> ID map to this project's `directory.toml`.
36    Discover(DiscoverArgs),
37    /// Inspect or hand-edit the name -> ID directory.
38    Directory(DirectoryArgs),
39    /// Inspect, scaffold, or dry-run the permissions policy.
40    Permissions(PermissionsArgs),
41    /// Manage the Slack user ID resolved from the literal `@me` in targets.
42    #[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// ---------------------------------------------------------------------------
62// send
63// ---------------------------------------------------------------------------
64
65#[derive(Debug, Args)]
66pub struct SendArgs {
67    /// Destination channel ID (`C...`) or name. Mutually exclusive with `--dm`.
68    #[arg(long, conflicts_with = "dm")]
69    pub channel: Option<String>,
70
71    /// Destination user ID (`U...`) or name for a direct message. Mutually
72    /// exclusive with `--channel`.
73    #[arg(long, conflicts_with = "channel")]
74    pub dm: Option<String>,
75
76    /// Read the message body from stdin instead of the positional argument.
77    #[arg(long, conflicts_with = "body")]
78    pub stdin: bool,
79
80    /// Message body.
81    pub body: Option<String>,
82
83    /// Emit machine-readable JSON instead of human-readable text.
84    #[arg(long)]
85    pub json: bool,
86
87    /// Preview the call without contacting Slack. Scope and permission
88    /// checks still run; no bot token is loaded.
89    #[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// ---------------------------------------------------------------------------
171// read
172// ---------------------------------------------------------------------------
173
174#[derive(Debug, Args)]
175pub struct ReadArgs {
176    /// Channel ID (`C...`) or name to read from.
177    #[arg(long)]
178    pub channel: String,
179
180    /// Maximum number of messages to fetch (1–200). Defaults to 20.
181    #[arg(long, default_value_t = 20)]
182    pub limit: usize,
183
184    /// Emit machine-readable JSON instead of human-readable text.
185    #[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    // Slack returns newest-first; print oldest-first.
247    for m in msgs.iter().rev() {
248        println!("[{}] <{}> {}", m.ts, m.user, m.text);
249    }
250    Ok(())
251}
252
253// ---------------------------------------------------------------------------
254// channels
255// ---------------------------------------------------------------------------
256
257#[derive(Debug, Args)]
258pub struct ChannelsArgs {
259    /// Emit machine-readable JSON instead of human-readable text.
260    #[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// ---------------------------------------------------------------------------
339// discover
340// ---------------------------------------------------------------------------
341
342#[derive(Debug, Args)]
343pub struct DiscoverArgs {
344    /// Skip the member-listing phase.
345    #[arg(long)]
346    pub skip_members: bool,
347
348    /// Emit machine-readable JSON instead of a human-readable summary.
349    #[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    // Channels
373    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    // Members
393    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// ---------------------------------------------------------------------------
446// directory
447// ---------------------------------------------------------------------------
448
449#[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    /// Upsert a name -> ID mapping. `<kind>` is one of `channel` or `user`.
461    Set(DirectorySetArgs),
462    /// Remove a single mapping.
463    Remove(DirectoryRemoveArgs),
464    /// Wipe every entry. Use with `--force`.
465    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// ---------------------------------------------------------------------------
669// permissions
670// ---------------------------------------------------------------------------
671
672#[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    /// Function to check: `send`, `read`, `channels`, `discover`.
716    #[arg(long)]
717    pub function: String,
718
719    /// Channel name or ID for `send` / `read`.
720    #[arg(long, conflicts_with = "user")]
721    pub channel: Option<String>,
722
723    /// User name or ID for `send` DM checks.
724    #[arg(long, conflicts_with = "channel")]
725    pub user: Option<String>,
726
727    /// Body to test against content rules (applies only to `send`).
728    #[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// ---------------------------------------------------------------------------
992// self — manage the `@me` resolution target
993// ---------------------------------------------------------------------------
994
995#[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    /// Your Slack user ID (`U...`).
1020    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
1084// ---------------------------------------------------------------------------
1085// credential / config plumbing
1086// ---------------------------------------------------------------------------
1087
1088enum 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 it looks like a Slack channel ID (starts with C or D), use as-is.
1163    if is_slack_id(input) {
1164        return Ok(input.to_string());
1165    }
1166    // Try the default_channel shorthand.
1167    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}