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 events.jsonl path for a given loop.
110    fn get_events_path(&self, loop_id: &str) -> PathBuf {
111        if loop_id == "main" {
112            self.workspace_root.join(".ralph").join("events.jsonl")
113        } else {
114            self.workspace_root
115                .join(".worktrees")
116                .join(loop_id)
117                .join(".ralph")
118                .join("events.jsonl")
119        }
120    }
121
122    /// Append an event line to the given file atomically.
123    fn append_event(&self, path: &Path, event_line: &str) -> TelegramResult<()> {
124        use std::fs::OpenOptions;
125        use std::io::Write;
126
127        if let Some(parent) = path.parent() {
128            std::fs::create_dir_all(parent).map_err(|e| {
129                crate::error::TelegramError::EventWrite(format!(
130                    "failed to create directory {}: {}",
131                    parent.display(),
132                    e
133                ))
134            })?;
135        }
136
137        let mut file = OpenOptions::new()
138            .create(true)
139            .append(true)
140            .open(path)
141            .map_err(|e| {
142                crate::error::TelegramError::EventWrite(format!(
143                    "failed to open {}: {}",
144                    path.display(),
145                    e
146                ))
147            })?;
148
149        writeln!(file, "{}", event_line).map_err(|e| {
150            crate::error::TelegramError::EventWrite(format!(
151                "failed to write to {}: {}",
152                path.display(),
153                e
154            ))
155        })?;
156
157        Ok(())
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::state::TelegramState;
165    use std::collections::HashMap;
166    use tempfile::TempDir;
167
168    fn setup() -> (MessageHandler, TempDir, TelegramState) {
169        let dir = TempDir::new().unwrap();
170        let state_path = dir.path().join(".ralph/telegram-state.json");
171        let state_manager = StateManager::new(state_path);
172        let handler = MessageHandler::new(state_manager, dir.path());
173        let state = TelegramState {
174            chat_id: None,
175            last_seen: None,
176            last_update_id: None,
177            pending_questions: HashMap::new(),
178        };
179        (handler, dir, state)
180    }
181
182    #[test]
183    fn writes_guidance_event_to_main() {
184        let (handler, dir, mut state) = setup();
185        handler
186            .handle_message(&mut state, "don't forget logging", 123, None)
187            .unwrap();
188
189        let events_path = dir.path().join(".ralph/events.jsonl");
190        let contents = std::fs::read_to_string(events_path).unwrap();
191        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
192        assert_eq!(event["topic"], "human.guidance");
193        assert_eq!(event["payload"], "don't forget logging");
194    }
195
196    #[test]
197    fn writes_response_event_when_pending_question() {
198        let (handler, dir, mut state) = setup();
199
200        // Simulate a pending question for main loop
201        state.pending_questions.insert(
202            "main".to_string(),
203            crate::state::PendingQuestion {
204                asked_at: chrono::Utc::now(),
205                message_id: 42,
206            },
207        );
208
209        handler
210            .handle_message(&mut state, "use async", 123, Some(42))
211            .unwrap();
212
213        let events_path = dir.path().join(".ralph/events.jsonl");
214        let contents = std::fs::read_to_string(events_path).unwrap();
215        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
216        assert_eq!(event["topic"], "human.response");
217        assert_eq!(event["payload"], "use async");
218
219        // Pending question should be removed
220        assert!(!state.pending_questions.contains_key("main"));
221    }
222
223    #[test]
224    fn routes_at_prefix_to_correct_loop() {
225        let (handler, dir, mut state) = setup();
226        handler
227            .handle_message(&mut state, "@feature-auth check edge cases", 123, None)
228            .unwrap();
229
230        let events_path = dir
231            .path()
232            .join(".worktrees/feature-auth/.ralph/events.jsonl");
233        let contents = std::fs::read_to_string(events_path).unwrap();
234        let event: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
235        assert_eq!(event["topic"], "human.guidance");
236    }
237
238    #[test]
239    fn auto_detects_chat_id() {
240        let (handler, _dir, mut state) = setup();
241        assert!(state.chat_id.is_none());
242
243        handler
244            .handle_message(&mut state, "hello", 999, None)
245            .unwrap();
246
247        assert_eq!(state.chat_id, Some(999));
248    }
249}