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::collections::HashSet;
9use std::sync::Arc;
10use parking_lot::Mutex;
11
12// ============================================================
13// Shared Types
14// ============================================================
15
16#[derive(Debug, Clone)]
17pub struct ChatMessage {
18    pub player: String,
19    pub content: String,
20    pub timestamp: chrono::DateTime<chrono::Local>,
21}
22
23#[derive(Debug, Clone)]
24#[allow(dead_code)]
25pub enum LogEvent {
26    // Chat events now come exclusively from ChatCapture implementations.
27    // LogMonitor no longer emits this variant, but it's kept for
28    // backward compatibility with server_run.rs event processing.
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
52#[derive(Debug, Clone)]
53pub enum ChatCaptureMode {
54    Tmux { session: String },
55    Process,
56    File,
57}
58
59// ============================================================
60// ChatCapture Trait - Unified interface for chat sources
61// ============================================================
62
63/// Trait for chat message capture. Each implementation provides
64/// a single source of truth for chat messages, eliminating the
65/// double-processing bug where both LogMonitor and ChatCapture
66/// detected the same messages.
67pub trait ChatCapture: Send {
68    /// Capture recent chat messages since the last call.
69    /// Implementations should use deduplication to avoid processing
70    /// the same message twice.
71    fn capture_recent_messages(&mut self) -> Vec<ChatMessage>;
72
73    /// Returns the name of this capture implementation for logging.
74    fn name(&self) -> &'static str;
75}
76
77// ============================================================
78// LogMonitor - Watches log file for non-chat events ONLY
79// ============================================================
80
81/// Monitors a log file for server lifecycle events (join, leave, death,
82/// start, stop). Chat messages are NOT emitted by LogMonitor — they
83/// come from ChatCapture implementations instead.
84pub struct LogMonitor {
85    log_path: PathBuf,
86    join_pattern: Regex,
87    leave_pattern: Regex,
88    death_pattern: Regex,
89}
90
91impl LogMonitor {
92    pub fn new(log_path: PathBuf) -> Result<Self> {
93        // Join pattern: handles vanilla player names
94        let join_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: ([a-zA-Z0-9_]+) joined the game")
95            .context("Failed to compile join pattern")?;
96
97        // Leave pattern: handles vanilla player names
98        let leave_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: ([a-zA-Z0-9_]+) left the game")
99            .context("Failed to compile leave pattern")?;
100
101        // Death pattern: handles various death messages
102        let death_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: ([a-zA-Z0-9_]+) .*(died|was|fell|drowned|blew up|burned|froze|suffocated|starved)")
103            .context("Failed to compile death pattern")?;
104
105        Ok(Self {
106            log_path,
107            join_pattern,
108            leave_pattern,
109            death_pattern,
110        })
111    }
112
113    pub fn start_monitoring(self) -> Result<Receiver<LogEvent>> {
114        let (tx, rx) = mpsc::channel(100);
115
116        let log_path = self.log_path.clone();
117
118        info!("Started monitoring log file: {:?}", log_path);
119
120        let mut last_offset: u64 = 0;
121        let mut last_file_id: Option<FileId> = None;
122
123        if log_path.exists() {
124            if let Ok(metadata) = std::fs::metadata(&log_path) {
125                last_offset = metadata.len();
126                last_file_id = FileId::from_metadata(&metadata);
127            }
128        } else {
129            warn!(
130                "Log file not found: {:?}. Please start the Minecraft server first to generate the log file.",
131                log_path
132            );
133        }
134
135        let (notify_tx, notify_rx) = std::sync::mpsc::channel();
136
137        let mut watcher = RecommendedWatcher::new(
138            move |res: Result<Event, notify::Error>| {
139                if let Ok(event) = res {
140                    let _ = notify_tx.send(event);
141                }
142            },
143            Config::default(),
144        ).context("Failed to create file watcher")?;
145
146        let parent_dir = log_path
147            .parent()
148            .context("Log file has no parent directory")?
149            .to_path_buf();
150
151        let parent_dir_for_unwatch = parent_dir.clone();
152
153        watcher
154            .watch(&parent_dir, RecursiveMode::NonRecursive)
155            .context("Failed to watch log directory")?;
156
157        let patterns = (
158            self.join_pattern,
159            self.leave_pattern,
160            self.death_pattern,
161        );
162
163        std::thread::spawn(move || {
164            loop {
165                match notify_rx.recv() {
166                    Ok(event) => {
167                        if !event.paths.iter().any(|p| p.file_name().map(|n| n == "latest.log").unwrap_or(false)) {
168                            continue;
169                        }
170
171                        match event.kind {
172                            EventKind::Modify(_) | EventKind::Create(_) => {
173                                if let Ok(events) = Self::check_file_changes(
174                                    &log_path,
175                                    &mut last_offset,
176                                    &mut last_file_id,
177                                    &patterns,
178                                ) {
179                                    for log_event in events {
180                                        if tx.blocking_send(log_event).is_err() {
181                                            debug!("Receiver dropped, stopping monitor");
182                                            return;
183                                        }
184                                    }
185                                }
186                            }
187                            EventKind::Remove(_) => {
188                                debug!("Log file removed/rotated, resetting state");
189                                last_offset = 0;
190                                last_file_id = None;
191                            }
192                            _ => {}
193                        }
194                    }
195                    Err(_) => {
196                        debug!("Notify channel closed, stopping monitor");
197                        break;
198                    }
199                }
200            }
201            let _ = watcher.unwatch(&parent_dir_for_unwatch);
202        });
203
204        Ok(rx)
205    }
206
207    fn check_file_changes(
208        log_path: &PathBuf,
209        last_offset: &mut u64,
210        last_file_id: &mut Option<FileId>,
211        patterns: &(Regex, Regex, Regex),
212    ) -> Result<Vec<LogEvent>> {
213        if !log_path.exists() {
214            return Ok(Vec::new());
215        }
216
217        let metadata = std::fs::metadata(log_path)?;
218        let current_file_id = FileId::from_metadata(&metadata);
219
220        if let (Some(current), Some(last)) = (current_file_id, *last_file_id) {
221            if current != last {
222                debug!("File rotation detected, resetting offset");
223                *last_offset = 0;
224            }
225        }
226
227        let current_size = metadata.len();
228
229        if current_size < *last_offset {
230            debug!("File size decreased, resetting offset");
231            *last_offset = 0;
232        }
233
234        if current_size == *last_offset {
235            return Ok(Vec::new());
236        }
237
238        let new_content = Self::read_from_offset(log_path, *last_offset, current_size)?;
239
240        *last_offset = current_size;
241        *last_file_id = current_file_id;
242
243        let events = Self::parse_lines(&new_content, patterns);
244        Ok(events)
245    }
246
247    fn read_from_offset(log_path: &PathBuf, offset: u64, end: u64) -> Result<String> {
248        use std::fs::File;
249        use std::io::{Read, Seek, SeekFrom};
250
251        let mut file = File::open(log_path)?;
252        file.seek(SeekFrom::Start(offset))?;
253
254        let bytes_to_read = (end - offset) as usize;
255        let mut buffer = Vec::with_capacity(bytes_to_read);
256        file.take(bytes_to_read as u64).read_to_end(&mut buffer)?;
257
258        String::from_utf8(buffer.clone())
259            .map_err(|e| {
260                let lossy = String::from_utf8_lossy(&buffer);
261                warn!("UTF-8 decode error, using lossy conversion: {}", e);
262                anyhow::anyhow!("Failed to convert file content to UTF-8: {}", lossy)
263            })
264            .or_else(|_| Ok(String::from_utf8_lossy(&buffer).into_owned()))
265    }
266
267    /// Parse log lines for non-chat events only.
268    /// Chat events are handled by ChatCapture implementations.
269    fn parse_lines(content: &str, patterns: &(Regex, Regex, Regex)) -> Vec<LogEvent> {
270        let (join_pattern, leave_pattern, death_pattern) = patterns;
271        let mut events = Vec::new();
272
273        for line in content.lines() {
274            if let Some(caps) = join_pattern.captures(line) {
275                if let Some(player) = caps.get(2) {
276                    events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
277                }
278            } else if let Some(caps) = leave_pattern.captures(line) {
279                if let Some(player) = caps.get(2) {
280                    events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
281                }
282            } else if let Some(caps) = death_pattern.captures(line) {
283                if let Some(player) = caps.get(2) {
284                    events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
285                }
286            }
287        }
288
289        events
290    }
291}
292
293// ============================================================
294// TmuxChatCapture - Captures chat from tmux session pane
295// ============================================================
296
297pub struct TmuxChatCapture {
298    session: String,
299    chat_pattern: Regex,
300    seen_positions: Arc<Mutex<HashSet<u64>>>,
301}
302
303impl TmuxChatCapture {
304    pub fn new(session: String) -> Result<Self> {
305        // Handle both vanilla: <Player> message
306        // and Fabric: [Not Secure] <Player> message
307        let chat_pattern = Regex::new(r"(?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)")
308            .context("Failed to compile chat pattern")?;
309
310        Ok(Self {
311            session,
312            chat_pattern,
313            seen_positions: Arc::new(Mutex::new(HashSet::new())),
314        })
315    }
316
317    pub fn mode(&self) -> ChatCaptureMode {
318        ChatCaptureMode::Tmux { session: self.session.clone() }
319    }
320
321    pub fn capture_pane_output(&self) -> Result<String> {
322        use std::process::Command;
323
324        let output = Command::new("tmux")
325            .args(["capture-pane", "-p", "-t", &self.session])
326            .output()
327            .context("Failed to execute tmux capture-pane")?;
328
329        if !output.status.success() {
330            return Err(anyhow::anyhow!(
331                "tmux capture-pane failed with exit code: {:?}",
332                output.status.code()
333            ));
334        }
335
336        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
337    }
338
339    fn hash_line(line: &str) -> u64 {
340        use std::collections::hash_map::DefaultHasher;
341        use std::hash::{Hash, Hasher};
342        let mut hasher = DefaultHasher::new();
343        line.hash(&mut hasher);
344        hasher.finish()
345    }
346}
347
348impl ChatCapture for TmuxChatCapture {
349    fn capture_recent_messages(&mut self) -> Vec<ChatMessage> {
350        let output = match self.capture_pane_output() {
351            Ok(o) => o,
352            Err(e) => {
353                warn!("[TmuxChatCapture] Failed to capture tmux pane: {}", e);
354                return Vec::new();
355            }
356        };
357
358        let mut messages = Vec::new();
359        let mut seen = self.seen_positions.lock();
360
361        for line in output.lines().rev() {
362            let line_hash = Self::hash_line(line);
363
364            if seen.contains(&line_hash) {
365                continue;
366            }
367
368            seen.insert(line_hash);
369
370            if seen.len() > 10000 {
371                let to_remove: Vec<_> = seen.iter().take(1000).cloned().collect();
372                for r in to_remove {
373                    seen.remove(&r);
374                }
375            }
376
377            if let Some(caps) = self.chat_pattern.captures(line) {
378                if let (Some(player), Some(content)) = (caps.get(1), caps.get(2)) {
379                    debug!("[TmuxChatCapture] Parsed chat: player='{}', content='{}'", player.as_str(), content.as_str());
380                    messages.push(ChatMessage {
381                        player: player.as_str().to_string(),
382                        content: content.as_str().to_string(),
383                        timestamp: chrono::Local::now(),
384                    });
385                }
386            }
387        }
388
389        messages.reverse();
390        messages
391    }
392
393    fn name(&self) -> &'static str {
394        "TmuxChatCapture"
395    }
396}
397
398// ============================================================
399// FileChatCapture - Captures chat from log file
400// ============================================================
401
402pub struct FileChatCapture {
403    log_path: PathBuf,
404    chat_pattern: Regex,
405    seen_positions: Arc<Mutex<HashSet<u64>>>,
406}
407
408impl FileChatCapture {
409    pub fn new(log_path: PathBuf) -> Result<Self> {
410        // Handle both vanilla and [Not Secure] prefixed chat messages in log files
411        let chat_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)")
412            .context("Failed to compile chat pattern")?;
413
414        Ok(Self {
415            log_path,
416            chat_pattern,
417            seen_positions: Arc::new(Mutex::new(HashSet::new())),
418        })
419    }
420
421    pub fn mode(&self) -> ChatCaptureMode {
422        ChatCaptureMode::File
423    }
424
425    fn hash_line(line: &str) -> u64 {
426        use std::collections::hash_map::DefaultHasher;
427        use std::hash::{Hash, Hasher};
428        let mut hasher = DefaultHasher::new();
429        line.hash(&mut hasher);
430        hasher.finish()
431    }
432}
433
434impl ChatCapture for FileChatCapture {
435    fn capture_recent_messages(&mut self) -> Vec<ChatMessage> {
436        let content = match std::fs::read_to_string(&self.log_path) {
437            Ok(c) => c,
438            Err(e) => {
439                warn!("[FileChatCapture] Failed to read log file: {}", e);
440                return Vec::new();
441            }
442        };
443
444        let mut messages = Vec::new();
445        let mut seen = self.seen_positions.lock();
446
447        for line in content.lines().rev().take(100) {
448            let line_hash = Self::hash_line(line);
449
450            if seen.contains(&line_hash) {
451                continue;
452            }
453
454            seen.insert(line_hash);
455
456            if let Some(caps) = self.chat_pattern.captures(line) {
457                if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
458                    debug!("[FileChatCapture] Parsed chat: player='{}', content='{}'", player.as_str(), content.as_str());
459                    messages.push(ChatMessage {
460                        player: player.as_str().to_string(),
461                        content: content.as_str().to_string(),
462                        timestamp: chrono::Local::now(),
463                    });
464                }
465            }
466        }
467
468        messages.reverse();
469        messages
470    }
471
472    fn name(&self) -> &'static str {
473        "FileChatCapture"
474    }
475}
476
477// ============================================================
478// ProcessChatCapture - Parses chat from process stdout lines
479// (Used by ForegroundProcess in TUI mode)
480// ============================================================
481
482pub struct ProcessChatCapture {
483    chat_pattern: Regex,
484    #[allow(dead_code)]
485    seen_positions: Arc<Mutex<HashSet<u64>>>,
486}
487
488impl ProcessChatCapture {
489    pub fn new() -> Result<Self> {
490        // Handle both vanilla: <Player> message
491        // and Fabric: [Not Secure] <Player> message
492        let chat_pattern = Regex::new(r"(?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)")
493            .context("Failed to compile chat pattern")?;
494
495        Ok(Self {
496            chat_pattern,
497            seen_positions: Arc::new(Mutex::new(HashSet::new())),
498        })
499    }
500
501    /// Parse a single line for chat messages.
502    /// Used by ForegroundProcess to parse stdout lines.
503    pub fn parse_line(&self, line: &str) -> Option<ChatMessage> {
504        if let Some(caps) = self.chat_pattern.captures(line) {
505            if let (Some(player), Some(content)) = (caps.get(1), caps.get(2)) {
506                return Some(ChatMessage {
507                    player: player.as_str().to_string(),
508                    content: content.as_str().to_string(),
509                    timestamp: chrono::Local::now(),
510                });
511            }
512        }
513        None
514    }
515}
516
517impl ChatCapture for ProcessChatCapture {
518    fn capture_recent_messages(&mut self) -> Vec<ChatMessage> {
519        // ProcessChatCapture doesn't poll; messages are pushed to it
520        // by the ForegroundProcess via parse_line(). This method
521        // returns empty since we don't have a file/tmux to poll.
522        Vec::new()
523    }
524
525    fn name(&self) -> &'static str {
526        "ProcessChatCapture"
527    }
528}
529
530// ============================================================
531// Tests
532// ============================================================
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_tmux_chat_pattern_vanilla() {
540        let pattern = Regex::new(r"(?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)").unwrap();
541        let caps = pattern.captures("<Steve> hello world").unwrap();
542        assert_eq!(caps.get(1).unwrap().as_str(), "Steve");
543        assert_eq!(caps.get(2).unwrap().as_str(), "hello world");
544    }
545
546    #[test]
547    fn test_tmux_chat_pattern_not_secure() {
548        let pattern = Regex::new(r"(?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)").unwrap();
549        let caps = pattern.captures("[Not Secure] <Player_1> !help").unwrap();
550        assert_eq!(caps.get(1).unwrap().as_str(), "Player_1");
551        assert_eq!(caps.get(2).unwrap().as_str(), "!help");
552    }
553
554    #[test]
555    fn test_file_chat_pattern_vanilla() {
556        let pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)").unwrap();
557        let line = "[12:34:56] [Server thread/INFO]: <Steve> hello";
558        let caps = pattern.captures(line).unwrap();
559        assert_eq!(caps.get(2).unwrap().as_str(), "Steve");
560        assert_eq!(caps.get(3).unwrap().as_str(), "hello");
561    }
562
563    #[test]
564    fn test_file_chat_pattern_not_secure() {
565        let pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (?:\[Not Secure\] )?<([a-zA-Z0-9_]+)> (.+)").unwrap();
566        let line = "[12:34:56] [Server thread/INFO]: [Not Secure] <Player_1> !help";
567        let caps = pattern.captures(line).unwrap();
568        assert_eq!(caps.get(2).unwrap().as_str(), "Player_1");
569        assert_eq!(caps.get(3).unwrap().as_str(), "!help");
570    }
571
572    #[test]
573    fn test_process_chat_capture_parse_line() {
574        let capture = ProcessChatCapture::new().unwrap();
575        
576        // Vanilla
577        let msg = capture.parse_line("<Steve> hello world").unwrap();
578        assert_eq!(msg.player, "Steve");
579        assert_eq!(msg.content, "hello world");
580
581        // Not Secure prefix
582        let msg = capture.parse_line("[Not Secure] <Player_1> !help").unwrap();
583        assert_eq!(msg.player, "Player_1");
584        assert_eq!(msg.content, "!help");
585
586        // Non-chat line
587        assert!(capture.parse_line("Server started on port 25565").is_none());
588    }
589
590    #[test]
591    fn test_log_monitor_join_pattern() {
592        let pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: ([a-zA-Z0-9_]+) joined the game").unwrap();
593        let line = "[12:34:56] [Server thread/INFO]: Steve joined the game";
594        let caps = pattern.captures(line).unwrap();
595        assert_eq!(caps.get(2).unwrap().as_str(), "Steve");
596    }
597
598    #[test]
599    fn test_log_monitor_leave_pattern() {
600        let pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: ([a-zA-Z0-9_]+) left the game").unwrap();
601        let line = "[12:34:56] [Server thread/INFO]: Player_1 left the game";
602        let caps = pattern.captures(line).unwrap();
603        assert_eq!(caps.get(2).unwrap().as_str(), "Player_1");
604    }
605}