From ddc49d9ceb01159a38597869afc496e3a9472c5d Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 29 Jul 2026 20:48:17 -0700 Subject: [PATCH] Filter noisy messages --- README.md | 12 +++++++- src/filters.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 ++++++++- src/workers.rs | 6 +++- 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/filters.rs diff --git a/README.md b/README.md index 7dcc8c4..46e2533 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,13 @@ running Mudmouth, validation, and multi-platform release packaging. ## Data and privacy -Mudmouth writes only two human-readable files in the operating system's +Mudmouth writes only three human-readable files in the operating system's standard application-data directory: - `config.toml` — endpoint, log path, event toggles, and playback settings - `speakers.json` — NPC names, voice modes, and last-seen context +- `filters.toml` — dialogue prefixes to ignore; created with the default + ambient-NPC and combat-chatter filters On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform directory is used on macOS and Windows. @@ -79,3 +81,11 @@ repeat_window_seconds = 15 repeat_second_speed = 1.5 repeat_third_plus_speed = 2.0 ``` + +To ignore additional repetitive dialogue, edit `filters.toml` and add literal +prefixes. For example: + +```toml +speaker_prefixes = ["A ", "An "] +text_prefixes = ["Time to die, ", "Guards! "] +``` diff --git a/src/filters.rs b/src/filters.rs new file mode 100644 index 0000000..2533583 --- /dev/null +++ b/src/filters.rs @@ -0,0 +1,80 @@ +use crate::{ + config::{Config, atomic_write}, + events::LogEvent, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; + +/// User-managed rules for ignoring repetitive EverQuest dialogue. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DialogueFilters { + /// Ignore dialogue whose speaker begins with any of these strings. + pub speaker_prefixes: Vec, + /// Ignore dialogue whose text begins with any of these strings. + pub text_prefixes: Vec, +} + +impl DialogueFilters { + pub fn path() -> Result { + Ok(Config::app_dir()?.join("filters.toml")) + } + + pub fn load() -> Result { + let path = Self::path()?; + if !path.exists() { + let filters = Self::default(); + atomic_write(&path, toml::to_string_pretty(&filters)?.as_bytes())?; + return Ok(filters); + } + let contents = fs::read_to_string(&path) + .with_context(|| format!("could not read {}", path.display()))?; + toml::from_str(&contents).context("could not parse dialogue filters") + } + + pub fn ignores(&self, event: &LogEvent) -> bool { + let LogEvent::NpcDialogue { speaker, text, .. } = event else { + return false; + }; + self.speaker_prefixes + .iter() + .any(|prefix| speaker.starts_with(prefix)) + || self + .text_prefixes + .iter() + .any(|prefix| text.starts_with(prefix)) + } +} + +impl Default for DialogueFilters { + fn default() -> Self { + Self { + speaker_prefixes: vec!["A ".into()], + text_prefixes: vec!["Time to die, ".into()], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Local; + + #[test] + fn filters_match_speaker_and_text_prefixes() { + let filters = DialogueFilters::default(); + let generic_speaker = LogEvent::NpcDialogue { + at: Local::now(), + speaker: "A froglok guardsman".into(), + text: "Halt!".into(), + }; + let combat_chatter = LogEvent::NpcDialogue { + at: Local::now(), + speaker: "Guard Ralnor".into(), + text: "Time to die, a crab spiderling.".into(), + }; + assert!(filters.ignores(&generic_speaker)); + assert!(filters.ignores(&combat_chatter)); + } +} diff --git a/src/main.rs b/src/main.rs index 153da9b..9446e8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod config; mod eq_discovery; mod events; +mod filters; mod kokoro; mod npc_voices; mod speakers; @@ -16,6 +17,7 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use events::LogEvent; +use filters::DialogueFilters; use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ io::{self, stdout}, @@ -53,6 +55,7 @@ struct RuntimeChannels { worker_control: tokio_mpsc::Sender, audio_control: mpsc::Sender, tailer_generation: Arc, + filters: DialogueFilters, } #[tokio::main] @@ -67,6 +70,7 @@ async fn main() -> Result<()> { } let _ = npc_voices::builtin_index(); + let filters = DialogueFilters::load()?; let speakers = speakers::SpeakerStore::load()?; let (worker_sender, worker_receiver) = mpsc::channel::(); let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone()); @@ -81,7 +85,13 @@ async fn main() -> Result<()> { let tailer_generation = Arc::new(AtomicUsize::new(0)); if config.log_path.as_ref().is_some_and(|path| path.is_file()) { let path = config.log_path.clone().expect("log path was checked"); - spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0); + spawn_log_tailer( + path, + filters.clone(), + worker_sender.clone(), + tailer_generation.clone(), + 0, + ); } let mut app = App::new(config, speakers); @@ -92,6 +102,7 @@ async fn main() -> Result<()> { worker_control, audio_control: audio_sender, tailer_generation, + filters, }; run_terminal(&mut app, channels).await } @@ -131,6 +142,7 @@ async fn run_loop( if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) { spawn_log_tailer( path, + channels.filters.clone(), channels.worker_sender.clone(), channels.tailer_generation.clone(), generation, diff --git a/src/workers.rs b/src/workers.rs index 3196e9f..dc5b220 100644 --- a/src/workers.rs +++ b/src/workers.rs @@ -1,6 +1,7 @@ use crate::{ config::Config, events::{LogEvent, parse_line}, + filters::DialogueFilters, kokoro::KokoroClient, }; use anyhow::Result; @@ -59,6 +60,7 @@ pub enum AudioCommand { pub fn spawn_log_tailer( path: PathBuf, + filters: DialogueFilters, sender: Sender, generation: Arc, expected_generation: usize, @@ -69,7 +71,9 @@ pub fn spawn_log_tailer( match read_new_lines(&path, &mut position) { Ok(lines) => { for line in lines { - if let Some(event) = parse_line(&line) { + if let Some(event) = + parse_line(&line).filter(|event| !filters.ignores(event)) + { let _ = sender.send(WorkerEvent::Log(event)); } }