Skip to main content

mur_common/bridge/
telegram_config.rs

1//! Track C2 — Telegram bridge configuration schema.
2//!
3//! `TelegramConfig` lives alongside an agent's `routes.yaml` (typically as
4//! `telegram.yaml`) and captures everything needed for the runtime to long-poll
5//! Telegram and route messages to the C1 bridge plumbing. Bot tokens are
6//! **never** stored in this struct — only the macOS Keychain account name that
7//! references them (`bot_token_keychain_account`).
8//!
9//! `PrivacyMode` controls whether the bridge accepts updates from groups in
10//! addition to direct messages. The default is `DmOnly` — the safest setting
11//! for a personal companion. `AllowGroups` requires `allow_groups[]` to be
12//! populated explicitly.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// Whether the bridge accepts updates from groups in addition to direct
18/// messages. Default = `DmOnly`.
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum PrivacyMode {
22    /// Only direct messages from `chat_id` are accepted; group updates are
23    /// dropped at the bridge edge.
24    #[default]
25    DmOnly,
26    /// Group updates are accepted, but only from `chat_id` values listed in
27    /// `allow_groups[]` on the parent `TelegramConfig`.
28    AllowGroups,
29}
30
31/// Telegram-specific bridge configuration.
32///
33/// The bot token is **not** stored here; only the keychain account name. At
34/// runtime the bridge resolves `bot_token_keychain_account` against the macOS
35/// Keychain (or the Linux/Windows native equivalents) and keeps the secret in
36/// memory only.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct TelegramConfig {
39    /// Public bot username from BotFather (e.g. `MyAgentBot`).
40    pub bot_username: String,
41    /// Keychain account name where the bot token is stored. The service is
42    /// `mur-agent`; the account format is conventionally
43    /// `{bridge_id}/telegram_bot_token`.
44    pub bot_token_keychain_account: String,
45    /// Telegram chat ID for the primary (DM) chat the bridge serves.
46    pub chat_id: i64,
47    /// Privacy gate for inbound updates (default `DmOnly`).
48    #[serde(default)]
49    pub privacy_mode: PrivacyMode,
50    /// Group chat IDs allowed when `privacy_mode = AllowGroups`. Ignored when
51    /// `privacy_mode = DmOnly`.
52    #[serde(default)]
53    pub allow_groups: Vec<i64>,
54    /// Timestamp at which the operator acknowledged the Telegram E2E
55    /// disclosure (Telegram DMs are not E2E encrypted by default). `None`
56    /// means the disclosure has not been acknowledged yet — the runtime can
57    /// use this to gate first-run flows.
58    #[serde(default)]
59    pub e2e_disclosure_acked_at: Option<DateTime<Utc>>,
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use chrono::{TimeZone, Utc};
66
67    #[test]
68    fn round_trips_yaml() {
69        let cfg = TelegramConfig {
70            bot_username: "MyAgentBot".into(),
71            bot_token_keychain_account: "tg-bridge-1/telegram_bot_token".into(),
72            chat_id: 123456789,
73            privacy_mode: PrivacyMode::DmOnly,
74            allow_groups: vec![],
75            e2e_disclosure_acked_at: Some(Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap()),
76        };
77        let s = serde_yaml_ng::to_string(&cfg).unwrap();
78        let back: TelegramConfig = serde_yaml_ng::from_str(&s).unwrap();
79        assert_eq!(back, cfg);
80    }
81
82    #[test]
83    fn default_privacy_is_dm_only() {
84        let s = "bot_username: bot\nbot_token_keychain_account: a\nchat_id: 1\n";
85        let cfg: TelegramConfig = serde_yaml_ng::from_str(s).unwrap();
86        assert_eq!(cfg.privacy_mode, PrivacyMode::DmOnly);
87    }
88
89    #[test]
90    fn allow_groups_deserialize() {
91        let s = "bot_username: b\nbot_token_keychain_account: a\nchat_id: 1\nprivacy_mode: allow_groups\nallow_groups: [-1001, -1002]\n";
92        let cfg: TelegramConfig = serde_yaml_ng::from_str(s).unwrap();
93        assert_eq!(cfg.privacy_mode, PrivacyMode::AllowGroups);
94        assert_eq!(cfg.allow_groups, vec![-1001, -1002]);
95    }
96
97    #[test]
98    fn ack_chrono_parse() {
99        let s = "bot_username: b\nbot_token_keychain_account: a\nchat_id: 1\ne2e_disclosure_acked_at: 2026-05-04T00:00:00Z\n";
100        let cfg: TelegramConfig = serde_yaml_ng::from_str(s).unwrap();
101        assert!(cfg.e2e_disclosure_acked_at.is_some());
102    }
103
104    #[test]
105    fn missing_token_account_errors() {
106        let s = "bot_username: b\nchat_id: 1\n";
107        let r: Result<TelegramConfig, _> = serde_yaml_ng::from_str(s);
108        assert!(r.is_err());
109    }
110}