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#[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(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
59pub trait ChatCapture: Send {
68 fn capture_recent_messages(&mut self) -> Vec<ChatMessage>;
72
73 fn name(&self) -> &'static str;
75}
76
77pub 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 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 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 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 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
293pub 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 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
398pub 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 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
477pub 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 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 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 Vec::new()
523 }
524
525 fn name(&self) -> &'static str {
526 "ProcessChatCapture"
527 }
528}
529
530#[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 let msg = capture.parse_line("<Steve> hello world").unwrap();
578 assert_eq!(msg.player, "Steve");
579 assert_eq!(msg.content, "hello world");
580
581 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 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}