Skip to main content

ralph_telegram/
handler.rs

1use std::path::{Path, PathBuf};
2
3use chrono::Utc;
4
5use crate::error::TelegramResult;
6use crate::state::{StateManager, TelegramState};
7
8/// Processes incoming Telegram messages and writes events to the correct loop's events.jsonl.
9pub struct MessageHandler {
10    state_manager: StateManager,
11    workspace_root: PathBuf,
12}
13
14impl MessageHandler {
15    /// Create a new message handler rooted at the given workspace.
16    pub fn new(state_manager: StateManager, workspace_root: impl Into<PathBuf>) -> Self {
17        Self {
18            state_manager,
19            workspace_root: workspace_root.into(),
20        }
21    }
22
23    /// Handle an incoming message from Telegram.
24    ///
25    /// Determines target loop, classifies as response or guidance, and appends
26    /// the appropriate event to the loop's events.jsonl.
27    ///
28    /// Returns the event topic that was written (`"human.response"` or `"human.guidance"`).
29    pub fn handle_message(
30        &self,
31        state: &mut TelegramState,
32        text: &str,
33        chat_id: i64,
34        reply_to_message_id: Option<i32>,
35    ) -> TelegramResult<String> {
36        // Auto-detect chat ID from first message
37        if state.chat_id.is_none() {
38            state.chat_id = Some(chat_id);
39            self.state_manager.save(state)?;
40            tracing::info!(chat_id, "auto-detected chat ID from first message");
41        }
42
43        let target_loop = self.determine_target_loop(state, text, reply_to_message_id);
44        let events_path = self.get_events_path(&target_loop);
45        let is_response = state.pending_questions.contains_key(&target_loop);
46
47        let topic = if is_response {
48            "human.response"
49        } else {
50            "human.guidance"
51        };
52
53        let timestamp = Utc::now().to_rfc3339();
54        let event_json = serde_json::json!({
55            "topic": topic,
56            "payload": text,
57            "ts": timestamp,
58        });
59        let event_line = serde_json::to_string(&event_json)?;
60
61        self.append_event(&events_path, &event_line)?;
62
63        if is_response {
64            self.state_manager
65                .remove_pending_question(state, &target_loop)?;
66        }
67
68        tracing::info!(
69            topic,
70            target_loop,
71            "wrote {} event for loop {}",
72            topic,
73            target_loop
74        );
75
76        Ok(topic.to_string())
77    }
78
79    /// Determine which loop a message is targeted at.
80    ///
81    /// Priority:
82    /// 1. Reply to a pending question message → that loop
83    /// 2. `@loop-id` prefix → extracted loop ID
84    /// 3. Default → "main"
85    fn determine_target_loop(
86        &self,
87        state: &TelegramState,
88        text: &str,
89        reply_to_message_id: Option<i32>,
90    ) -> String {
91        // Check reply-to routing
92        if let Some(reply_id) = reply_to_message_id
93            && let Some(loop_id) = self.state_manager.get_loop_for_reply(state, reply_id)
94        {
95            return loop_id;
96        }
97
98        // Check @loop-id prefix
99        if let Some(loop_id) = text.strip_prefix('@')
100            && let Some(id) = loop_id.split_whitespace().next()
101            && !id.is_empty()
102        {
103            return id.to_string();
104        }
105
106        "main".to_string()
107    }
108
109    /// Get the active events file path for a given loop.
110    ///
111    /// Reads the `current-events` marker to find the timestamped events file.
112    /// Falls back to the default `events.jsonl` if the marker doesn't exist.
113    fn get_events_path(&self, loop_id: &str) -> PathBuf {
114        let ralph_dir = if loop_id == "main" {
115            self.workspace_root.join(".ralph")
116        } else {
117            self.workspace_root
118                .join(".worktrees")
119                .join(loop_id)
120                .join(".ralph")
121        };
122
123        let marker_path = ralph_dir.join("current-events");
124        if let Ok(contents) = std::fs::read_to_string(&marker_path) {
125            let relative = contents.trim();
126            if !relative.is_empty() {
127                // Marker contains a path relative to workspace root
128                // (e.g., ".ralph/events-20260201-210033.jsonl")
129                if loop_id == "main" {
130                    return self.workspace_root.join(relative);
131                } else {
132                    return self
133                        .workspace_root
134                        .join(".worktrees")
135                        .join(loop_id)
136                        .join(relative);
137                }
138            }
139        }
140
141        ralph_dir.join("events.jsonl")
142    }
143
144    /// Append an event line to the given file atomically.
145    fn append_event(&self, path: &Path, event_line: &str) -> TelegramResult<()> {
146        use std::fs::OpenOptions;
147        use std::io::Write;
148
149        if let Some(parent) = path.parent() {
150            std::fs::create_dir_all(parent).map_err(|e| {
151                crate::error::TelegramError::EventWrite(format!(
152                    "failed to create directory {}: {}",
153                    parent.display(),
154                    e
155                ))
156            })?;
157        }
158
159        let mut file = OpenOptions::new()
160            .create(true)
161            .append(true)
162            .open(path)
163            .map_err(|e| {
164                crate::error::TelegramError::EventWrite(format!(
165                    "failed to open {}: {}",
166                    path.display(),
167                    e
168                ))
169            })?;
170
171        writeln!(file, "{}", event_line).map_err(|e| {
172            crate::error::TelegramError::EventWrite(format!(
173                "failed to write to {}: {}",
174                path.display(),
175                e
176            ))
177        })?;
178
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::state::TelegramState;
187    use std::collections::HashMap;
188    use tempfile::TempDir;
189
190    fn setup() -> (MessageHandler, TempDir, TelegramState) {
191        let dir = TempDir::new().unwrap();
192        let state_path = dir.path().join(".ralph/telegram-state.json");
193        let state_manager = StateManager::new(state_path);
194        let handler = MessageHandler::new(state_manager, dir.path());
195        let state = TelegramState {
196            chat_id: None,
197            last_seen: None,
198            last_update_id: None,
199            pending_questions: HashMap::new(),
200        };
201        (handler, dir, state)
202    }
203
204    #[test]
205    fn writes_guidance_event_to_main() {
206        let (handler, dir, mut state) = setup();
207        handler
208            .handle_message(&mut state, "don't forget logging", 123, None)
209            .unwrap();
210
211        let events_path = dir.path().join(".ralph/events.jsonl");
212        let contents = std::fs::read_to_string(events_path).unwrap();
213        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
214        assert_eq!(event["topic"], "human.guidance");
215        assert_eq!(event["payload"], "don't forget logging");
216    }
217
218    #[test]
219    fn writes_response_event_when_pending_question() {
220        let (handler, dir, mut state) = setup();
221
222        // Simulate a pending question for main loop
223        state.pending_questions.insert(
224            "main".to_string(),
225            crate::state::PendingQuestion {
226                asked_at: chrono::Utc::now(),
227                message_id: 42,
228            },
229        );
230
231        handler
232            .handle_message(&mut state, "use async", 123, Some(42))
233            .unwrap();
234
235        let events_path = dir.path().join(".ralph/events.jsonl");
236        let contents = std::fs::read_to_string(events_path).unwrap();
237        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
238        assert_eq!(event["topic"], "human.response");
239        assert_eq!(event["payload"], "use async");
240
241        // Pending question should be removed
242        assert!(!state.pending_questions.contains_key("main"));
243    }
244
245    #[test]
246    fn routes_at_prefix_to_correct_loop() {
247        let (handler, dir, mut state) = setup();
248        handler
249            .handle_message(&mut state, "@feature-auth check edge cases", 123, None)
250            .unwrap();
251
252        let events_path = dir
253            .path()
254            .join(".worktrees/feature-auth/.ralph/events.jsonl");
255        let contents = std::fs::read_to_string(events_path).unwrap();
256        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
257        assert_eq!(event["topic"], "human.guidance");
258    }
259
260    #[test]
261    fn auto_detects_chat_id() {
262        let (handler, _dir, mut state) = setup();
263        assert!(state.chat_id.is_none());
264
265        handler
266            .handle_message(&mut state, "hello", 999, None)
267            .unwrap();
268
269        assert_eq!(state.chat_id, Some(999));
270    }
271
272    #[test]
273    fn writes_to_timestamped_events_file_when_marker_exists() {
274        let (handler, dir, mut state) = setup();
275
276        // Create the .ralph dir and a current-events marker pointing to a timestamped file
277        let ralph_dir = dir.path().join(".ralph");
278        std::fs::create_dir_all(&ralph_dir).unwrap();
279        std::fs::write(
280            ralph_dir.join("current-events"),
281            ".ralph/events-20260201-210033.jsonl",
282        )
283        .unwrap();
284
285        handler
286            .handle_message(&mut state, "progress update", 123, None)
287            .unwrap();
288
289        // Event should be written to the timestamped file, not events.jsonl
290        let timestamped_path = dir.path().join(".ralph/events-20260201-210033.jsonl");
291        assert!(
292            timestamped_path.exists(),
293            "event should be written to timestamped events file"
294        );
295
296        let contents = std::fs::read_to_string(&timestamped_path).unwrap();
297        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
298        assert_eq!(event["topic"], "human.guidance");
299        assert_eq!(event["payload"], "progress update");
300
301        // The old default events.jsonl should NOT exist
302        let default_path = dir.path().join(".ralph/events.jsonl");
303        assert!(
304            !default_path.exists(),
305            "event should NOT be written to default events.jsonl when marker exists"
306        );
307    }
308}