Skip to main content

mc_minder/monitor/
mod.rs

1use anyhow::{Context, Result};
2use log::{info, debug, warn};
3use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
4use regex::Regex;
5use std::path::PathBuf;
6use tokio::sync::mpsc;
7use tokio::sync::mpsc::Receiver;
8use std::sync::Arc;
9use parking_lot::Mutex;
10
11#[derive(Debug, Clone)]
12pub struct ChatMessage {
13    pub player: String,
14    pub content: String,
15    pub timestamp: chrono::DateTime<chrono::Local>,
16}
17
18pub struct LogMonitor {
19    log_path: PathBuf,
20    chat_pattern: Regex,
21    join_pattern: Regex,
22    leave_pattern: Regex,
23    death_pattern: Regex,
24}
25
26#[derive(Debug, Clone)]
27#[allow(dead_code)]
28pub enum LogEvent {
29    Chat(ChatMessage),
30    PlayerJoin(String),
31    PlayerLeave(String),
32    PlayerDeath(String),
33    ServerStart,
34    ServerStop,
35}
36
37#[derive(Debug, Clone, PartialEq, Copy)]
38struct FileId {
39    size: u64,
40    modified_secs: i64,
41}
42
43impl FileId {
44    fn from_metadata(metadata: &std::fs::Metadata) -> Option<Self> {
45        let size = metadata.len();
46        let modified = metadata.modified().ok()?;
47        let modified_secs = modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64;
48        Some(Self { size, modified_secs })
49    }
50}
51
52impl LogMonitor {
53    pub fn new(log_path: PathBuf) -> Result<Self> {
54        let chat_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: <([^>]+)> (.+)")
55            .context("Failed to compile chat pattern")?;
56
57        let join_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) joined the game")
58            .context("Failed to compile join pattern")?;
59
60        let leave_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) left the game")
61            .context("Failed to compile leave pattern")?;
62
63        let death_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) .*(died|was|fell|drowned|blew up|burned|froze|suffocated|starved)")
64            .context("Failed to compile death pattern")?;
65
66        Ok(Self {
67            log_path,
68            chat_pattern,
69            join_pattern,
70            leave_pattern,
71            death_pattern,
72        })
73    }
74
75    pub fn start_monitoring(self) -> Result<Receiver<LogEvent>> {
76        let (tx, rx) = mpsc::channel(100);
77
78        let log_path = self.log_path.clone();
79
80        info!("Started monitoring log file: {:?}", log_path);
81
82        let mut last_offset: u64 = 0;
83        let mut last_file_id: Option<FileId> = None;
84
85        if log_path.exists() {
86            if let Ok(metadata) = std::fs::metadata(&log_path) {
87                last_offset = metadata.len();
88                last_file_id = FileId::from_metadata(&metadata);
89            }
90        } else {
91            warn!(
92                "Log file not found: {:?}. Please start the Minecraft server first to generate the log file.",
93                log_path
94            );
95        }
96
97        let (notify_tx, notify_rx) = std::sync::mpsc::channel();
98
99        let mut watcher = RecommendedWatcher::new(
100            move |res: Result<Event, notify::Error>| {
101                if let Ok(event) = res {
102                    let _ = notify_tx.send(event);
103                }
104            },
105            Config::default(),
106        ).context("Failed to create file watcher")?;
107
108        let parent_dir = log_path
109            .parent()
110            .context("Log file has no parent directory")?
111            .to_path_buf();
112
113        let parent_dir_for_unwatch = parent_dir.clone();
114
115        watcher
116            .watch(&parent_dir, RecursiveMode::NonRecursive)
117            .context("Failed to watch log directory")?;
118
119        let patterns = (
120            self.chat_pattern,
121            self.join_pattern,
122            self.leave_pattern,
123            self.death_pattern,
124        );
125
126        std::thread::spawn(move || {
127            loop {
128                match notify_rx.recv() {
129                    Ok(event) => {
130                        if !event.paths.iter().any(|p| p.file_name().map(|n| n == "latest.log").unwrap_or(false)) {
131                            continue;
132                        }
133
134                        match event.kind {
135                            EventKind::Modify(_) | EventKind::Create(_) => {
136                                if let Ok(events) = Self::check_file_changes(
137                                    &log_path,
138                                    &mut last_offset,
139                                    &mut last_file_id,
140                                    &patterns,
141                                ) {
142                                    for log_event in events {
143                                        if tx.blocking_send(log_event).is_err() {
144                                            debug!("Receiver dropped, stopping monitor");
145                                            return;
146                                        }
147                                    }
148                                }
149                            }
150                            EventKind::Remove(_) => {
151                                debug!("Log file removed/rotated, resetting state");
152                                last_offset = 0;
153                                last_file_id = None;
154                            }
155                            _ => {}
156                        }
157                    }
158                    Err(_) => {
159                        debug!("Notify channel closed, stopping monitor");
160                        break;
161                    }
162                }
163            }
164            let _ = watcher.unwatch(&parent_dir_for_unwatch);
165        });
166
167        Ok(rx)
168    }
169
170    fn check_file_changes(
171        log_path: &PathBuf,
172        last_offset: &mut u64,
173        last_file_id: &mut Option<FileId>,
174        patterns: &(Regex, Regex, Regex, Regex),
175    ) -> Result<Vec<LogEvent>> {
176        if !log_path.exists() {
177            return Ok(Vec::new());
178        }
179
180        let metadata = std::fs::metadata(log_path)?;
181        let current_file_id = FileId::from_metadata(&metadata);
182
183        if let (Some(current), Some(last)) = (current_file_id, *last_file_id) {
184            if current != last {
185                debug!("File rotation detected, resetting offset");
186                *last_offset = 0;
187            }
188        }
189
190        let current_size = metadata.len();
191
192        if current_size < *last_offset {
193            debug!("File size decreased, resetting offset");
194            *last_offset = 0;
195        }
196
197        if current_size == *last_offset {
198            return Ok(Vec::new());
199        }
200
201        let new_content = Self::read_from_offset(log_path, *last_offset, current_size)?;
202
203        *last_offset = current_size;
204        *last_file_id = current_file_id;
205
206        let events = Self::parse_lines(&new_content, patterns);
207        Ok(events)
208    }
209
210    fn read_from_offset(log_path: &PathBuf, offset: u64, end: u64) -> Result<String> {
211        use std::fs::File;
212        use std::io::{Read, Seek, SeekFrom};
213
214        let mut file = File::open(log_path)?;
215        file.seek(SeekFrom::Start(offset))?;
216
217        let bytes_to_read = (end - offset) as usize;
218        let mut buffer = Vec::with_capacity(bytes_to_read);
219        file.take(bytes_to_read as u64).read_to_end(&mut buffer)?;
220
221        String::from_utf8(buffer.clone())
222            .map_err(|e| {
223                let lossy = String::from_utf8_lossy(&buffer);
224                warn!("UTF-8 decode error, using lossy conversion: {}", e);
225                anyhow::anyhow!("Failed to convert file content to UTF-8: {}", lossy)
226            })
227            .or_else(|_| Ok(String::from_utf8_lossy(&buffer).into_owned()))
228    }
229
230    fn parse_lines(content: &str, patterns: &(Regex, Regex, Regex, Regex)) -> Vec<LogEvent> {
231        let (chat_pattern, join_pattern, leave_pattern, death_pattern) = patterns;
232        let mut events = Vec::new();
233
234        for line in content.lines() {
235            if let Some(caps) = chat_pattern.captures(line) {
236                if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
237                    debug!("[Monitor] Parsed chat event: player='{}', content='{}'", player.as_str(), content.as_str());
238                    events.push(LogEvent::Chat(ChatMessage {
239                        player: player.as_str().to_string(),
240                        content: content.as_str().to_string(),
241                        timestamp: chrono::Local::now(),
242                    }));
243                }
244            } else if let Some(caps) = join_pattern.captures(line) {
245                if let Some(player) = caps.get(2) {
246                    events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
247                }
248            } else if let Some(caps) = leave_pattern.captures(line) {
249                if let Some(player) = caps.get(2) {
250                    events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
251                }
252            } else if let Some(caps) = death_pattern.captures(line) {
253                if let Some(player) = caps.get(2) {
254                    events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
255                }
256            }
257        }
258
259        events
260    }
261}
262
263#[derive(Debug, Clone)]
264pub enum ChatCaptureMode {
265    Tmux { session: String },
266    Process,
267    File,
268}
269
270pub struct TmuxChatCapture {
271    session: String,
272    chat_pattern: Regex,
273    seen_positions: Arc<Mutex<std::collections::HashSet<u64>>>,
274}
275
276impl TmuxChatCapture {
277    pub fn new(session: String) -> Result<Self> {
278        let chat_pattern = Regex::new(r"<([a-zA-Z0-9_]+)> (.+)")
279            .context("Failed to compile chat pattern")?;
280
281        Ok(Self {
282            session,
283            chat_pattern,
284            seen_positions: Arc::new(Mutex::new(std::collections::HashSet::new())),
285        })
286    }
287
288    pub fn mode(&self) -> ChatCaptureMode {
289        ChatCaptureMode::Tmux { session: self.session.clone() }
290    }
291
292    pub fn name(&self) -> &'static str {
293        "TmuxChatCapture"
294    }
295
296    pub fn capture_pane_output(&self) -> Result<String> {
297        use std::process::Command;
298
299        let output = Command::new("tmux")
300            .args(["capture-pane", "-p", "-t", &self.session])
301            .output()
302            .context("Failed to execute tmux capture-pane")?;
303
304        if !output.status.success() {
305            return Err(anyhow::anyhow!(
306                "tmux capture-pane failed with exit code: {:?}",
307                output.status.code()
308            ));
309        }
310
311        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
312    }
313
314    pub async fn capture_recent_messages(&mut self) -> Vec<ChatMessage> {
315        let output = match self.capture_pane_output() {
316            Ok(o) => o,
317            Err(e) => {
318                warn!("[TmuxChatCapture] Failed to capture tmux pane: {}", e);
319                return Vec::new();
320            }
321        };
322
323        let mut messages = Vec::new();
324        let mut seen = self.seen_positions.lock();
325
326        for line in output.lines().rev() {
327            let line_hash = Self::hash_line(line);
328
329            if seen.contains(&line_hash) {
330                continue;
331            }
332
333            seen.insert(line_hash);
334
335            if seen.len() > 10000 {
336                let to_remove: Vec<_> = seen.iter().take(1000).cloned().collect();
337                for r in to_remove {
338                    seen.remove(&r);
339                }
340            }
341
342            if let Some(caps) = self.chat_pattern.captures(line) {
343                if let (Some(player), Some(content)) = (caps.get(1), caps.get(2)) {
344                    debug!("[TmuxChatCapture] Parsed chat: player='{}', content='{}'", player.as_str(), content.as_str());
345                    messages.push(ChatMessage {
346                        player: player.as_str().to_string(),
347                        content: content.as_str().to_string(),
348                        timestamp: chrono::Local::now(),
349                    });
350                }
351            }
352        }
353
354        messages.reverse();
355        messages
356    }
357
358    fn hash_line(line: &str) -> u64 {
359        use std::collections::hash_map::DefaultHasher;
360        use std::hash::{Hash, Hasher};
361        let mut hasher = DefaultHasher::new();
362        line.hash(&mut hasher);
363        hasher.finish()
364    }
365}
366
367pub struct FileChatCapture {
368    log_path: PathBuf,
369    chat_pattern: Regex,
370    seen_positions: Arc<Mutex<std::collections::HashSet<u64>>>,
371}
372
373impl FileChatCapture {
374    pub fn new(log_path: PathBuf) -> Result<Self> {
375        let chat_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: <([a-zA-Z0-9_]+)> (.+)")
376            .context("Failed to compile chat pattern")?;
377
378        Ok(Self {
379            log_path,
380            chat_pattern,
381            seen_positions: Arc::new(Mutex::new(std::collections::HashSet::new())),
382        })
383    }
384
385    pub fn mode(&self) -> ChatCaptureMode {
386        ChatCaptureMode::File
387    }
388
389    pub fn name(&self) -> &'static str {
390        "FileChatCapture"
391    }
392
393    pub async fn capture_recent_messages(&mut self) -> Vec<ChatMessage> {
394        let content = match tokio::fs::read_to_string(&self.log_path).await {
395            Ok(c) => c,
396            Err(e) => {
397                warn!("[FileChatCapture] Failed to read log file: {}", e);
398                return Vec::new();
399            }
400        };
401
402        let mut messages = Vec::new();
403        let mut seen = self.seen_positions.lock();
404
405        for line in content.lines().rev().take(100) {
406            let line_hash = Self::hash_line(line);
407
408            if seen.contains(&line_hash) {
409                continue;
410            }
411
412            seen.insert(line_hash);
413
414            if let Some(caps) = self.chat_pattern.captures(line) {
415                if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
416                    debug!("[FileChatCapture] Parsed chat: player='{}', content='{}'", player.as_str(), content.as_str());
417                    messages.push(ChatMessage {
418                        player: player.as_str().to_string(),
419                        content: content.as_str().to_string(),
420                        timestamp: chrono::Local::now(),
421                    });
422                }
423            }
424        }
425
426        messages.reverse();
427        messages
428    }
429
430    fn hash_line(line: &str) -> u64 {
431        use std::collections::hash_map::DefaultHasher;
432        use std::hash::{Hash, Hasher};
433        let mut hasher = DefaultHasher::new();
434        line.hash(&mut hasher);
435        hasher.finish()
436    }
437}