1use std::path::{Path, PathBuf};
2
3use chrono::Utc;
4
5use crate::error::TelegramResult;
6use crate::state::{StateManager, TelegramState};
7
8pub struct MessageHandler {
10 state_manager: StateManager,
11 workspace_root: PathBuf,
12}
13
14impl MessageHandler {
15 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 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 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 fn determine_target_loop(
86 &self,
87 state: &TelegramState,
88 text: &str,
89 reply_to_message_id: Option<i32>,
90 ) -> String {
91 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 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 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 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 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 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 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 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 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(×tamped_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 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}