Skip to main content

astrid_telegram/
config.rs

1//! Configuration for the Telegram bot.
2//!
3//! Loads settings from the unified Astrid config system (`~/.astrid/config.toml`)
4//! with environment variable fallbacks.
5
6use std::path::Path;
7
8use tracing::{debug, warn};
9
10use crate::error::{TelegramBotError, TelegramResult};
11
12/// Telegram bot configuration.
13#[derive(Clone)]
14pub struct TelegramConfig {
15    /// Telegram Bot API token (from `@BotFather`).
16    pub bot_token: String,
17    /// `WebSocket` URL for the daemon (e.g. `ws://127.0.0.1:3100`).
18    /// If not set, auto-discovers from `~/.astrid/daemon.port`.
19    pub daemon_url: Option<String>,
20    /// Telegram user IDs allowed to interact with the bot.
21    /// Empty means allow all users.
22    pub allowed_user_ids: Vec<u64>,
23    /// Workspace path to use when creating sessions.
24    pub workspace_path: Option<String>,
25}
26
27impl std::fmt::Debug for TelegramConfig {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("TelegramConfig")
30            .field("bot_token", &"[REDACTED]")
31            .field("daemon_url", &self.daemon_url)
32            .field("allowed_user_ids", &self.allowed_user_ids)
33            .field("workspace_path", &self.workspace_path)
34            .finish()
35    }
36}
37
38impl TelegramConfig {
39    /// Load configuration from the unified config system, falling back to
40    /// environment variables.
41    ///
42    /// Config file locations (highest priority first):
43    /// - `{workspace}/.astrid/config.toml`
44    /// - `~/.astrid/config.toml`
45    /// - `/etc/astrid/config.toml`
46    ///
47    /// Environment variable fallbacks:
48    /// - `TELEGRAM_BOT_TOKEN` → `bot_token`
49    /// - `ASTRID_DAEMON_URL` → `daemon_url`
50    /// - `TELEGRAM_ALLOWED_USERS` (comma-separated) → `allowed_user_ids`
51    /// - `ASTRID_WORKSPACE` → `workspace_path`
52    pub fn load(workspace_root: Option<&Path>) -> TelegramResult<Self> {
53        // Try loading from the unified config system.
54        let telegram_section = match astrid_config::Config::load(workspace_root) {
55            Ok(resolved) => {
56                debug!(
57                    files = ?resolved.loaded_files,
58                    "loaded config from files"
59                );
60                resolved.config.telegram
61            },
62            Err(e) => {
63                warn!("failed to load config files, using env vars only: {e}");
64                let mut section = astrid_config::TelegramSection::default();
65                if let Ok(val) = std::env::var("TELEGRAM_BOT_TOKEN")
66                    && !val.is_empty()
67                {
68                    section.bot_token = Some(val);
69                }
70                if let Ok(val) = std::env::var("ASTRID_DAEMON_URL")
71                    && !val.is_empty()
72                {
73                    section.daemon_url = Some(val);
74                }
75                if let Ok(val) = std::env::var("ASTRID_WORKSPACE")
76                    && !val.is_empty()
77                {
78                    section.workspace_path = Some(val);
79                }
80                section
81            },
82        };
83
84        // The unified config system already merges env var fallbacks for
85        // bot_token, daemon_url, and workspace_path. We just need to handle
86        // TELEGRAM_ALLOWED_USERS separately (comma-separated → Vec<u64>).
87        let bot_token = telegram_section.bot_token.ok_or_else(|| {
88            TelegramBotError::Config(
89                "bot_token is required — set [telegram] bot_token in \
90                 ~/.astrid/config.toml or TELEGRAM_BOT_TOKEN env var"
91                    .to_owned(),
92            )
93        })?;
94
95        let mut allowed_user_ids = telegram_section.allowed_user_ids;
96        if allowed_user_ids.is_empty()
97            && let Ok(val) = std::env::var("TELEGRAM_ALLOWED_USERS")
98        {
99            allowed_user_ids = val
100                .split(',')
101                .filter_map(|entry| {
102                    let trimmed = entry.trim();
103                    if trimmed.is_empty() {
104                        return None;
105                    }
106                    match trimmed.parse::<u64>() {
107                        Ok(id) => Some(id),
108                        Err(e) => {
109                            warn!(
110                                value = trimmed,
111                                error = %e,
112                                "ignoring unparseable entry in TELEGRAM_ALLOWED_USERS"
113                            );
114                            None
115                        },
116                    }
117                })
118                .collect();
119        }
120
121        Ok(Self {
122            bot_token,
123            daemon_url: telegram_section.daemon_url,
124            allowed_user_ids,
125            workspace_path: telegram_section.workspace_path,
126        })
127    }
128
129    /// Check if a user ID is allowed.
130    pub fn is_user_allowed(&self, user_id: u64) -> bool {
131        self.allowed_user_ids.is_empty() || self.allowed_user_ids.contains(&user_id)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// Helper to build a config without going through env vars.
140    fn test_config(allowed: Vec<u64>) -> TelegramConfig {
141        TelegramConfig {
142            bot_token: "test-token".to_owned(),
143            daemon_url: None,
144            allowed_user_ids: allowed,
145            workspace_path: None,
146        }
147    }
148
149    #[test]
150    fn empty_allowlist_permits_everyone() {
151        let cfg = test_config(vec![]);
152        assert!(cfg.is_user_allowed(12345));
153        assert!(cfg.is_user_allowed(99999));
154    }
155
156    #[test]
157    fn allowlist_permits_listed_users() {
158        let cfg = test_config(vec![100, 200, 300]);
159        assert!(cfg.is_user_allowed(100));
160        assert!(cfg.is_user_allowed(200));
161        assert!(cfg.is_user_allowed(300));
162    }
163
164    #[test]
165    fn allowlist_denies_unlisted_users() {
166        let cfg = test_config(vec![100, 200]);
167        assert!(!cfg.is_user_allowed(999));
168        assert!(!cfg.is_user_allowed(0));
169    }
170}