ralph_telegram/
handler.rs1use 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 {
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 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 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 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}