Skip to main content

ralph_telegram/
state.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::TelegramResult;
8
9/// Persistent state for the Telegram bot, stored at `.ralph/telegram-state.json`.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TelegramState {
12    /// The chat ID for the human operator (auto-detected from first message).
13    pub chat_id: Option<i64>,
14
15    /// Timestamp of the last message seen.
16    pub last_seen: Option<DateTime<Utc>>,
17
18    /// Last Telegram update ID processed by the bot.
19    #[serde(default)]
20    pub last_update_id: Option<i32>,
21
22    /// Pending questions keyed by loop ID, tracking which message awaits a reply.
23    #[serde(default)]
24    pub pending_questions: HashMap<String, PendingQuestion>,
25}
26
27/// A question sent to the human that is awaiting a response.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PendingQuestion {
30    /// When the question was sent.
31    pub asked_at: DateTime<Utc>,
32
33    /// The Telegram message ID, used to match reply-to routing.
34    pub message_id: i32,
35}
36
37/// Manages persistence of Telegram bot state to disk.
38pub struct StateManager {
39    path: PathBuf,
40}
41
42impl StateManager {
43    /// Create a new StateManager that reads/writes to the given path.
44    pub fn new(path: impl Into<PathBuf>) -> Self {
45        Self { path: path.into() }
46    }
47
48    /// Load state from disk. Returns `None` if the file doesn't exist.
49    pub fn load(&self) -> TelegramResult<Option<TelegramState>> {
50        if !self.path.exists() {
51            return Ok(None);
52        }
53        let contents = std::fs::read_to_string(&self.path)?;
54        let state: TelegramState = serde_json::from_str(&contents)?;
55        Ok(Some(state))
56    }
57
58    /// Save state to disk using atomic write (temp file + rename).
59    pub fn save(&self, state: &TelegramState) -> TelegramResult<()> {
60        let json = serde_json::to_string_pretty(state)?;
61        let tmp_path = self.path.with_extension("json.tmp");
62
63        if let Some(parent) = self.path.parent() {
64            std::fs::create_dir_all(parent)?;
65        }
66
67        std::fs::write(&tmp_path, &json)?;
68        std::fs::rename(&tmp_path, &self.path)?;
69        Ok(())
70    }
71
72    /// Load existing state or create a fresh empty state.
73    pub fn load_or_default(&self) -> TelegramResult<TelegramState> {
74        Ok(self.load()?.unwrap_or_else(|| TelegramState {
75            chat_id: None,
76            last_seen: None,
77            last_update_id: None,
78            pending_questions: HashMap::new(),
79        }))
80    }
81
82    /// Add a pending question for a given loop.
83    pub fn add_pending_question(
84        &self,
85        state: &mut TelegramState,
86        loop_id: &str,
87        message_id: i32,
88    ) -> TelegramResult<()> {
89        state.pending_questions.insert(
90            loop_id.to_string(),
91            PendingQuestion {
92                asked_at: Utc::now(),
93                message_id,
94            },
95        );
96        self.save(state)
97    }
98
99    /// Remove a pending question for a given loop.
100    pub fn remove_pending_question(
101        &self,
102        state: &mut TelegramState,
103        loop_id: &str,
104    ) -> TelegramResult<()> {
105        state.pending_questions.remove(loop_id);
106        self.save(state)
107    }
108
109    /// Given a reply_to_message_id, find which loop it belongs to.
110    pub fn get_loop_for_reply(
111        &self,
112        state: &TelegramState,
113        reply_message_id: i32,
114    ) -> Option<String> {
115        state
116            .pending_questions
117            .iter()
118            .find(|(_, q)| q.message_id == reply_message_id)
119            .map(|(loop_id, _)| loop_id.clone())
120    }
121
122    /// Return the path to the state file.
123    pub fn path(&self) -> &Path {
124        &self.path
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use tempfile::TempDir;
132
133    fn test_manager() -> (StateManager, TempDir) {
134        let dir = TempDir::new().unwrap();
135        let path = dir.path().join("telegram-state.json");
136        (StateManager::new(path), dir)
137    }
138
139    #[test]
140    fn load_missing_file_returns_none() {
141        let (mgr, _dir) = test_manager();
142        assert!(mgr.load().unwrap().is_none());
143    }
144
145    #[test]
146    fn save_and_load_round_trip() {
147        let (mgr, _dir) = test_manager();
148        let state = TelegramState {
149            chat_id: Some(123_456),
150            last_seen: Some(Utc::now()),
151            last_update_id: Some(101),
152            pending_questions: HashMap::new(),
153        };
154        mgr.save(&state).unwrap();
155
156        let loaded = mgr.load().unwrap().unwrap();
157        assert_eq!(loaded.chat_id, Some(123_456));
158        assert_eq!(loaded.last_update_id, Some(101));
159    }
160
161    #[test]
162    fn corrupted_json_returns_error() {
163        let (mgr, _dir) = test_manager();
164        std::fs::write(mgr.path(), "not json").unwrap();
165        assert!(mgr.load().is_err());
166    }
167
168    #[test]
169    fn pending_question_tracking() {
170        let (mgr, _dir) = test_manager();
171        let mut state = mgr.load_or_default().unwrap();
172
173        mgr.add_pending_question(&mut state, "main", 42).unwrap();
174        assert!(state.pending_questions.contains_key("main"));
175        assert_eq!(state.pending_questions["main"].message_id, 42);
176
177        mgr.remove_pending_question(&mut state, "main").unwrap();
178        assert!(!state.pending_questions.contains_key("main"));
179    }
180
181    #[test]
182    fn reply_routing_lookup() {
183        let (mgr, _dir) = test_manager();
184        let mut state = mgr.load_or_default().unwrap();
185
186        mgr.add_pending_question(&mut state, "main", 10).unwrap();
187        mgr.add_pending_question(&mut state, "feature-auth", 20)
188            .unwrap();
189
190        assert_eq!(mgr.get_loop_for_reply(&state, 10), Some("main".to_string()));
191        assert_eq!(
192            mgr.get_loop_for_reply(&state, 20),
193            Some("feature-auth".to_string())
194        );
195        assert_eq!(mgr.get_loop_for_reply(&state, 99), None);
196    }
197}