1use 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#[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(SendArgs),
41 Read(ReadArgs),
43 Listen(ListenArgs),
45 Chats(ChatsArgs),
47 Discover(DiscoverArgs),
50 Directory(DirectoryArgs),
52 Permissions(PermissionsArgs),
55 #[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#[derive(Debug, Args)]
83pub struct SendArgs {
84 #[arg(long)]
88 pub chat: Option<String>,
89
90 #[arg(long, conflicts_with = "body")]
93 pub stdin: bool,
94
95 #[arg(long = "file", value_name = "PATH", action = clap::ArgAction::Append)]
103 pub files: Vec<PathBuf>,
104
105 pub body: Option<String>,
108
109 #[arg(long)]
111 pub json: bool,
112
113 #[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 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 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#[derive(Debug, Args)]
216pub struct ReadArgs {
217 #[arg(long)]
219 pub chat: String,
220
221 #[arg(long, default_value_t = 20)]
223 pub limit: usize,
224
225 #[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 for m in msgs.iter().rev() {
293 println!("[{}] <{}> {}", m.id, m.author, m.body);
294 }
295 Ok(())
296}
297
298#[derive(Debug, Args)]
303pub struct ListenArgs {
304 #[arg(long)]
306 pub chat: String,
307
308 #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=50))]
312 pub timeout: u32,
313
314 #[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#[derive(Debug, Args)]
382pub struct ChatsArgs {
383 #[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 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#[derive(Debug, Args)]
478pub struct DiscoverArgs {
479 #[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 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
558enum 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
613fn 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
691fn 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#[derive(Debug, Args)]
730pub struct DirectoryArgs {
731 #[command(subcommand)]
732 pub action: Option<DirectoryAction>,
733
734 #[arg(long)]
736 pub json: bool,
737}
738
739#[derive(Debug, Subcommand)]
740pub enum DirectoryAction {
741 Set(DirectorySetArgs),
743 Remove(DirectoryRemoveArgs),
745 Clear(DirectoryClearArgs),
747}
748
749#[derive(Debug, Args)]
750pub struct DirectorySetArgs {
751 pub name: String,
753 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#[derive(Debug, Args)]
911pub struct PermissionsArgs {
912 #[command(subcommand)]
913 pub action: Option<PermissionsAction>,
914
915 #[arg(long)]
917 pub json: bool,
918}
919
920#[derive(Debug, Subcommand)]
921pub enum PermissionsAction {
922 Show(PermissionsShowArgs),
924 Init(PermissionsInitArgs),
926 Path(PermissionsPathArgs),
929 Check(PermissionsCheckArgs),
933 #[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 #[arg(long)]
950 pub local: bool,
951
952 #[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 #[arg(long)]
970 pub function: String,
971
972 #[arg(long)]
974 pub chat: Option<String>,
975
976 #[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 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#[derive(Debug, Args)]
1237pub struct SelfArgs {
1238 #[command(subcommand)]
1239 pub action: Option<SelfAction>,
1240
1241 #[arg(long)]
1243 pub json: bool,
1244}
1245
1246#[derive(Debug, Subcommand)]
1247pub enum SelfAction {
1248 Show(SelfShowArgs),
1250 Set(SelfSetArgs),
1253 Clear(SelfClearArgs),
1255 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 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 #[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 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}