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;
8
9#[derive(Debug, Clone)]
10#[allow(dead_code)]
11pub struct ChatMessage {
12    pub player: String,
13    pub content: String,
14    pub timestamp: chrono::DateTime<chrono::Local>,
15}
16
17pub struct LogMonitor {
18    log_path: PathBuf,
19    chat_pattern: Regex,
20    join_pattern: Regex,
21    leave_pattern: Regex,
22    death_pattern: Regex,
23}
24
25#[derive(Debug, Clone)]
26#[allow(dead_code)]
27pub enum LogEvent {
28    Chat(ChatMessage),
29    PlayerJoin(String),
30    PlayerLeave(String),
31    PlayerDeath(String),
32    ServerStart,
33    ServerStop,
34}
35
36/// 文件标识,用于检测文件轮转
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38struct FileId {
39    size: u64,
40    modified_secs: i64,
41}
42
43impl FileId {
44    /// 从文件元数据创建文件标识
45    fn from_metadata(metadata: &std::fs::Metadata) -> Option<Self> {
46        let size = metadata.len();
47        let modified = metadata.modified().ok()?;
48        let modified_secs = modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64;
49        Some(Self { size, modified_secs })
50    }
51}
52
53impl LogMonitor {
54    pub fn new(log_path: PathBuf) -> Result<Self> {
55        // 更灵活的正则表达式,支持多种 Minecraft 版本:
56        // - 时间部分:小时可以有或没有前导零 [9:30:45] 或 [09:30:45]
57        // - 线程名称:支持 [Server thread/INFO]、[Server thread/INFO] [Minecraft] 等
58        // - 玩家消息:<玩家名> 消息内容
59        let chat_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: <([^>]+)> (.+)")
60            .context("Failed to compile chat pattern")?;
61        
62        let join_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) joined the game")
63            .context("Failed to compile join pattern")?;
64        
65        let leave_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) left the game")
66            .context("Failed to compile leave pattern")?;
67        
68        let death_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) .*(died|was|fell|drowned|blew up|burned|froze|suffocated|starved)")
69            .context("Failed to compile death pattern")?;
70
71        Ok(Self {
72            log_path,
73            chat_pattern,
74            join_pattern,
75            leave_pattern,
76            death_pattern,
77        })
78    }
79
80    pub fn start_monitoring(self) -> Result<Receiver<LogEvent>> {
81        let (tx, rx) = mpsc::channel(100);
82        
83        let log_path = self.log_path.clone();
84        
85        info!("Started monitoring log file: {:?}", log_path);
86
87        let mut last_offset: u64 = 0;
88        let mut last_file_id: Option<FileId> = None;
89
90        if log_path.exists() {
91            if let Ok(metadata) = std::fs::metadata(&log_path) {
92                last_offset = metadata.len();
93                last_file_id = FileId::from_metadata(&metadata);
94            }
95        } else {
96            warn!(
97                "Log file not found: {:?}. \n\
98                 Please start the Minecraft server first to generate the log file.",
99                log_path
100            );
101        }
102
103        let (notify_tx, notify_rx) = std::sync::mpsc::channel();
104        
105        let mut watcher = RecommendedWatcher::new(
106            move |res: Result<Event, notify::Error>| {
107                if let Ok(event) = res {
108                    let _ = notify_tx.send(event);
109                }
110            },
111            Config::default(),
112        ).context("Failed to create file watcher")?;
113
114        let parent_dir = log_path
115            .parent()
116            .context("Log file has no parent directory")?
117            .to_path_buf();
118        
119        let parent_dir_for_unwatch = parent_dir.clone();
120        
121        watcher
122            .watch(&parent_dir, RecursiveMode::NonRecursive)
123            .context("Failed to watch log directory")?;
124
125        let patterns = (
126            self.chat_pattern,
127            self.join_pattern,
128            self.leave_pattern,
129            self.death_pattern,
130        );
131
132        std::thread::spawn(move || {
133            loop {
134                match notify_rx.recv() {
135                    Ok(event) => {
136                        if !event.paths.iter().any(|p| p.file_name().map(|n| n == "latest.log").unwrap_or(false)) {
137                            continue;
138                        }
139
140                        match event.kind {
141                            EventKind::Modify(_) | EventKind::Create(_) => {
142                                if let Ok(events) = Self::check_file_changes(
143                                    &log_path,
144                                    &mut last_offset,
145                                    &mut last_file_id,
146                                    &patterns,
147                                ) {
148                                    for log_event in events {
149                                        if tx.blocking_send(log_event).is_err() {
150                                            debug!("Receiver dropped, stopping monitor");
151                                            return;
152                                        }
153                                    }
154                                }
155                            }
156                            EventKind::Remove(_) => {
157                                debug!("Log file removed/rotated, resetting state");
158                                last_offset = 0;
159                                last_file_id = None;
160                            }
161                            _ => {}
162                        }
163                    }
164                    Err(_) => {
165                        debug!("Notify channel closed, stopping monitor");
166                        break;
167                    }
168                }
169            }
170            let _ = watcher.unwatch(&parent_dir_for_unwatch);
171        });
172
173        Ok(rx)
174    }
175
176    fn check_file_changes(
177        log_path: &PathBuf,
178        last_offset: &mut u64,
179        last_file_id: &mut Option<FileId>,
180        patterns: &(Regex, Regex, Regex, Regex),
181    ) -> Result<Vec<LogEvent>> {
182        // 检查文件是否存在
183        if !log_path.exists() {
184            return Ok(Vec::new());
185        }
186
187        let metadata = std::fs::metadata(log_path)?;
188        let current_file_id = FileId::from_metadata(&metadata);
189
190        // 检测文件轮转:如果文件标识变化,重置偏移量
191        if let (Some(current), Some(last)) = (current_file_id, *last_file_id) {
192            if current != last {
193                debug!("File rotation detected, resetting offset");
194                *last_offset = 0;
195            }
196        }
197
198        let current_size = metadata.len();
199
200        // 如果文件变小了,说明可能被截断或轮转,重置偏移量
201        if current_size < *last_offset {
202            debug!("File size decreased, resetting offset");
203            *last_offset = 0;
204        }
205
206        // 没有新内容
207        if current_size == *last_offset {
208            return Ok(Vec::new());
209        }
210
211        // 从 last_offset 位置读取到文件末尾
212        let new_content = Self::read_from_offset(log_path, *last_offset, current_size)?;
213
214        // 更新状态
215        *last_offset = current_size;
216        *last_file_id = current_file_id;
217
218        let events = Self::parse_lines(&new_content, patterns);
219        Ok(events)
220    }
221
222    /// 从指定偏移量读取文件内容
223    fn read_from_offset(log_path: &PathBuf, offset: u64, end: u64) -> Result<String> {
224        use std::fs::File;
225        use std::io::{Read, Seek, SeekFrom};
226
227        let mut file = File::open(log_path)?;
228        file.seek(SeekFrom::Start(offset))?;
229
230        let bytes_to_read = (end - offset) as usize;
231        let mut buffer = Vec::with_capacity(bytes_to_read);
232        file.take(bytes_to_read as u64).read_to_end(&mut buffer)?;
233
234        String::from_utf8(buffer.clone())
235            .map_err(|e| {
236                let lossy = String::from_utf8_lossy(&buffer);
237                warn!("UTF-8 decode error, using lossy conversion: {}", e);
238                anyhow::anyhow!("Failed to convert file content to UTF-8: {}", lossy)
239            })
240            .or_else(|_| Ok(String::from_utf8_lossy(&buffer).into_owned()))
241    }
242
243    fn parse_lines(content: &str, patterns: &(Regex, Regex, Regex, Regex)) -> Vec<LogEvent> {
244        let (chat_pattern, join_pattern, leave_pattern, death_pattern) = patterns;
245        let mut events = Vec::new();
246        
247        for line in content.lines() {
248            if let Some(caps) = chat_pattern.captures(line) {
249                if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
250                    events.push(LogEvent::Chat(ChatMessage {
251                        player: player.as_str().to_string(),
252                        content: content.as_str().to_string(),
253                        timestamp: chrono::Local::now(),
254                    }));
255                }
256            } else if let Some(caps) = join_pattern.captures(line) {
257                if let Some(player) = caps.get(2) {
258                    events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
259                }
260            } else if let Some(caps) = leave_pattern.captures(line) {
261                if let Some(player) = caps.get(2) {
262                    events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
263                }
264            } else if let Some(caps) = death_pattern.captures(line) {
265                if let Some(player) = caps.get(2) {
266                    events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
267                }
268            }
269        }
270        
271        events
272    }
273}