Filter noisy messages
CI / macos-latest (push) Has been cancelled
CI / ubuntu-latest (push) Has been cancelled
CI / windows-latest (push) Has been cancelled

This commit is contained in:
pleb
2026-07-29 20:48:17 -07:00
parent 7d659167bd
commit ddc49d9ceb
4 changed files with 109 additions and 3 deletions
+11 -1
View File
@@ -63,11 +63,13 @@ running Mudmouth, validation, and multi-platform release packaging.
## Data and privacy ## 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: standard application-data directory:
- `config.toml` — endpoint, log path, event toggles, and playback settings - `config.toml` — endpoint, log path, event toggles, and playback settings
- `speakers.json` — NPC names, voice modes, and last-seen context - `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 On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform
directory is used on macOS and Windows. directory is used on macOS and Windows.
@@ -79,3 +81,11 @@ repeat_window_seconds = 15
repeat_second_speed = 1.5 repeat_second_speed = 1.5
repeat_third_plus_speed = 2.0 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! "]
```
+80
View File
@@ -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<String>,
/// Ignore dialogue whose text begins with any of these strings.
pub text_prefixes: Vec<String>,
}
impl DialogueFilters {
pub fn path() -> Result<std::path::PathBuf> {
Ok(Config::app_dir()?.join("filters.toml"))
}
pub fn load() -> Result<Self> {
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));
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
mod config; mod config;
mod eq_discovery; mod eq_discovery;
mod events; mod events;
mod filters;
mod kokoro; mod kokoro;
mod npc_voices; mod npc_voices;
mod speakers; mod speakers;
@@ -16,6 +17,7 @@ use crossterm::{
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
}; };
use events::LogEvent; use events::LogEvent;
use filters::DialogueFilters;
use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui::{Terminal, backend::CrosstermBackend};
use std::{ use std::{
io::{self, stdout}, io::{self, stdout},
@@ -53,6 +55,7 @@ struct RuntimeChannels {
worker_control: tokio_mpsc::Sender<Config>, worker_control: tokio_mpsc::Sender<Config>,
audio_control: mpsc::Sender<AudioCommand>, audio_control: mpsc::Sender<AudioCommand>,
tailer_generation: Arc<AtomicUsize>, tailer_generation: Arc<AtomicUsize>,
filters: DialogueFilters,
} }
#[tokio::main] #[tokio::main]
@@ -67,6 +70,7 @@ async fn main() -> Result<()> {
} }
let _ = npc_voices::builtin_index(); let _ = npc_voices::builtin_index();
let filters = DialogueFilters::load()?;
let speakers = speakers::SpeakerStore::load()?; let speakers = speakers::SpeakerStore::load()?;
let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>(); let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>();
let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone()); 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)); let tailer_generation = Arc::new(AtomicUsize::new(0));
if config.log_path.as_ref().is_some_and(|path| path.is_file()) { if config.log_path.as_ref().is_some_and(|path| path.is_file()) {
let path = config.log_path.clone().expect("log path was checked"); 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); let mut app = App::new(config, speakers);
@@ -92,6 +102,7 @@ async fn main() -> Result<()> {
worker_control, worker_control,
audio_control: audio_sender, audio_control: audio_sender,
tailer_generation, tailer_generation,
filters,
}; };
run_terminal(&mut app, channels).await 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()) { if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) {
spawn_log_tailer( spawn_log_tailer(
path, path,
channels.filters.clone(),
channels.worker_sender.clone(), channels.worker_sender.clone(),
channels.tailer_generation.clone(), channels.tailer_generation.clone(),
generation, generation,
+5 -1
View File
@@ -1,6 +1,7 @@
use crate::{ use crate::{
config::Config, config::Config,
events::{LogEvent, parse_line}, events::{LogEvent, parse_line},
filters::DialogueFilters,
kokoro::KokoroClient, kokoro::KokoroClient,
}; };
use anyhow::Result; use anyhow::Result;
@@ -59,6 +60,7 @@ pub enum AudioCommand {
pub fn spawn_log_tailer( pub fn spawn_log_tailer(
path: PathBuf, path: PathBuf,
filters: DialogueFilters,
sender: Sender<WorkerEvent>, sender: Sender<WorkerEvent>,
generation: Arc<AtomicUsize>, generation: Arc<AtomicUsize>,
expected_generation: usize, expected_generation: usize,
@@ -69,7 +71,9 @@ pub fn spawn_log_tailer(
match read_new_lines(&path, &mut position) { match read_new_lines(&path, &mut position) {
Ok(lines) => { Ok(lines) => {
for line in 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)); let _ = sender.send(WorkerEvent::Log(event));
} }
} }