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}