1use std::time::{Duration, Instant};
14
15use crate::cli::DialoguerExt;
16use async_trait::async_trait;
17use clap::Args;
18use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme};
19
20use crate::cli::lifecycle::{
21 BotTokenArgs, CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg,
22 SecretRef, resolve_scopes,
23};
24use zad::config::{ProjectConfig, TelegramServiceCfg};
25use zad::error::{Result, ZadError};
26use zad::secrets::{self, Scope};
27use zad::service::telegram::TelegramHttp;
28use zad::service::telegram::client::BotIdentity;
29
30pub const CAPTURE_TIMEOUT: Duration = Duration::from_secs(60);
35const CAPTURE_POLL_INTERVAL: Duration = Duration::from_millis(1500);
36
37const DEFAULT_SCOPES: &[&str] = &["chats", "messages.read", "messages.send"];
38const ALL_SCOPES: &[&str] = &["chats", "messages.read", "messages.send", "gateway.listen"];
39
40pub struct TelegramSecrets {
48 pub bot_token: String,
49}
50
51#[derive(Debug, Args)]
56pub struct CreateArgs {
57 #[command(flatten)]
58 pub base: CreateArgsBase,
59 #[command(flatten)]
60 pub token: BotTokenArgs,
61 #[command(flatten)]
62 pub scopes: ScopesArg,
63 #[arg(long)]
68 pub default_chat: Option<String>,
69 #[arg(long)]
76 pub self_chat: Option<i64>,
77}
78
79impl CreateArgsLike for CreateArgs {
80 fn base(&self) -> &CreateArgsBase {
81 &self.base
82 }
83}
84
85pub struct TelegramLifecycle;
90
91#[async_trait]
92impl LifecycleService for TelegramLifecycle {
93 const NAME: &'static str = "telegram";
94 const DISPLAY: &'static str = "Telegram";
95 type Cfg = TelegramServiceCfg;
96 type Secrets = TelegramSecrets;
97
98 fn enable_in_project(cfg: &mut ProjectConfig) {
99 cfg.enable_telegram();
100 }
101
102 fn disable_in_project(cfg: &mut ProjectConfig) {
103 cfg.disable_telegram();
104 }
105
106 async fn validate(_cfg: &TelegramServiceCfg, creds: &mut TelegramSecrets) -> Result<String> {
107 TelegramHttp::unscoped(&creds.bot_token)
108 .validate_token()
109 .await
110 .map_err(|e| ZadError::Service {
111 name: Self::NAME,
112 message: format!("token validation failed: {e}"),
113 })
114 }
115
116 fn store_secrets(creds: &TelegramSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
117 let account = secrets::account(Self::NAME, "bot", scope);
118 secrets::store(&account, &creds.bot_token)?;
119 Ok(vec![SecretRef {
120 label: "token",
121 account,
122 present: true,
123 }])
124 }
125
126 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
127 let account = secrets::account(Self::NAME, "bot", scope);
128 secrets::delete(&account)?;
129 Ok(vec![SecretRef {
130 label: "token",
131 account,
132 present: false,
133 }])
134 }
135
136 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
137 let account = secrets::account(Self::NAME, "bot", scope);
138 let present = secrets::load(&account)?.is_some();
139 Ok(vec![SecretRef {
140 label: "token",
141 account,
142 present,
143 }])
144 }
145
146 fn load_secrets(scope: Scope<'_>) -> Result<Option<TelegramSecrets>> {
147 let account = secrets::account(Self::NAME, "bot", scope);
148 Ok(secrets::load(&account)?.map(|bot_token| TelegramSecrets { bot_token }))
149 }
150
151 fn cfg_human(cfg: &TelegramServiceCfg) -> Vec<(&'static str, String)> {
152 let mut out = vec![];
153 if let Some(c) = &cfg.default_chat {
154 out.push(("chat", c.clone()));
155 }
156 if let Some(id) = cfg.self_chat_id {
157 out.push(("self", id.to_string()));
158 }
159 out
160 }
161
162 fn cfg_json(cfg: &TelegramServiceCfg) -> serde_json::Value {
163 serde_json::json!({
164 "default_chat": cfg.default_chat,
165 "self_chat_id": cfg.self_chat_id,
166 })
167 }
168
169 fn scopes_of(cfg: &TelegramServiceCfg) -> &[String] {
170 &cfg.scopes
171 }
172
173 }
177
178#[async_trait]
179impl CliLifecycle for TelegramLifecycle {
180 type CreateArgs = CreateArgs;
181
182 async fn resolve(
183 args: &CreateArgs,
184 non_interactive: bool,
185 ) -> Result<(TelegramServiceCfg, TelegramSecrets)> {
186 let open_browser = !args.base.no_browser;
187 let default_chat = resolve_default_chat(args.default_chat.as_deref(), non_interactive)?;
188 let scopes = resolve_scopes(
189 args.scopes.scopes.as_deref(),
190 DEFAULT_SCOPES,
191 ALL_SCOPES,
192 non_interactive,
193 )?;
194 let bot_token = resolve_telegram_bot_token(
195 args.token.bot_token.as_deref(),
196 args.token.bot_token_env.as_deref(),
197 open_browser,
198 non_interactive,
199 )?;
200 let self_chat_id =
201 resolve_self_chat_id(args.self_chat, &bot_token, open_browser, non_interactive).await?;
202 Ok((
203 TelegramServiceCfg {
204 scopes,
205 default_chat,
206 self_chat_id,
207 },
208 TelegramSecrets { bot_token },
209 ))
210 }
211}
212
213fn theme() -> ColorfulTheme {
218 ColorfulTheme::default()
219}
220
221fn resolve_default_chat(flag: Option<&str>, non_interactive: bool) -> Result<Option<String>> {
222 if let Some(v) = flag {
223 validate_chat(v)?;
224 return Ok(Some(v.to_string()));
225 }
226 if non_interactive {
227 return Ok(None);
228 }
229
230 println!();
231 println!("Default chat accepts any of:");
232 println!(" • @username (public channel or supergroup)");
233 println!(" • numeric chat ID (e.g. -1001234567890 for a group)");
234 println!(" • alias (resolved later via the directory)");
235 println!("For private chats, message @userinfobot to get your chat ID.");
236 println!("Leave blank to skip — you can set a default chat later.");
237
238 let v: String = Input::with_theme(&theme())
239 .with_prompt("Default chat ID, @username, or alias (leave blank for none)")
240 .allow_empty(true)
241 .interact_text()
242 .into_zad()?;
243 if v.trim().is_empty() {
244 Ok(None)
245 } else {
246 validate_chat(&v).map(|_| Some(v))
247 }
248}
249
250fn resolve_telegram_bot_token(
255 flag: Option<&str>,
256 env_flag: Option<&str>,
257 open_browser: bool,
258 non_interactive: bool,
259) -> Result<String> {
260 if let Some(env) = env_flag {
261 return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
262 }
263 if let Some(v) = flag {
264 return Ok(v.to_string());
265 }
266 if non_interactive {
267 return Err(ZadError::MissingRequired("--bot-token or --bot-token-env"));
268 }
269
270 let url = BOTFATHER_URL;
271 println!();
272 println!("Telegram bot tokens are issued by @BotFather:");
273 println!(" {url}");
274 println!("Send /newbot to create a bot, or /mybots → pick a bot → \"API Token\"");
275 println!("for an existing one. Copy the token and paste it below.");
276 if open_browser {
277 let _ = open::that(url);
278 }
279
280 let v = Password::with_theme(&theme())
281 .with_prompt("Telegram bot token")
282 .interact()
283 .into_zad()?;
284 Ok(v)
285}
286
287const BOTFATHER_URL: &str = "https://t.me/BotFather";
288
289fn validate_chat(v: &str) -> Result<()> {
300 let trimmed = v.trim();
301 if trimmed.is_empty() {
302 return Err(ZadError::Invalid("default-chat must not be empty".into()));
303 }
304 if trimmed.parse::<i64>().is_ok() {
305 return Ok(());
306 }
307 if let Some(name) = trimmed.strip_prefix('@') {
308 if name.len() >= 5 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
309 return Ok(());
310 }
311 return Err(ZadError::Invalid(format!(
312 "default-chat `{v}` looks like a @username but isn't valid (5+ chars, [A-Za-z0-9_])"
313 )));
314 }
315 if trimmed.chars().any(char::is_whitespace) {
318 return Err(ZadError::Invalid(format!(
319 "default-chat `{v}` contains whitespace"
320 )));
321 }
322 Ok(())
323}
324
325#[derive(Debug, Clone)]
333pub struct CapturedChat {
334 pub chat_id: i64,
335 pub first_name: String,
336 pub username: Option<String>,
337}
338
339async fn resolve_self_chat_id(
345 flag: Option<i64>,
346 bot_token: &str,
347 open_browser: bool,
348 non_interactive: bool,
349) -> Result<Option<i64>> {
350 if let Some(id) = flag {
351 return Ok(Some(id));
352 }
353 if non_interactive {
354 return Ok(None);
355 }
356
357 let client = TelegramHttp::unscoped(bot_token);
358 let identity = client.get_me().await.map_err(|e| ZadError::Service {
359 name: "telegram",
360 message: format!("getMe failed while preparing self-chat capture: {e}"),
361 })?;
362
363 println!();
364 println!("Optional: configure `@me` so commands like");
365 println!(" zad telegram send --chat @me \"hello\"");
366 println!("resolve to your own private chat with the bot.");
367
368 let want = Confirm::with_theme(&theme())
369 .with_prompt("Capture your self-chat now?")
370 .default(true)
371 .interact()
372 .into_zad()?;
373 if !want {
374 println!(
375 "Skipping. Run `zad telegram self capture` or `zad telegram self set <id>` later."
376 );
377 return Ok(None);
378 }
379
380 match capture_self_chat(&client, &identity, open_browser).await? {
381 Some(c) => Ok(Some(c.chat_id)),
382 None => Ok(None),
383 }
384}
385
386pub async fn capture_self_chat(
395 client: &TelegramHttp,
396 identity: &BotIdentity,
397 open_browser: bool,
398) -> Result<Option<CapturedChat>> {
399 let handle = identity
400 .username
401 .as_deref()
402 .map(|u| format!("@{u}"))
403 .unwrap_or_else(|| identity.first_name.clone());
404 let bot_url = identity
405 .username
406 .as_deref()
407 .map(|u| format!("https://t.me/{u}"));
408
409 println!();
410 println!("Open Telegram and send {handle} any message (e.g. /start).");
411 if let Some(url) = &bot_url {
412 println!(" {url}");
413 if open_browser {
414 let _ = open::that(url);
415 }
416 }
417 println!(
418 "Waiting up to {}s for your message…",
419 CAPTURE_TIMEOUT.as_secs()
420 );
421
422 let deadline = Instant::now() + CAPTURE_TIMEOUT;
423 let bot_id = identity.id;
424 while Instant::now() < deadline {
425 let updates = client
426 .get_updates_unscoped(None)
427 .await
428 .map_err(|e| ZadError::Service {
429 name: "telegram",
430 message: format!("getUpdates failed during capture: {e}"),
431 })?;
432 for update in &updates {
433 if let Some(msg) = update.message.as_ref()
434 && msg.chat.kind == "private"
435 && let Some(from) = msg.from.as_ref()
436 && from.id != bot_id
437 {
438 let captured = CapturedChat {
439 chat_id: msg.chat.id,
440 first_name: from.first_name.clone(),
441 username: from.username.clone(),
442 };
443 return confirm_captured(captured);
444 }
445 }
446 tokio::time::sleep(CAPTURE_POLL_INTERVAL).await;
447 }
448
449 println!(
450 "No message received within {}s. Skipping — run `zad telegram self capture` when you're ready.",
451 CAPTURE_TIMEOUT.as_secs()
452 );
453 Ok(None)
454}
455
456fn confirm_captured(c: CapturedChat) -> Result<Option<CapturedChat>> {
457 let handle = c
458 .username
459 .as_deref()
460 .map(|u| format!(" (@{u})"))
461 .unwrap_or_default();
462 let label = format!(
463 "Identified you as {}{handle}, chat id {}.",
464 c.first_name, c.chat_id
465 );
466 println!(" ✓ {label}");
467 let ok = Confirm::with_theme(&theme())
468 .with_prompt("Save as self-chat?")
469 .default(true)
470 .interact()
471 .into_zad()?;
472 if ok { Ok(Some(c)) } else { Ok(None) }
473}