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}