Skip to main content

zad_cli/cli/
service_telegram.rs

1//! Telegram's plug-in to the generic service lifecycle.
2//!
3//! Everything in this file is Telegram-specific — scope names, the
4//! prompt for a default chat, the shape of the Telegram credential
5//! (`TelegramSecrets` = one bot token), and the call that validates
6//! the token against the Bot API. The generic plumbing (flag parsing,
7//! path resolution, JSON envelopes, human banners, keychain I/O
8//! sequencing) lives in `src/cli/lifecycle.rs` and is shared with
9//! every other service.
10//!
11//! See `docs/services.md#adding-a-new-service` for the full recipe.
12
13use 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
30/// How long `capture_self_chat` will wait for the user to send a
31/// message to the bot before giving up. Picked to be long enough for a
32/// context-switch to the Telegram client, short enough to fail fast if
33/// the user never sends anything.
34pub 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
40// ---------------------------------------------------------------------------
41// Telegram's credential shape
42// ---------------------------------------------------------------------------
43
44/// Telegram uses one long-lived bot token, issued by @BotFather. It
45/// carries the bot's identity on its own — there is no separate
46/// application ID.
47pub struct TelegramSecrets {
48    pub bot_token: String,
49}
50
51// ---------------------------------------------------------------------------
52// Telegram's `zad service create telegram` args
53// ---------------------------------------------------------------------------
54
55#[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    /// Optional default chat for verbs that omit `--chat`. Accepts a
64    /// numeric chat ID (negative for groups/supergroups), a
65    /// `@username` (channels, public supergroups), or a directory
66    /// alias.
67    #[arg(long)]
68    pub default_chat: Option<String>,
69    /// Private-chat ID for the human user this bot belongs to.
70    /// Resolved from the literal `@me` in later send targets. If
71    /// omitted in interactive mode, `create` offers to capture it by
72    /// polling for your first message to the bot; if omitted in
73    /// non-interactive mode, the field is left unset (you can fill
74    /// it later via `zad telegram self capture|set`).
75    #[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
85// ---------------------------------------------------------------------------
86// The trait impl — this is the entire Telegram-specific surface
87// ---------------------------------------------------------------------------
88
89pub 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    // No `post_create_hint`: Telegram bots are added to chats by an
174    // admin pasting `@botname` into the chat, not by visiting a URL.
175    // Offering a link would be misleading.
176}
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
213// ---------------------------------------------------------------------------
214// Telegram-specific prompt helpers
215// ---------------------------------------------------------------------------
216
217fn 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
250/// Telegram-specific bot-token prompt: same flag/env contract as the
251/// generic `resolve_bot_token`, but the interactive path surfaces (and
252/// optionally opens) the @BotFather chat, since that's the only source
253/// of a Telegram bot token — there's no developer portal.
254fn 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
289/// Lightweight sanity-check on a chat reference. Accepts:
290///
291/// - a signed decimal integer (`12345`, `-1001234567890`),
292/// - a `@username` (letters/digits/underscores, at least 5 chars per
293///   Telegram's rule),
294/// - a bare alias (anything non-empty that isn't obviously neither of
295///   the above).
296///
297/// The real membership / reachability check happens at the Bot API,
298/// when the first runtime verb fires.
299fn 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    // Bare alias — accept anything non-whitespace for the directory to
316    // resolve later.
317    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// ---------------------------------------------------------------------------
326// Self-chat capture
327// ---------------------------------------------------------------------------
328
329/// Snapshot of the first private-chat message we saw during capture.
330/// `chat_id` is the value persisted to config; the remaining fields are
331/// used only to render the confirmation prompt.
332#[derive(Debug, Clone)]
333pub struct CapturedChat {
334    pub chat_id: i64,
335    pub first_name: String,
336    pub username: Option<String>,
337}
338
339/// Resolve `self_chat_id` for `zad service create telegram`. If
340/// `--self-chat` was passed, use it verbatim. In non-interactive mode
341/// we leave it unset (the user can run `zad telegram self set` later).
342/// Interactively, we show the bot's `@username` (from `getMe`), ask if
343/// the user wants to capture now, and on `yes` run the polling loop.
344async 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
386/// Poll `getUpdates` for up to [`CAPTURE_TIMEOUT`] seconds, looking for
387/// the first private-chat message whose `from.id` differs from the
388/// bot's own ID. Returns `Some(CapturedChat)` on success (and after the
389/// user confirms the detected identity), `None` if the user skipped or
390/// the timeout elapsed.
391///
392/// Shared between the create-time path and `zad telegram self capture`
393/// so both use the exact same prompts and filtering.
394pub 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}