Skip to main content

zad_cli/cli/
telegram.rs

1//! `zad telegram <verb>` — runtime commands against a configured
2//! Telegram bot.
3//!
4//! Credential resolution mirrors `zad service enable telegram`: the
5//! project-local config wins over the global one, and the matching
6//! keychain entry holds the bot token. The project must already have
7//! enabled the Telegram service.
8
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use clap::{Args, Subcommand};
13use serde::Serialize;
14
15use zad::config::{self, TelegramServiceCfg};
16use zad::error::{Result, ZadError};
17use zad::permissions::attachments::AttachmentInfo;
18use zad::secrets::{self, Scope};
19use zad::service::default_dry_run_sink;
20use zad::service::telegram::client::{
21    TELEGRAM_MAX_CAPTION_LEN, TELEGRAM_MAX_MEDIA_GROUP, TELEGRAM_MAX_MESSAGE_LEN,
22};
23use zad::service::telegram::directory::{self as dir, Directory};
24use zad::service::telegram::permissions::{self as perms, TelegramFunction};
25use zad::service::telegram::{DryRunTelegramTransport, TelegramHttp, TelegramTransport};
26
27// ---------------------------------------------------------------------------
28// subcommand plumbing
29// ---------------------------------------------------------------------------
30
31#[derive(Debug, Args)]
32pub struct TelegramArgs {
33    #[command(subcommand)]
34    pub action: Option<Action>,
35}
36
37#[derive(Debug, Subcommand)]
38pub enum Action {
39    /// Send a message to a chat (private, group, supergroup, or channel).
40    Send(SendArgs),
41    /// Fetch recent messages the bot has buffered for a chat.
42    Read(ReadArgs),
43    /// Long-poll the Bot API and stream new messages for a chat to stdout.
44    Listen(ListenArgs),
45    /// List chats the bot has seen (local directory + recent updates).
46    Chats(ChatsArgs),
47    /// Poll the Bot API for recent updates and upsert chat aliases
48    /// into this project's `directory.toml`.
49    Discover(DiscoverArgs),
50    /// Inspect or hand-edit the name -> chat_id directory.
51    Directory(DirectoryArgs),
52    /// Inspect, scaffold, or dry-run the permissions policy that
53    /// narrows what this service may actually do.
54    Permissions(PermissionsArgs),
55    /// Manage the private-chat ID resolved from the literal `@me` in
56    /// send/read targets. Capture (by polling for your first message
57    /// to the bot), show, set, or clear.
58    #[command(name = "self")]
59    SelfCmd(SelfArgs),
60}
61
62pub async fn run(args: TelegramArgs) -> Result<()> {
63    let action = args.action.ok_or_else(|| {
64        ZadError::Invalid("missing subcommand. Run `zad telegram --help`.".into())
65    })?;
66    match action {
67        Action::Send(a) => run_send(a).await,
68        Action::Read(a) => run_read(a).await,
69        Action::Listen(a) => run_listen(a).await,
70        Action::Chats(a) => run_chats(a).await,
71        Action::Discover(a) => run_discover(a).await,
72        Action::Directory(a) => run_directory(a),
73        Action::Permissions(a) => run_permissions(a),
74        Action::SelfCmd(a) => run_self(a).await,
75    }
76}
77
78// ---------------------------------------------------------------------------
79// send
80// ---------------------------------------------------------------------------
81
82#[derive(Debug, Args)]
83pub struct SendArgs {
84    /// Destination chat: a signed integer chat_id (groups/supergroups
85    /// are negative), a `@username` for public channels, or a
86    /// directory alias.
87    #[arg(long)]
88    pub chat: Option<String>,
89
90    /// Read the message body from standard input instead of the
91    /// positional argument.
92    #[arg(long, conflicts_with = "body")]
93    pub stdin: bool,
94
95    /// Attach a file to the message. Repeat up to Telegram's
96    /// `sendMediaGroup` cap of 10 to attach multiple files. With one
97    /// file the message is sent via `sendDocument`; with 2+ files it
98    /// becomes a `sendMediaGroup`. The body (if any) is sent as the
99    /// caption on the first item; with attachments present Telegram's
100    /// 1024-character caption cap applies instead of the 4096-character
101    /// plain-text cap.
102    #[arg(long = "file", value_name = "PATH", action = clap::ArgAction::Append)]
103    pub files: Vec<PathBuf>,
104
105    /// Message body. Required unless `--stdin` is set or at least one
106    /// `--file` is attached.
107    pub body: Option<String>,
108
109    /// Emit machine-readable JSON instead of human-readable text.
110    #[arg(long)]
111    pub json: bool,
112
113    /// Preview the outgoing call without contacting the Bot API.
114    /// Scope and permission checks still run; no bot token is loaded.
115    #[arg(long)]
116    pub dry_run: bool,
117}
118
119#[derive(Debug, Serialize)]
120struct SendOutput {
121    command: &'static str,
122    chat_id: String,
123    message_id: String,
124}
125
126async fn run_send(args: SendArgs) -> Result<()> {
127    let (cfg, _scope) = effective_config()?;
128    let directory = dir::load().unwrap_or_default();
129    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
130    permissions.check_time(TelegramFunction::Send)?;
131
132    let (chat_input, chat_id) = resolve_chat_arg(
133        args.chat.as_deref(),
134        cfg.default_chat.as_deref(),
135        cfg.self_chat_id,
136        &directory,
137    )?;
138    permissions.check_send_chat(&chat_input, chat_id, &directory)?;
139
140    let body = if args.files.is_empty() {
141        resolve_body(args.body.as_deref(), args.stdin)?
142    } else {
143        resolve_body_or_empty(args.body.as_deref(), args.stdin)?
144    };
145    let len = body.chars().count();
146    // Bot API caption cap (1024) is stricter than the plain-text cap
147    // (4096); when attachments are present the body rides as the
148    // caption, so narrow the check accordingly.
149    let body_cap = if args.files.is_empty() {
150        TELEGRAM_MAX_MESSAGE_LEN
151    } else {
152        TELEGRAM_MAX_CAPTION_LEN
153    };
154    if len > body_cap {
155        let label = if args.files.is_empty() {
156            "hard limit"
157        } else {
158            "caption cap (attachments present)"
159        };
160        return Err(ZadError::Invalid(format!(
161            "message body is {len} characters; Telegram's {label} is {body_cap}"
162        )));
163    }
164    if args.files.len() > TELEGRAM_MAX_MEDIA_GROUP {
165        return Err(ZadError::Invalid(format!(
166            "{} attachments is above Telegram's per-message cap of {TELEGRAM_MAX_MEDIA_GROUP}",
167            args.files.len()
168        )));
169    }
170    permissions.check_send_body(&body)?;
171
172    let infos: Vec<AttachmentInfo> = args
173        .files
174        .iter()
175        .map(|p| {
176            AttachmentInfo::probe(p).map_err(|e| {
177                ZadError::Invalid(format!("attachment `{}` not readable: {e}", p.display()))
178            })
179        })
180        .collect::<Result<_>>()?;
181    permissions.check_send_attachments(&infos)?;
182
183    let http = telegram_http_for("messages.send", args.dry_run)?;
184    let message_id = http.send(chat_id, &body, &args.files).await?;
185
186    // When --dry-run is active the transport already emitted a preview
187    // record (human summary via `tracing::info!`, JSON payload on
188    // stdout). Skip the trailing "Sent …" / SendOutput print so we
189    // never claim success for an operation we didn't actually perform.
190    if args.dry_run {
191        return Ok(());
192    }
193    if crate::cli::echo::echo_active() {
194        crate::cli::echo::render_and_clear(args.json);
195        return Ok(());
196    }
197
198    if args.json {
199        let out = SendOutput {
200            command: "telegram.send",
201            chat_id: chat_id.to_string(),
202            message_id: message_id.to_string(),
203        };
204        println!("{}", serde_json::to_string_pretty(&out).unwrap());
205    } else {
206        println!("Sent message {message_id} to chat {chat_id}.");
207    }
208    Ok(())
209}
210
211// ---------------------------------------------------------------------------
212// read
213// ---------------------------------------------------------------------------
214
215#[derive(Debug, Args)]
216pub struct ReadArgs {
217    /// Chat to read from. Same accepted formats as `--chat` on `send`.
218    #[arg(long)]
219    pub chat: String,
220
221    /// Maximum number of messages to fetch (1–100). Defaults to 20.
222    #[arg(long, default_value_t = 20)]
223    pub limit: usize,
224
225    /// Emit machine-readable JSON instead of human-readable text.
226    #[arg(long)]
227    pub json: bool,
228}
229
230#[derive(Debug, Serialize)]
231struct ReadOutput {
232    command: &'static str,
233    chat_id: String,
234    count: usize,
235    messages: Vec<ReadMessage>,
236}
237
238#[derive(Debug, Serialize)]
239struct ReadMessage {
240    id: String,
241    author: String,
242    body: String,
243}
244
245async fn run_read(args: ReadArgs) -> Result<()> {
246    if args.limit == 0 || args.limit > 100 {
247        return Err(ZadError::Invalid(
248            "--limit must be between 1 and 100".into(),
249        ));
250    }
251    let (cfg, _scope) = effective_config()?;
252    let directory = dir::load().unwrap_or_default();
253    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
254    permissions.check_time(TelegramFunction::Read)?;
255
256    let (chat_input, chat_id) =
257        resolve_chat_arg(Some(&args.chat), None, cfg.self_chat_id, &directory)?;
258    permissions.check_read_chat(&chat_input, chat_id, &directory)?;
259
260    let http = telegram_http_for("messages.read", false)?;
261    let msgs = http.history(chat_id, args.limit).await?;
262
263    if crate::cli::echo::echo_active() {
264        crate::cli::echo::render_and_clear(args.json);
265        return Ok(());
266    }
267
268    if args.json {
269        let out = ReadOutput {
270            command: "telegram.read",
271            chat_id: chat_id.to_string(),
272            count: msgs.len(),
273            messages: msgs
274                .iter()
275                .map(|m| ReadMessage {
276                    id: m.id.to_string(),
277                    author: m.author.clone(),
278                    body: m.body.clone(),
279                })
280                .collect(),
281        };
282        println!("{}", serde_json::to_string_pretty(&out).unwrap());
283        return Ok(());
284    }
285
286    if msgs.is_empty() {
287        println!("(no messages — `getUpdates` is forward-only; see `zad man telegram`)");
288        return Ok(());
289    }
290    // Print oldest-first so a human reads top-to-bottom in chronological
291    // order. `history` returned newest-first.
292    for m in msgs.iter().rev() {
293        println!("[{}] <{}> {}", m.id, m.author, m.body);
294    }
295    Ok(())
296}
297
298// ---------------------------------------------------------------------------
299// listen
300// ---------------------------------------------------------------------------
301
302#[derive(Debug, Args)]
303pub struct ListenArgs {
304    /// Chat to stream. Same accepted formats as `--chat` on `send`/`read`.
305    #[arg(long)]
306    pub chat: String,
307
308    /// Server-side long-poll seconds (1-50; Telegram caps the timeout
309    /// at 50). Higher values mean fewer HTTP round-trips when the
310    /// queue is idle, at the cost of slightly slower Ctrl-C latency.
311    #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=50))]
312    pub timeout: u32,
313
314    /// Emit one JSON object per message (NDJSON) instead of the
315    /// human-readable default.
316    #[arg(long)]
317    pub json: bool,
318}
319
320#[derive(Debug, Serialize)]
321struct ListenLine<'a> {
322    command: &'static str,
323    chat_id: String,
324    id: String,
325    author: &'a str,
326    body: &'a str,
327}
328
329async fn run_listen(args: ListenArgs) -> Result<()> {
330    let (cfg, _scope) = effective_config()?;
331    let directory = dir::load().unwrap_or_default();
332    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
333    permissions.check_time(TelegramFunction::Listen)?;
334
335    let (chat_input, chat_id) =
336        resolve_chat_arg(Some(&args.chat), None, cfg.self_chat_id, &directory)?;
337    permissions.check_listen_chat(&chat_input, chat_id, &directory)?;
338
339    let transport = telegram_http_for("messages.read", false)?;
340
341    if crate::cli::echo::echo_active() {
342        crate::cli::echo::render_and_clear(args.json);
343        return Ok(());
344    }
345
346    use std::io::Write;
347    let mut offset: Option<i64> = None;
348    loop {
349        tokio::select! {
350            _ = tokio::signal::ctrl_c() => return Ok(()),
351            res = transport.listen_updates(offset, args.timeout) => {
352                let (msgs, next) = res?;
353                let mut stdout = std::io::stdout().lock();
354                for m in msgs.iter().filter(|m| m.chat == chat_id) {
355                    if args.json {
356                        let line = ListenLine {
357                            command: "telegram.listen",
358                            chat_id: chat_id.to_string(),
359                            id: m.id.to_string(),
360                            author: &m.author,
361                            body: &m.body,
362                        };
363                        writeln!(stdout, "{}", serde_json::to_string(&line).unwrap()).ok();
364                    } else {
365                        writeln!(stdout, "[{}] <{}> {}", m.id, m.author, m.body).ok();
366                    }
367                }
368                stdout.flush().ok();
369                if let Some(n) = next {
370                    offset = Some(n);
371                }
372            }
373        }
374    }
375}
376
377// ---------------------------------------------------------------------------
378// chats
379// ---------------------------------------------------------------------------
380
381#[derive(Debug, Args)]
382pub struct ChatsArgs {
383    /// Emit machine-readable JSON instead of human-readable text.
384    #[arg(long)]
385    pub json: bool,
386}
387
388#[derive(Debug, Serialize)]
389struct ChatsOutput {
390    command: &'static str,
391    count: usize,
392    chats: Vec<ChatRow>,
393}
394
395#[derive(Debug, Serialize)]
396struct ChatRow {
397    id: String,
398    title: String,
399    kind: String,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    username: Option<String>,
402    source: &'static str,
403}
404
405async fn run_chats(args: ChatsArgs) -> Result<()> {
406    let (_cfg, _scope) = effective_config()?;
407    let directory = dir::load().unwrap_or_default();
408    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
409    permissions.check_time(TelegramFunction::Chats)?;
410    if crate::cli::echo::echo_active() {
411        crate::cli::echo::render_and_clear(args.json);
412        return Ok(());
413    }
414
415    let http = telegram_http_for("chats", false)?;
416    let observed = http.list_chats().await?;
417
418    // Merge observed chats with the local directory cache so an
419    // operator sees every chat zad knows about, not just the ones
420    // whose updates happen to be buffered right now. Observed entries
421    // override directory rows when they share an id so kind/username
422    // come from the live data where possible.
423    let mut by_id: std::collections::BTreeMap<i64, ChatRow> = std::collections::BTreeMap::new();
424    for (name, id_s) in &directory.chats {
425        if let Ok(id) = id_s.parse::<i64>() {
426            by_id.entry(id).or_insert_with(|| ChatRow {
427                id: id.to_string(),
428                title: name.clone(),
429                kind: "unknown".into(),
430                username: None,
431                source: "directory",
432            });
433        }
434    }
435    for c in &observed {
436        by_id.insert(
437            c.id,
438            ChatRow {
439                id: c.id.to_string(),
440                title: c.title.clone(),
441                kind: c.kind.clone(),
442                username: c.username.clone(),
443                source: "observed",
444            },
445        );
446    }
447    let rows: Vec<ChatRow> = by_id.into_values().collect();
448
449    if args.json {
450        let out = ChatsOutput {
451            command: "telegram.chats",
452            count: rows.len(),
453            chats: rows,
454        };
455        println!("{}", serde_json::to_string_pretty(&out).unwrap());
456        return Ok(());
457    }
458
459    if rows.is_empty() {
460        println!("(no chats — run `zad telegram discover` once the bot has seen traffic)");
461        return Ok(());
462    }
463    println!("{:<20}  {:<10}  {:<10}  TITLE", "ID", "KIND", "SOURCE");
464    for r in &rows {
465        println!(
466            "{:<20}  {:<10}  {:<10}  {}",
467            r.id, r.kind, r.source, r.title
468        );
469    }
470    Ok(())
471}
472
473// ---------------------------------------------------------------------------
474// discover
475// ---------------------------------------------------------------------------
476
477#[derive(Debug, Args)]
478pub struct DiscoverArgs {
479    /// Emit machine-readable JSON instead of a human-readable summary.
480    #[arg(long)]
481    pub json: bool,
482}
483
484#[derive(Debug, Serialize)]
485struct DiscoverOutput {
486    command: &'static str,
487    chats: usize,
488    added: usize,
489    skipped: usize,
490    warnings: Vec<String>,
491}
492
493async fn run_discover(args: DiscoverArgs) -> Result<()> {
494    let (_cfg, _scope) = effective_config()?;
495    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
496    permissions.check_time(TelegramFunction::Discover)?;
497    if crate::cli::echo::echo_active() {
498        crate::cli::echo::render_and_clear(args.json);
499        return Ok(());
500    }
501
502    let http = telegram_http_for("chats", false)?;
503    let observed = http.list_chats().await?;
504
505    let mut directory = dir::load().unwrap_or_default();
506    let mut added = 0usize;
507    let mut skipped = 0usize;
508    let warnings: Vec<String> = vec![];
509
510    for c in &observed {
511        // Silently skip chats the policy denies from discovery — the
512        // walk is best-effort and shouldn't fail the whole call.
513        if permissions
514            .check_discover_chat(&c.title, c.id, &directory)
515            .is_err()
516        {
517            skipped += 1;
518            continue;
519        }
520        let key = c.title.clone();
521        let id_s = c.id.to_string();
522        match directory.chats.get(&key) {
523            Some(existing) if existing == &id_s => {}
524            _ => {
525                directory.chats.insert(key, id_s);
526                added += 1;
527            }
528        }
529    }
530
531    directory.generated_at_unix = Some(
532        SystemTime::now()
533            .duration_since(UNIX_EPOCH)
534            .map(|d| d.as_secs())
535            .unwrap_or(0),
536    );
537    dir::save(&directory)?;
538
539    if args.json {
540        let out = DiscoverOutput {
541            command: "telegram.discover",
542            chats: observed.len(),
543            added,
544            skipped,
545            warnings: warnings.clone(),
546        };
547        println!("{}", serde_json::to_string_pretty(&out).unwrap());
548    } else {
549        let total = observed.len();
550        println!("Observed {total} chat(s); added {added}, skipped {skipped} (denied by policy).");
551        for w in &warnings {
552            crate::output::warn(w);
553        }
554    }
555    Ok(())
556}
557
558// ---------------------------------------------------------------------------
559// credential / config plumbing
560// ---------------------------------------------------------------------------
561
562enum EffectiveScope {
563    Global,
564    Local(String),
565}
566
567fn require_telegram_enabled() -> Result<()> {
568    let project_path = config::path::project_config_path()?;
569    let project_cfg = config::load_from(&project_path)?;
570    if !project_cfg.has_service("telegram") {
571        return Err(ZadError::Invalid(format!(
572            "telegram is not enabled for this project ({}). \
573             Run `zad service enable telegram` first.",
574            project_path.display()
575        )));
576    }
577    Ok(())
578}
579
580fn effective_config() -> Result<(TelegramServiceCfg, EffectiveScope)> {
581    require_telegram_enabled()?;
582
583    let slug = config::path::project_slug()?;
584    let local_path = config::path::project_service_config_path_for(&slug, "telegram")?;
585    if let Some(cfg) = config::load_flat::<TelegramServiceCfg>(&local_path)? {
586        return Ok((cfg, EffectiveScope::Local(slug)));
587    }
588    let global_path = config::path::global_service_config_path("telegram")?;
589    if let Some(cfg) = config::load_flat::<TelegramServiceCfg>(&global_path)? {
590        return Ok((cfg, EffectiveScope::Global));
591    }
592    Err(ZadError::Invalid(format!(
593        "no Telegram credentials found for this project.\n\
594         looked in:\n  {}\n  {}",
595        local_path.display(),
596        global_path.display()
597    )))
598}
599
600fn load_token(scope: &EffectiveScope) -> Result<String> {
601    let account = match scope {
602        EffectiveScope::Global => secrets::account("telegram", "bot", Scope::Global),
603        EffectiveScope::Local(slug) => secrets::account("telegram", "bot", Scope::Project(slug)),
604    };
605    secrets::load(&account)?.ok_or_else(|| {
606        ZadError::Invalid(format!(
607            "bot token missing from keychain (account `{account}`). \
608             Re-run `zad service create telegram` to reinstall it."
609        ))
610    })
611}
612
613/// Resolve config + token + scope set into a ready-to-call transport,
614/// failing fast with [`ZadError::ScopeDenied`] if `required` isn't
615/// declared. The fail-fast scope check happens *before* the keychain
616/// read, so a denied op never touches secrets; [`TelegramHttp`] also
617/// guards the same scope internally for library-level callers.
618///
619/// When `dry_run` is `true` the scope check still runs (so preview
620/// respects the caller's policy boundary), but the keychain read is
621/// skipped and a [`DryRunTelegramTransport`] is returned instead of a
622/// live client. That lets `--dry-run` work before the operator has
623/// configured a bot, and guarantees no token is ever loaded into
624/// memory for a preview.
625fn telegram_http_for(required: &'static str, dry_run: bool) -> Result<Box<dyn TelegramTransport>> {
626    let (cfg, scope) = effective_config()?;
627    let config_path = match &scope {
628        EffectiveScope::Local(slug) => {
629            config::path::project_service_config_path_for(slug, "telegram")?
630        }
631        EffectiveScope::Global => config::path::global_service_config_path("telegram")?,
632    };
633    let scopes: std::collections::BTreeSet<String> = cfg.scopes.iter().cloned().collect();
634    if !scopes.contains(required) {
635        return Err(ZadError::ScopeDenied {
636            service: "telegram",
637            scope: required,
638            config_path,
639        });
640    }
641    if dry_run || crate::cli::echo::echo_active() {
642        let sink = if crate::cli::echo::echo_active() {
643            crate::cli::echo::dry_run_sink_for_echo()
644        } else {
645            default_dry_run_sink()
646        };
647        return Ok(Box::new(DryRunTelegramTransport::new(sink)));
648    }
649    let token = load_token(&scope)?;
650    Ok(Box::new(TelegramHttp::new(&token, scopes, config_path)))
651}
652
653fn resolve_chat_arg(
654    flag: Option<&str>,
655    default: Option<&str>,
656    self_chat_id: Option<i64>,
657    directory: &Directory,
658) -> Result<(String, i64)> {
659    let raw = flag.or(default).ok_or_else(|| {
660        ZadError::Invalid(
661            "no chat specified: pass --chat <ID|@username|name> or set `default_chat` in the config"
662                .into(),
663        )
664    })?;
665    if raw.eq_ignore_ascii_case("@me") {
666        return match self_chat_id {
667            Some(id) => Ok((raw.to_string(), id)),
668            None => Err(ZadError::Invalid(
669                "`@me` has no self-chat configured. Run `zad telegram self capture` \
670                 to poll for your first message to the bot, or \
671                 `zad telegram self set <id>` if you already know the id."
672                    .into(),
673            )),
674        };
675    }
676    let id = directory.resolve_chat(raw).ok_or_else(|| {
677        let key = raw.strip_prefix('@').unwrap_or(raw);
678        ZadError::Invalid(format!(
679            "--chat `{raw}` is neither a chat_id nor a known directory entry. \
680             Run `zad telegram discover` or map it manually with \
681             `zad telegram directory set {key} <id>`."
682        ))
683    })?;
684    Ok((raw.to_string(), id))
685}
686
687fn resolve_body(positional: Option<&str>, from_stdin: bool) -> Result<String> {
688    resolve_body_inner(positional, from_stdin, false)
689}
690
691/// Same as [`resolve_body`] but tolerates an empty result, for send
692/// paths that carry at least one attachment (the caption on a
693/// `sendDocument` / `sendMediaGroup` is optional).
694fn resolve_body_or_empty(positional: Option<&str>, from_stdin: bool) -> Result<String> {
695    resolve_body_inner(positional, from_stdin, true)
696}
697
698fn resolve_body_inner(
699    positional: Option<&str>,
700    from_stdin: bool,
701    allow_empty: bool,
702) -> Result<String> {
703    if from_stdin {
704        use std::io::Read;
705        let mut buf = String::new();
706        std::io::stdin().read_to_string(&mut buf).map_err(|e| {
707            ZadError::Invalid(format!("failed to read message body from stdin: {e}"))
708        })?;
709        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
710        if trimmed.is_empty() && !allow_empty {
711            return Err(ZadError::Invalid("message body is empty (stdin)".into()));
712        }
713        return Ok(trimmed);
714    }
715    match positional {
716        Some(b) if !b.is_empty() => Ok(b.to_string()),
717        Some(_) if allow_empty => Ok(String::new()),
718        None if allow_empty => Ok(String::new()),
719        _ => Err(ZadError::Invalid(
720            "missing message body: pass it as a positional arg, --stdin, or attach at least one --file".into(),
721        )),
722    }
723}
724
725// ---------------------------------------------------------------------------
726// directory
727// ---------------------------------------------------------------------------
728
729#[derive(Debug, Args)]
730pub struct DirectoryArgs {
731    #[command(subcommand)]
732    pub action: Option<DirectoryAction>,
733
734    /// When no subcommand is given, print the directory as JSON.
735    #[arg(long)]
736    pub json: bool,
737}
738
739#[derive(Debug, Subcommand)]
740pub enum DirectoryAction {
741    /// Upsert a name -> chat_id mapping.
742    Set(DirectorySetArgs),
743    /// Remove a single mapping. Silent no-op if the key is absent.
744    Remove(DirectoryRemoveArgs),
745    /// Wipe every entry. Use with `--force`.
746    Clear(DirectoryClearArgs),
747}
748
749#[derive(Debug, Args)]
750pub struct DirectorySetArgs {
751    /// Human-readable name to map from.
752    pub name: String,
753    /// Signed chat_id (groups/supergroups are negative).
754    pub id: String,
755    #[arg(long)]
756    pub json: bool,
757}
758
759#[derive(Debug, Args)]
760pub struct DirectoryRemoveArgs {
761    pub name: String,
762    #[arg(long)]
763    pub json: bool,
764}
765
766#[derive(Debug, Args)]
767pub struct DirectoryClearArgs {
768    #[arg(long)]
769    pub force: bool,
770    #[arg(long)]
771    pub json: bool,
772}
773
774#[derive(Debug, Serialize)]
775struct DirectoryOutput<'a> {
776    command: &'static str,
777    path: String,
778    generated_at_unix: Option<u64>,
779    chats: &'a std::collections::BTreeMap<String, String>,
780}
781
782#[derive(Debug, Serialize)]
783struct DirectoryMutation {
784    command: &'static str,
785    name: String,
786    id: Option<String>,
787    removed: bool,
788}
789
790fn run_directory(args: DirectoryArgs) -> Result<()> {
791    require_telegram_enabled()?;
792    match args.action {
793        None => run_directory_list(args.json),
794        Some(DirectoryAction::Set(a)) => run_directory_set(a),
795        Some(DirectoryAction::Remove(a)) => run_directory_remove(a),
796        Some(DirectoryAction::Clear(a)) => run_directory_clear(a),
797    }
798}
799
800fn run_directory_list(json: bool) -> Result<()> {
801    let path = dir::path_current()?;
802    let directory = dir::load_from(&path)?;
803    if json {
804        let out = DirectoryOutput {
805            command: "telegram.directory",
806            path: path.display().to_string(),
807            generated_at_unix: directory.generated_at_unix,
808            chats: &directory.chats,
809        };
810        println!("{}", serde_json::to_string_pretty(&out).unwrap());
811        return Ok(());
812    }
813    if directory.total() == 0 {
814        println!("(empty) {}", path.display());
815        println!("Run `zad telegram discover` to populate it (once implemented),");
816        println!("or add entries manually with `zad telegram directory set <name> <id>`.");
817        return Ok(());
818    }
819    println!("# {}", path.display());
820    if !directory.chats.is_empty() {
821        println!("\n[chats]");
822        for (n, id) in &directory.chats {
823            println!("  {n:<32}  {id}");
824        }
825    }
826    Ok(())
827}
828
829fn run_directory_set(args: DirectorySetArgs) -> Result<()> {
830    let id = parse_chat_id(&args.id)?;
831    let path = dir::path_current()?;
832    let mut directory = dir::load_from(&path)?;
833    directory.chats.insert(args.name.clone(), id.to_string());
834    dir::save_to(&path, &directory)?;
835
836    if args.json {
837        let out = DirectoryMutation {
838            command: "telegram.directory.set",
839            name: args.name,
840            id: Some(id.to_string()),
841            removed: false,
842        };
843        println!("{}", serde_json::to_string_pretty(&out).unwrap());
844    } else {
845        println!("Mapped chat `{}` -> {id} in {}.", args.name, path.display());
846    }
847    Ok(())
848}
849
850fn run_directory_remove(args: DirectoryRemoveArgs) -> Result<()> {
851    let path = dir::path_current()?;
852    let mut directory = dir::load_from(&path)?;
853    let removed = directory.chats.remove(&args.name).is_some();
854    if removed {
855        dir::save_to(&path, &directory)?;
856    }
857
858    if args.json {
859        let out = DirectoryMutation {
860            command: "telegram.directory.remove",
861            name: args.name,
862            id: None,
863            removed,
864        };
865        println!("{}", serde_json::to_string_pretty(&out).unwrap());
866    } else if removed {
867        println!("Removed chat `{}` from {}.", args.name, path.display());
868    } else {
869        println!("No chat entry named `{}`.", args.name);
870    }
871    Ok(())
872}
873
874fn run_directory_clear(args: DirectoryClearArgs) -> Result<()> {
875    if !args.force {
876        return Err(ZadError::Invalid(
877            "refusing to clear the directory without --force".into(),
878        ));
879    }
880    let path = dir::path_current()?;
881    let directory = Directory::default();
882    dir::save_to(&path, &directory)?;
883    if args.json {
884        println!(
885            "{}",
886            serde_json::to_string_pretty(&serde_json::json!({
887                "command": "telegram.directory.clear",
888                "path": path.display().to_string(),
889            }))
890            .unwrap()
891        );
892    } else {
893        println!("Cleared {}.", path.display());
894    }
895    Ok(())
896}
897
898fn parse_chat_id(v: &str) -> Result<i64> {
899    v.parse::<i64>().map_err(|_| {
900        ZadError::Invalid(format!(
901            "<id> must be a signed decimal chat_id (groups are negative), got `{v}`"
902        ))
903    })
904}
905
906// ---------------------------------------------------------------------------
907// permissions — inspect / scaffold / dry-run the permissions policy
908// ---------------------------------------------------------------------------
909
910#[derive(Debug, Args)]
911pub struct PermissionsArgs {
912    #[command(subcommand)]
913    pub action: Option<PermissionsAction>,
914
915    /// When no subcommand is given, behave like `show`.
916    #[arg(long)]
917    pub json: bool,
918}
919
920#[derive(Debug, Subcommand)]
921pub enum PermissionsAction {
922    /// Print the effective policy (global + local) for this project.
923    Show(PermissionsShowArgs),
924    /// Write a starter `permissions.toml` at the selected scope.
925    Init(PermissionsInitArgs),
926    /// Print the paths considered for this project, in precedence
927    /// order.
928    Path(PermissionsPathArgs),
929    /// Dry-run: ask whether a proposed action would be admitted
930    /// *without* hitting the Bot API. Useful for agents that want to
931    /// pre-flight.
932    Check(PermissionsCheckArgs),
933    /// Staged-commit workflow: queue mutations in a `.pending` file and
934    /// only sign on `commit`. See `cli::permissions`.
935    #[command(flatten)]
936    Staging(crate::cli::permissions::StagingAction),
937}
938
939#[derive(Debug, Args)]
940pub struct PermissionsShowArgs {
941    #[arg(long)]
942    pub json: bool,
943}
944
945#[derive(Debug, Args)]
946pub struct PermissionsInitArgs {
947    /// Write to the project-local `permissions.toml`. Default is
948    /// global.
949    #[arg(long)]
950    pub local: bool,
951
952    /// Overwrite any existing file at that scope.
953    #[arg(long)]
954    pub force: bool,
955
956    #[arg(long)]
957    pub json: bool,
958}
959
960#[derive(Debug, Args)]
961pub struct PermissionsPathArgs {
962    #[arg(long)]
963    pub json: bool,
964}
965
966#[derive(Debug, Args)]
967pub struct PermissionsCheckArgs {
968    /// Function to check: `send`, `read`, `chats`, `discover`.
969    #[arg(long)]
970    pub function: String,
971
972    /// Chat to test against the chats list.
973    #[arg(long)]
974    pub chat: Option<String>,
975
976    /// Body to test against `content` rules (applies only to `send`).
977    #[arg(long)]
978    pub body: Option<String>,
979
980    #[arg(long)]
981    pub json: bool,
982}
983
984fn run_permissions(args: PermissionsArgs) -> Result<()> {
985    match args.action {
986        None => run_permissions_show(PermissionsShowArgs { json: args.json }),
987        Some(PermissionsAction::Show(a)) => run_permissions_show(a),
988        Some(PermissionsAction::Init(a)) => run_permissions_init(a),
989        Some(PermissionsAction::Path(a)) => run_permissions_path(a),
990        Some(PermissionsAction::Check(a)) => run_permissions_check(a),
991        Some(PermissionsAction::Staging(a)) => {
992            crate::cli::permissions::run::<perms::PermissionsService>(a)
993        }
994    }
995}
996
997#[derive(Debug, Serialize)]
998struct PermissionsShowOutput {
999    command: &'static str,
1000    global: PermissionsScopeBlock,
1001    local: PermissionsScopeBlock,
1002}
1003
1004#[derive(Debug, Serialize)]
1005struct PermissionsScopeBlock {
1006    path: String,
1007    present: bool,
1008}
1009
1010fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
1011    let global_p = perms::global_path()?;
1012    let local_p = perms::local_path_current()?;
1013    let global_present = global_p.exists();
1014    let local_present = local_p.exists();
1015
1016    // Pre-load to surface any compile errors up front, before
1017    // printing.
1018    let effective = perms::load_effective()?;
1019    let _ = effective;
1020
1021    if args.json {
1022        let out = PermissionsShowOutput {
1023            command: "telegram.permissions.show",
1024            global: PermissionsScopeBlock {
1025                path: global_p.display().to_string(),
1026                present: global_present,
1027            },
1028            local: PermissionsScopeBlock {
1029                path: local_p.display().to_string(),
1030                present: local_present,
1031            },
1032        };
1033        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1034        return Ok(());
1035    }
1036
1037    println!("# permissions");
1038    println!(
1039        "  global : {} ({})",
1040        global_p.display(),
1041        if global_present {
1042            "present"
1043        } else {
1044            "not present (no restrictions at this scope)"
1045        }
1046    );
1047    println!(
1048        "  local  : {} ({})",
1049        local_p.display(),
1050        if local_present {
1051            "present"
1052        } else {
1053            "not present (no restrictions at this scope)"
1054        }
1055    );
1056    println!();
1057    if !global_present && !local_present {
1058        println!("No permission files found. Every declared scope is currently unrestricted.");
1059        println!("Run `zad telegram permissions init` to scaffold a starter policy.");
1060        return Ok(());
1061    }
1062    for p in [&global_p, &local_p] {
1063        if !p.exists() {
1064            continue;
1065        }
1066        println!("## {}", p.display());
1067        match std::fs::read_to_string(p) {
1068            Ok(body) => {
1069                for line in body.lines() {
1070                    println!("  {line}");
1071                }
1072            }
1073            Err(e) => println!("  (failed to read: {e})"),
1074        }
1075        println!();
1076    }
1077    Ok(())
1078}
1079
1080#[derive(Debug, Serialize)]
1081struct PermissionsInitOutput {
1082    command: &'static str,
1083    scope: &'static str,
1084    path: String,
1085    written: bool,
1086}
1087
1088fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
1089    let (path, scope) = if args.local {
1090        (perms::local_path_current()?, "local")
1091    } else {
1092        (perms::global_path()?, "global")
1093    };
1094    if path.exists() && !args.force {
1095        return Err(ZadError::Invalid(format!(
1096            "permissions file already exists at {}. Pass --force to overwrite.",
1097            path.display()
1098        )));
1099    }
1100    let template = perms::starter_template();
1101    let key = zad::permissions::signing::load_or_create_from_keychain()?;
1102    zad::permissions::signing::write_public_key_cache(&key)?;
1103    perms::save_file(&path, &template, &key)?;
1104    if args.json {
1105        let out = PermissionsInitOutput {
1106            command: "telegram.permissions.init",
1107            scope,
1108            path: path.display().to_string(),
1109            written: true,
1110        };
1111        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1112    } else {
1113        println!("Wrote starter permissions ({scope}): {}", path.display());
1114        println!("Signed with key {}.", key.fingerprint());
1115        println!("Review it; the defaults deny admin-like chats.");
1116    }
1117    Ok(())
1118}
1119
1120#[derive(Debug, Serialize)]
1121struct PermissionsPathOutput {
1122    command: &'static str,
1123    global: String,
1124    local: String,
1125}
1126
1127fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
1128    let global_p = perms::global_path()?;
1129    let local_p = perms::local_path_current()?;
1130    if args.json {
1131        let out = PermissionsPathOutput {
1132            command: "telegram.permissions.path",
1133            global: global_p.display().to_string(),
1134            local: local_p.display().to_string(),
1135        };
1136        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1137    } else {
1138        println!("{}", global_p.display());
1139        println!("{}", local_p.display());
1140    }
1141    Ok(())
1142}
1143
1144#[derive(Debug, Serialize)]
1145struct PermissionsCheckOutput {
1146    command: &'static str,
1147    function: String,
1148    allowed: bool,
1149    #[serde(skip_serializing_if = "Option::is_none")]
1150    reason: Option<String>,
1151    #[serde(skip_serializing_if = "Option::is_none")]
1152    config_path: Option<String>,
1153}
1154
1155fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
1156    let function = parse_function(&args.function)?;
1157    let permissions = perms::load_effective()?;
1158    let directory = dir::load().unwrap_or_default();
1159
1160    let mut outcome: Result<()> = Ok(());
1161    outcome = outcome.and_then(|()| permissions.check_time(function));
1162
1163    if outcome.is_ok()
1164        && let Some(c) = &args.chat
1165    {
1166        let id = directory.resolve_chat(c).unwrap_or(0);
1167        outcome = match function {
1168            TelegramFunction::Send => permissions.check_send_chat(c, id, &directory),
1169            TelegramFunction::Read => permissions.check_read_chat(c, id, &directory),
1170            TelegramFunction::Listen => permissions.check_listen_chat(c, id, &directory),
1171            TelegramFunction::Chats => permissions.check_chats_chat(c, id, &directory),
1172            TelegramFunction::Discover => permissions.check_discover_chat(c, id, &directory),
1173        };
1174    }
1175
1176    if outcome.is_ok()
1177        && function == TelegramFunction::Send
1178        && let Some(body) = &args.body
1179    {
1180        outcome = permissions.check_send_body(body);
1181    }
1182
1183    let (allowed, reason, config_path) = match outcome {
1184        Ok(()) => (true, None, None),
1185        Err(ZadError::PermissionDenied {
1186            reason,
1187            config_path,
1188            ..
1189        }) => (false, Some(reason), Some(config_path.display().to_string())),
1190        Err(e) => return Err(e),
1191    };
1192
1193    if args.json {
1194        let out = PermissionsCheckOutput {
1195            command: "telegram.permissions.check",
1196            function: args.function.clone(),
1197            allowed,
1198            reason,
1199            config_path,
1200        };
1201        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1202    } else if allowed {
1203        println!("allow");
1204    } else {
1205        println!(
1206            "deny — {}",
1207            reason.as_deref().unwrap_or("unspecified reason")
1208        );
1209        if let Some(p) = &config_path {
1210            println!("  config: {p}");
1211        }
1212    }
1213    if !allowed {
1214        std::process::exit(1);
1215    }
1216    Ok(())
1217}
1218
1219fn parse_function(name: &str) -> Result<TelegramFunction> {
1220    match name {
1221        "send" => Ok(TelegramFunction::Send),
1222        "read" => Ok(TelegramFunction::Read),
1223        "listen" => Ok(TelegramFunction::Listen),
1224        "chats" => Ok(TelegramFunction::Chats),
1225        "discover" => Ok(TelegramFunction::Discover),
1226        other => Err(ZadError::Invalid(format!(
1227            "unknown function `{other}`. Expected one of: send, read, listen, chats, discover."
1228        ))),
1229    }
1230}
1231
1232// ---------------------------------------------------------------------------
1233// self — manage the `@me` resolution target
1234// ---------------------------------------------------------------------------
1235
1236#[derive(Debug, Args)]
1237pub struct SelfArgs {
1238    #[command(subcommand)]
1239    pub action: Option<SelfAction>,
1240
1241    /// When no subcommand is given, behave like `show`.
1242    #[arg(long)]
1243    pub json: bool,
1244}
1245
1246#[derive(Debug, Subcommand)]
1247pub enum SelfAction {
1248    /// Print the stored self-chat ID (or note that it's not set).
1249    Show(SelfShowArgs),
1250    /// Set the self-chat ID directly. No validation — use `capture`
1251    /// for a validated setup.
1252    Set(SelfSetArgs),
1253    /// Clear the stored self-chat ID.
1254    Clear(SelfClearArgs),
1255    /// Poll `getUpdates` for up to 60s waiting for your first message
1256    /// to the bot, then store that private-chat ID.
1257    Capture(SelfCaptureArgs),
1258}
1259
1260#[derive(Debug, Args)]
1261pub struct SelfShowArgs {
1262    #[arg(long)]
1263    pub json: bool,
1264}
1265
1266#[derive(Debug, Args)]
1267pub struct SelfSetArgs {
1268    /// Your private-chat ID (a signed integer).
1269    pub chat_id: i64,
1270    #[arg(long)]
1271    pub json: bool,
1272}
1273
1274#[derive(Debug, Args)]
1275pub struct SelfClearArgs {
1276    #[arg(long)]
1277    pub json: bool,
1278}
1279
1280#[derive(Debug, Args)]
1281pub struct SelfCaptureArgs {
1282    /// Don't open the bot's `https://t.me/<username>` link in the
1283    /// system browser. The link is still printed either way.
1284    #[arg(long)]
1285    pub no_browser: bool,
1286    #[arg(long)]
1287    pub json: bool,
1288}
1289
1290#[derive(Debug, Serialize)]
1291struct SelfOutput {
1292    command: &'static str,
1293    self_chat_id: Option<i64>,
1294}
1295
1296async fn run_self(args: SelfArgs) -> Result<()> {
1297    match args.action {
1298        None => run_self_show(SelfShowArgs { json: args.json }),
1299        Some(SelfAction::Show(a)) => run_self_show(a),
1300        Some(SelfAction::Set(a)) => run_self_set(a),
1301        Some(SelfAction::Clear(a)) => run_self_clear(a),
1302        Some(SelfAction::Capture(a)) => run_self_capture(a).await,
1303    }
1304}
1305
1306fn run_self_show(args: SelfShowArgs) -> Result<()> {
1307    let (cfg, _scope) = effective_config()?;
1308    emit_self(args.json, "telegram.self.show", cfg.self_chat_id)
1309}
1310
1311fn run_self_set(args: SelfSetArgs) -> Result<()> {
1312    let (mut cfg, scope) = effective_config()?;
1313    cfg.self_chat_id = Some(args.chat_id);
1314    save_effective_config(&cfg, &scope)?;
1315    emit_self(args.json, "telegram.self.set", cfg.self_chat_id)
1316}
1317
1318fn run_self_clear(args: SelfClearArgs) -> Result<()> {
1319    let (mut cfg, scope) = effective_config()?;
1320    cfg.self_chat_id = None;
1321    save_effective_config(&cfg, &scope)?;
1322    emit_self(args.json, "telegram.self.clear", None)
1323}
1324
1325async fn run_self_capture(args: SelfCaptureArgs) -> Result<()> {
1326    let (mut cfg, scope) = effective_config()?;
1327    let token = load_token(&scope)?;
1328    let client = TelegramHttp::unscoped(&token);
1329    let identity = client.get_me().await?;
1330    let captured =
1331        crate::cli::service_telegram::capture_self_chat(&client, &identity, !args.no_browser)
1332            .await?;
1333    match captured {
1334        Some(c) => {
1335            cfg.self_chat_id = Some(c.chat_id);
1336            save_effective_config(&cfg, &scope)?;
1337            emit_self(args.json, "telegram.self.capture", cfg.self_chat_id)
1338        }
1339        None => {
1340            // User declined at the confirmation prompt or timeout
1341            // expired. Report the unchanged state rather than erroring —
1342            // `capture_self_chat` already printed the reason.
1343            emit_self(args.json, "telegram.self.capture", cfg.self_chat_id)
1344        }
1345    }
1346}
1347
1348fn emit_self(json: bool, command: &'static str, self_chat_id: Option<i64>) -> Result<()> {
1349    if json {
1350        println!(
1351            "{}",
1352            serde_json::to_string_pretty(&SelfOutput {
1353                command,
1354                self_chat_id
1355            })
1356            .unwrap()
1357        );
1358    } else {
1359        match self_chat_id {
1360            Some(id) => println!("self chat id: {id}"),
1361            None => println!("self chat id: not configured"),
1362        }
1363    }
1364    Ok(())
1365}
1366
1367fn save_effective_config(cfg: &TelegramServiceCfg, scope: &EffectiveScope) -> Result<()> {
1368    let path = match scope {
1369        EffectiveScope::Local(slug) => {
1370            config::path::project_service_config_path_for(slug, "telegram")?
1371        }
1372        EffectiveScope::Global => config::path::global_service_config_path("telegram")?,
1373    };
1374    config::save_flat(&path, cfg)
1375}