bhcli

A TUI for chatting on LE PHP Chats
git clone https://git.dasho.dev/bhcli.git
Log | Files | Refs | README

commit cb141a849ed6414526e4b22faf87b20a1630c878
parent d36b7456588cd8f363fee081148bcf98ac226b13
Author: Dasho <git@dasho.dev>
Date:   Wed, 16 Jul 2025 00:14:53 +0100

feat(moderation): Adds moderation commands and content filtering

Implements chat moderation commands for banning users (by username or exact name), filtering messages, and listing/removing filters.

Adds a content scoring system to detect harmful content based on regular expressions and keyword matching.

Updates dependencies and configuration file handling for filter persistence.

Diffstat:
ACMDS.md | 34++++++++++++++++++++++++++++++++++
MCargo.lock | 14+++++++-------
MCargo.toml | 2+-
MREADME.md | 28++++++++++++++++++++++++++++
Asrc/harm.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 546++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
6 files changed, 611 insertions(+), 111 deletions(-)

diff --git a/CMDS.md b/CMDS.md @@ -0,0 +1,34 @@ +# Commands + +## Chat Commands +- `/pm <user> <msg>` private message +- `/kick <user> [msg]` kick a user +- `/ban <user>` ban usernames containing `<user>` (also kicks) +- `/ban "<user>"` ban an exact username +- `/banlist` list banned username filters +- `/banexactlist` list exact banned usernames +- `/unban <user>` remove banned username +- `/unban "<user>"` remove exact banned username +- `/filter <text>` filter messages containing text +- `/unfilter <text>` remove message filter +- `/filterlist` list filtered message terms +- `/ignore <user>` ignore user +- `/unignore <user>` unignore user +- `/dl` delete last message +- `/dlN` delete last N messages (e.g. `/dl5`) +- `/dall` delete all messages +- `/u <path> [@target] [msg]` upload file + +## Keyboard shortcuts +- `Ctrl+k` prefill `/kick <username>` for selected message +- `Ctrl+b` prefill `/ban <username>` for selected message +- `Ctrl+Shift+B` prefill `/ban "<username>"` for selected message +- `t` tag selected user +- `p` pm selected user +- `y` or `Ctrl+C` copy selected message +- `Shift+Y` copy first link in message +- `m` toggle notifications +- `Shift+G` guest view +- `Shift+M` members view +- `Ctrl+D`/`PageDown` scroll down +- `Ctrl+U`/`PageUp` scroll up diff --git a/Cargo.lock b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -612,9 +612,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -1788,9 +1788,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" -version = "0.10.66" +version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ "bitflags 2.6.0", "cfg-if", @@ -1820,9 +1820,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.103" +version = "0.9.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml @@ -17,7 +17,7 @@ clipboard = "0.5.0" colors-transform = "0.2.4" confy = "0.5.1" crossbeam = "0.8.1" -crossbeam-channel = "0.5.1" +crossbeam-channel = "0.5.15" crossterm = { version = "0.26.1" } http = "0.2.4" image = "0.24.6" diff --git a/README.md b/README.md @@ -24,6 +24,14 @@ Pre-buit binaries can be found on the [official website](http://git.dkforestseea - Delete all messages `/dall` - Ignore someone `/ignore username` - Unignore someone `/unignore username` +- Ban a username and kick `/ban username` +- Ban a username exactly `/ban "username"` +- Filter messages containing text `/filter text` +- List banned usernames `/banlist` +- List exact banned usernames `/banexactlist` +- List filtered message terms `/filterlist` +- Unban a username `/unban username` +- Remove a message filter `/unfilter text` - Toggle notifications sound `m` - Toggle a "guest" view, by filtering out PMs and "Members chat" `shift+G` - Toggle a "members" view, by filtering out PMs and "Guest chat" `shift+M` @@ -33,6 +41,7 @@ Pre-buit binaries can be found on the [official website](http://git.dkforestseea - Directly tag author of selected message `t` will prefil the input with `@username ` - Directly private message author of selected message `p` will prefil the input with `/pm username ` - Shortcut to kick author of selected message `ctrl+k` will prefil the input with `/kick username ` +- Shortcut to ban author of selected message `ctrl+b` will prefil the input with `/ban username ` - captcha is displayed directly in terminal 10 times the real size - Upload file `/u C:\path\to\file.png @username message` (@username is optional) `@members` for members group - `<tab>` to autocomplete usernames while typing @@ -121,3 +130,22 @@ Comands must start from "!" in the textbox, but "!" are not required in config. command1 = "This is the mesage that will be posted" hello = "hello everyone !" ``` + +## Configuration file + +The configuration is stored using `confy`. On Linux this is usually +`~/.config/bhcli/bhcli.toml`. You can edit this file to preload profiles, +create custom commands and maintain filters. + +To manually add or remove banned usernames or message filters you can edit the +`bad_usernames`, `bad_exact_usernames` and `bad_messages` arrays in this file: + +```toml +bad_usernames = ["spammer1", "spammer2"] +bad_exact_usernames = ["baduser"] +bad_messages = ["buy now", "free money"] +``` + +Filters modified using `/ban`, `/ban "name"`, `/filter`, `/unban` and `/unfilter` are saved +back to this file automatically and any custom commands in the `[commands]` +section are preserved. diff --git a/src/harm.rs b/src/harm.rs @@ -0,0 +1,98 @@ +use regex::Regex; + +/// The type of harmful content detected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reason { + RacialSlur, + CsabTalk, + CsabRequest, +} + +impl Reason { + pub fn description(&self) -> &'static str { + match self { + Reason::RacialSlur => "using a racial slur (sorry if this is false)", + Reason::CsabTalk => "referencing child sexual abuse material (sorry if this is false)", + Reason::CsabRequest => "requesting child sexual abuse material (sorry if this is false)", + } + } +} + +/// Result of scoring a message. +pub struct ScoreResult { + pub score: u32, + pub reason: Option<Reason>, +} + +/// Return a severity score between 0 and 100 based on harmful content and +/// provide a reason when content is detected. +pub fn score_message(message: &str) -> ScoreResult { + let msg = message.to_lowercase(); + let collapsed: String = msg.chars().filter(|c| c.is_alphanumeric()).collect(); + let normalized: String = collapsed + .chars() + .map(|c| match c { + '0' => 'o', + '1' => 'i', + '3' => 'e', + '4' => 'a', + '5' => 's', + '7' => 't', + _ => c, + }) + .collect(); + + let mut score = 0u32; + let mut reason = None; + + // Detect uses of racial slurs (N-word and common variants) + let nword_re = Regex::new(r"nigg(?:er|a)").unwrap(); + if nword_re.is_match(&msg) || normalized.contains("nigger") { + let directed_re = Regex::new(r"(?:you|u|@\S+).{0,20}?nigg(?:er|a)").unwrap(); + if directed_re.is_match(&msg) { + score = score.max(70); + } else { + score = score.max(40); + } + reason.get_or_insert(Reason::RacialSlur); + } + + // Detect CSAM related talk (various obfuscations) + let csam_terms = ["csam", "childporn", "pedo", "chees pizza", "childsex", "childsexualabuse", "cp"]; + if csam_terms.iter().any(|t| msg.contains(t) || normalized.contains(t)) { + let request_re = Regex::new( + r"\b(send|share|looking|where|has|download|anyone|link|give|provide)\b", + ) + .unwrap(); + if request_re.is_match(&msg) { + score = score.max(90); + reason = Some(Reason::CsabRequest); + } else { + score = score.max(50); + reason.get_or_insert(Reason::CsabTalk); + } + } + + if score > 100 { + score = 100; + } + + ScoreResult { score, reason } +} + +/// Determine which action should be taken based on the score. +pub fn action_from_score(score: u32) -> Option<Action> { + match score { + 0..=39 => None, + 40..=92 => Some(Action::Warn), + 93..=99 => Some(Action::Kick), + _ => Some(Action::Ban), + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Action { + Warn, + Kick, + Ban, +} diff --git a/src/main.rs b/src/main.rs @@ -1,6 +1,7 @@ mod bhc; mod lechatphp; mod util; +mod harm; use crate::lechatphp::LoginErr; use anyhow::{anyhow, Context}; @@ -54,6 +55,7 @@ use tui::{ }; use unicode_width::UnicodeWidthStr; use util::StatefulList; +use harm::{action_from_score, score_message, Action}; const LANG: &str = "en"; const SEND_TO_ALL: &str = "s *"; @@ -112,6 +114,14 @@ struct Profile { #[derive(Default, Debug, Serialize, Deserialize)] struct MyConfig { dkf_api_key: Option<String>, + #[serde(default)] + bad_usernames: Vec<String>, + #[serde(default)] + bad_exact_usernames: Vec<String>, + #[serde(default)] + bad_messages: Vec<String>, + #[serde(default)] + commands: HashMap<String, String>, profiles: HashMap<String, Profile>, } @@ -170,6 +180,13 @@ struct Opts { #[arg(long)] sxiv: bool, + + #[arg(skip)] + bad_usernames: Option<Vec<String>>, + #[arg(skip)] + bad_exact_usernames: Option<Vec<String>>, + #[arg(skip)] + bad_messages: Option<Vec<String>>, } struct LeChatPHPConfig { @@ -221,6 +238,10 @@ struct LeChatPHPClient { color_tx: crossbeam_channel::Sender<()>, color_rx: Arc<Mutex<crossbeam_channel::Receiver<()>>>, + + bad_username_filters: Arc<Mutex<Vec<String>>>, + bad_exact_username_filters: Arc<Mutex<Vec<String>>>, + bad_message_filters: Arc<Mutex<Vec<String>>>, } impl LeChatPHPClient { @@ -365,6 +386,10 @@ impl LeChatPHPClient { let exit_rx = sig.lock().unwrap().clone(); let sig = Arc::clone(sig); let members_tag = self.config.members_tag.clone(); + let tx = self.tx.clone(); + let bad_usernames = Arc::clone(&self.bad_username_filters); + let bad_exact_usernames = Arc::clone(&self.bad_exact_username_filters); + let bad_messages = Arc::clone(&self.bad_message_filters); thread::spawn(move || loop { let (_stream, stream_handle) = OutputStream::try_default().unwrap(); let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); @@ -383,6 +408,10 @@ impl LeChatPHPClient { &datetime_fmt, &messages, &mut should_notify, + &tx, + &bad_usernames, + &bad_exact_usernames, + &bad_messages, ) { log::error!("{}", err); }; @@ -561,6 +590,236 @@ impl LeChatPHPClient { }); } + fn save_filters(&self) { + if let Ok(mut cfg) = confy::load::<MyConfig>("bhcli", None) { + cfg.bad_usernames = self.bad_username_filters.lock().unwrap().clone(); + cfg.bad_exact_usernames = self.bad_exact_username_filters.lock().unwrap().clone(); + cfg.bad_messages = self.bad_message_filters.lock().unwrap().clone(); + if let Err(e) = confy::store("bhcli", None, cfg) { + log::error!("failed to store config: {}", e); + } + } + } + + fn list_filters(&self, usernames: bool) -> String { + let list = if usernames { + self.bad_username_filters.lock().unwrap().clone() + } else { + self.bad_message_filters.lock().unwrap().clone() + }; + if list.is_empty() { + String::from("(empty)") + } else { + list.join(", ") + } + } + + fn list_exact_filters(&self) -> String { + let list = self.bad_exact_username_filters.lock().unwrap().clone(); + if list.is_empty() { + String::from("(empty)") + } else { + list.join(", ") + } + } + + fn remove_filter(&self, term: &str, usernames: bool) -> bool { + if usernames { + { + let mut filters = self.bad_username_filters.lock().unwrap(); + if let Some(pos) = filters.iter().position(|x| x == term) { + filters.remove(pos); + return true; + } + } + { + let mut filters = self.bad_exact_username_filters.lock().unwrap(); + if let Some(pos) = filters.iter().position(|x| x == term) { + filters.remove(pos); + return true; + } + } + false + } else { + let mut filters = self.bad_message_filters.lock().unwrap(); + if let Some(pos) = filters.iter().position(|x| x == term) { + filters.remove(pos); + true + } else { + false + } + } + } + + fn apply_ban_filters(&self, users: &Arc<Mutex<Users>>) { + let users = users.lock().unwrap(); + let name_filters = self.bad_username_filters.lock().unwrap().clone(); + let exact_filters = self.bad_exact_username_filters.lock().unwrap().clone(); + for (_, name) in &users.guests { + if exact_filters.iter().any(|f| f == name) + || name_filters + .iter() + .any(|f| name.to_lowercase().contains(&f.to_lowercase())) + { + let _ = self.tx.send(PostType::Kick(String::new(), name.clone())); + } + } + } + + fn process_command(&mut self, input: &str, app: &mut App, users: &Arc<Mutex<Users>>) -> bool { + if input == "/dl" { + self.post_msg(PostType::DeleteLast).unwrap(); + } else if let Some(captures) = DLX_RGX.captures(input) { + let x: usize = captures.get(1).unwrap().as_str().parse().unwrap(); + for _ in 0..x { + self.post_msg(PostType::DeleteLast).unwrap(); + } + } else if input == "/dall" { + self.post_msg(PostType::DeleteAll).unwrap(); + } else if input == "/cycles" { + self.color_tx.send(()).unwrap(); + } else if input == "/cycle1" { + self.start_cycle(true); + } else if input == "/cycle2" { + self.start_cycle(false); + } else if input == "/kall" { + let username = "s _".to_owned(); + let msg = "".to_owned(); + self.post_msg(PostType::Kick(msg, username)).unwrap(); + } else if let Some(captures) = PM_RGX.captures(input) { + let username = &captures[1]; + let msg = captures[2].to_owned(); + let to = Some(username.to_owned()); + self.post_msg(PostType::Post(msg, to)).unwrap(); + app.input = format!("/pm {} ", username); + app.input_idx = app.input.width(); + } else if let Some(captures) = NEW_NICKNAME_RGX.captures(input) { + let new_nickname = captures[1].to_owned(); + self.post_msg(PostType::NewNickname(new_nickname)).unwrap(); + } else if let Some(captures) = NEW_COLOR_RGX.captures(input) { + let new_color = captures[1].to_owned(); + self.post_msg(PostType::NewColor(new_color)).unwrap(); + } else if let Some(captures) = KICK_RGX.captures(input) { + let username = captures[1].to_owned(); + let msg = captures[2].to_owned(); + self.post_msg(PostType::Kick(msg, username)).unwrap(); + } else if input.starts_with("/banname ") || input.starts_with("/ban ") { + let mut name = if input.starts_with("/banname ") { + remove_prefix(input, "/banname ") + } else { + remove_prefix(input, "/ban ") + }; + let exact = name.starts_with('"') && name.ends_with('"') && name.len() >= 2; + if exact { + name = &name[1..name.len()-1]; + } + let name = name.to_owned(); + if exact { + let mut f = self.bad_exact_username_filters.lock().unwrap(); + f.push(name.clone()); + } else { + let mut f = self.bad_username_filters.lock().unwrap(); + f.push(name.clone()); + } + self.save_filters(); + self.post_msg(PostType::Kick(String::new(), name.clone())).unwrap(); + self.apply_ban_filters(users); + let msg = if exact { + format!("Banned exact user \"{}\"", name) + } else { + format!("Banned userfilter \"{}\"", name) + }; + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } else if input.starts_with("/banmsg ") || input.starts_with("/filter ") { + let term = if input.starts_with("/banmsg ") { + remove_prefix(input, "/banmsg ") + } else { + remove_prefix(input, "/filter ") + }; + let term = term.to_owned(); + { + let mut f = self.bad_message_filters.lock().unwrap(); + f.push(term.clone()); + } + self.save_filters(); + let msg = format!("Filtering messages including \"{}\"", term); + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } else if input == "/banlist" { + let list = self.list_filters(true); + let list_exact = self.list_exact_filters(); + let msg = format!("Banned names: {}", list) + + &if list_exact.is_empty() { + String::new() + } else { + format!("\nBanned exact names: {}", list_exact) + }; + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } else if input == "/filterlist" { + let list = self.list_filters(false); + let msg = format!("Filtered messages: {}", list); + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } else if input.starts_with("/unban ") { + let mut name = remove_prefix(input, "/unban "); + if name.starts_with('"') && name.ends_with('"') && name.len() >= 2 { + name = &name[1..name.len() - 1]; + } + if self.remove_filter(name, true) { + self.save_filters(); + let msg = format!("Unbanned {}", name); + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } + } else if input.starts_with("/unfilter ") { + let term = remove_prefix(input, "/unfilter "); + if self.remove_filter(term, false) { + self.save_filters(); + let msg = format!("Unfiltered \"{}\"", term); + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } + } else if let Some(captures) = IGNORE_RGX.captures(input) { + let username = captures[1].to_owned(); + self.post_msg(PostType::Ignore(username)).unwrap(); + } else if let Some(captures) = UNIGNORE_RGX.captures(input) { + let username = captures[1].to_owned(); + self.post_msg(PostType::Unignore(username)).unwrap(); + } else if let Some(captures) = UPLOAD_RGX.captures(input) { + let file_path = captures[1].to_owned(); + let send_to = match captures.get(2) { + Some(to_match) => match to_match.as_str() { + "members" => SEND_TO_MEMBERS, + "staffs" => SEND_TO_STAFFS, + "admins" => SEND_TO_ADMINS, + _ => SEND_TO_ALL, + }, + None => SEND_TO_ALL, + } + .to_owned(); + let msg = match captures.get(3) { + Some(msg_match) => msg_match.as_str().to_owned(), + None => "".to_owned(), + }; + self.post_msg(PostType::Upload(file_path, send_to, msg)).unwrap(); + } else if input.starts_with("!warn") { + let msg = input.trim_start_matches("!warn").trim(); + let msg = if msg.starts_with('@') { + msg.to_owned() + } else if msg.is_empty() { + String::new() + } else { + format!("@{}", msg) + }; + let end_msg = format!( + "This is your warning - {}, will be kicked next. Please read the !-rules / https://4-0-4.io/bhc-rules", + msg + ); + self + .post_msg(PostType::Post(end_msg, None)) + .unwrap(); + } else { + return false; + } + true + } + fn handle_input( &mut self, events: &Events, @@ -789,6 +1048,18 @@ impl LeChatPHPClient { .. } => self.handle_normal_mode_key_event_kick(app), KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_normal_mode_key_event_ban(app), + KeyEvent { + code: KeyCode::Char('B'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.handle_normal_mode_key_event_ban_exact(app) + } + KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::CONTROL, .. @@ -849,9 +1120,16 @@ impl LeChatPHPClient { match key_event { KeyEvent { code: KeyCode::Enter, + modifiers, + .. + } if modifiers.contains(KeyModifiers::SHIFT) + || modifiers.contains(KeyModifiers::CONTROL) => + self.handle_editing_mode_key_event_newline(app), + KeyEvent { + code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. - } => self.handle_editing_mode_key_event_enter(app)?, + } => self.handle_editing_mode_key_event_enter(app, users)?, KeyEvent { code: KeyCode::Tab, modifiers: KeyModifiers::NONE, @@ -1258,6 +1536,36 @@ impl LeChatPHPClient { } } + fn handle_normal_mode_key_event_ban(&mut self, app: &mut App) { + if let Some(idx) = app.items.state.selected() { + if let Some(username) = get_username( + &self.base_client.username, + &app.items.items.get(idx).unwrap().text, + &self.config.members_tag, + ) { + app.input = format!("/ban {} ", username); + app.input_idx = app.input.width(); + app.input_mode = InputMode::Editing; + app.items.unselect(); + } + } + } + + fn handle_normal_mode_key_event_ban_exact(&mut self, app: &mut App) { + if let Some(idx) = app.items.state.selected() { + if let Some(username) = get_username( + &self.base_client.username, + &app.items.items.get(idx).unwrap().text, + &self.config.members_tag, + ) { + app.input = format!(r#"/ban "{}" "#, username); + app.input_idx = app.input.width(); + app.input_mode = InputMode::Editing; + app.items.unselect(); + } + } + } + //Strange fn handle_normal_mode_key_event_translate( &mut self, @@ -1333,12 +1641,17 @@ impl LeChatPHPClient { app.items.state.select(Some(0)); } - fn handle_editing_mode_key_event_enter(&mut self, app: &mut App) -> Result<(), ExitSignal> { + fn handle_editing_mode_key_event_enter( + &mut self, + app: &mut App, + users: &Arc<Mutex<Users>>, + ) -> Result<(), ExitSignal> { if FIND_RGX.is_match(&app.input) { return Ok(()); } - let input: String = app.input.drain(..).collect(); + let mut input: String = app.input.drain(..).collect(); + input = replace_newline_escape(&input); app.input_idx = 0; // Iterate over commands and execute associated actions @@ -1353,118 +1666,47 @@ impl LeChatPHPClient { } } - if input == "/dl" { - // Delete last message - self.post_msg(PostType::DeleteLast).unwrap(); - } else if let Some(captures) = DLX_RGX.captures(&input) { - // Delete the last X messages - let x: usize = captures.get(1).unwrap().as_str().parse().unwrap(); - for _ in 0..x { - self.post_msg(PostType::DeleteLast).unwrap(); + let mut cmd_input = input.clone(); + let mut members_prefix = false; + if cmd_input.starts_with("/m ") { + members_prefix = true; + if remove_prefix(&cmd_input, "/m ").starts_with('/') { + cmd_input = remove_prefix(&cmd_input, "/m ").to_owned(); } - } else if input == "/dall" { - // Delete all messages - self.post_msg(PostType::DeleteAll).unwrap(); - } else if input == "/cycles" { - self.color_tx.send(()).unwrap(); - } else if input == "/cycle1" { - self.start_cycle(true); - } else if input == "/cycle2" { - self.start_cycle(false); - } else if input == "/kall" { - // Kick all guests - let username = "s _".to_owned(); - let msg = "".to_owned(); - self.post_msg(PostType::Kick(msg, username)).unwrap(); - } else if input.starts_with("/m ") { - // Send message to "members" section + } + + if self.process_command(&cmd_input, app, users) { + if members_prefix { + app.input = "/m ".to_owned(); + app.input_idx = app.input.width(); + } + return Ok(()); + } + + if members_prefix { let msg = remove_prefix(&input, "/m ").to_owned(); let to = Some(SEND_TO_MEMBERS.to_owned()); self.post_msg(PostType::Post(msg, to)).unwrap(); app.input = "/m ".to_owned(); - app.input_idx = app.input.width() + app.input_idx = app.input.width(); } else if input.starts_with("/a ") { - // Send message to "admin" section let msg = remove_prefix(&input, "/a ").to_owned(); let to = Some(SEND_TO_ADMINS.to_owned()); self.post_msg(PostType::Post(msg, to)).unwrap(); app.input = "/a ".to_owned(); - app.input_idx = app.input.width() + app.input_idx = app.input.width(); } else if input.starts_with("/s ") { - // Send message to "staff" section let msg = remove_prefix(&input, "/s ").to_owned(); let to = Some(SEND_TO_STAFFS.to_owned()); self.post_msg(PostType::Post(msg, to)).unwrap(); app.input = "/s ".to_owned(); - app.input_idx = app.input.width() - } else if let Some(captures) = PM_RGX.captures(&input) { - // Send PM message - let username = &captures[1]; - let msg = captures[2].to_owned(); - let to = Some(username.to_owned()); - self.post_msg(PostType::Post(msg, to)).unwrap(); - app.input = format!("/pm {} ", username); - app.input_idx = app.input.width() - } else if let Some(captures) = NEW_NICKNAME_RGX.captures(&input) { - // Change nickname - let new_nickname = captures[1].to_owned(); - self.post_msg(PostType::NewNickname(new_nickname)).unwrap(); - } else if let Some(captures) = NEW_COLOR_RGX.captures(&input) { - // Change color - let new_color = captures[1].to_owned(); - self.post_msg(PostType::NewColor(new_color)).unwrap(); - } else if let Some(captures) = KICK_RGX.captures(&input) { - // Kick a user - let username = captures[1].to_owned(); - let msg = captures[2].to_owned(); - self.post_msg(PostType::Kick(msg, username)).unwrap(); - } else if let Some(captures) = IGNORE_RGX.captures(&input) { - // Ignore a user - let username = captures[1].to_owned(); - self.post_msg(PostType::Ignore(username)).unwrap(); - } else if let Some(captures) = UNIGNORE_RGX.captures(&input) { - // Unignore a user - let username = captures[1].to_owned(); - self.post_msg(PostType::Unignore(username)).unwrap(); - } else if let Some(captures) = UPLOAD_RGX.captures(&input) { - // Upload a file - let file_path = captures[1].to_owned(); - let send_to = match captures.get(2) { - Some(to_match) => match to_match.as_str() { - "members" => SEND_TO_MEMBERS, - "staffs" => SEND_TO_STAFFS, - "admins" => SEND_TO_ADMINS, - _ => SEND_TO_ALL, - }, - None => SEND_TO_ALL, - } - .to_owned(); - let msg = match captures.get(3) { - Some(msg_match) => msg_match.as_str().to_owned(), - None => "".to_owned(), - }; - self.post_msg(PostType::Upload(file_path, send_to, msg)) - .unwrap(); - } else if input.starts_with("!warn") { - // Strange - let msg: String = input - .find('@') - .map(|index| input[index..].to_string()) - .unwrap_or_else(String::new); - - let end_msg = format!( - "This is your warning - {}, will be kicked next !rules", - msg - ); - // log::error!("The Strange end_msg is :{}", end_msg); - self.post_msg(PostType::Post(end_msg, None)).unwrap(); + app.input_idx = app.input.width(); } else { if input.starts_with("/") && !input.starts_with("/me ") { app.input_idx = input.len(); app.input = input; app.input_mode = InputMode::EditingErr; } else { - // Send normal message self.post_msg(PostType::Post(input, None)).unwrap(); } } @@ -1482,7 +1724,8 @@ impl LeChatPHPClient { && ((parts[0] == "/kick" || parts[0] == "/k") || parts[0] == "/pm" || parts[0] == "/ignore" - || parts[0] == "/unignore") + || parts[0] == "/unignore" + || parts[0] == "/ban") { should_autocomplete = true; } else if user_prefix.starts_with("@") { @@ -1553,10 +1796,16 @@ impl LeChatPHPClient { if let Ok(clipboard) = ctx.get_contents() { let byte_position = byte_pos(&app.input, app.input_idx).unwrap(); app.input.insert_str(byte_position, &clipboard); - app.input_idx += clipboard.width(); + app.input_idx += clipboard.chars().count(); } } + fn handle_editing_mode_key_event_newline(&mut self, app: &mut App) { + let byte_position = byte_pos(&app.input, app.input_idx).unwrap(); + app.input.insert(byte_position, '\n'); + app.input_idx += 1; + } + fn handle_editing_mode_key_event_left(&mut self, app: &mut App) { if app.input_idx > 0 { app.input_idx -= 1; @@ -1889,6 +2138,10 @@ fn get_msgs( datetime_fmt: &str, messages: &Arc<Mutex<Vec<Message>>>, should_notify: &mut bool, + tx: &crossbeam_channel::Sender<PostType>, + bad_usernames: &Arc<Mutex<Vec<String>>>, + bad_exact_usernames: &Arc<Mutex<Vec<String>>>, + bad_messages: &Arc<Mutex<Vec<String>>>, ) -> anyhow::Result<()> { let url = format!( "{}/{}?action=view&session={}&lang={}", @@ -1905,6 +2158,21 @@ fn get_msgs( return Ok(()); } }; + let current_users = extract_users(&doc); + { + let previous = users.lock().unwrap(); + let filters = bad_usernames.lock().unwrap(); + let exact_filters = bad_exact_usernames.lock().unwrap(); + for (_, name) in &current_users.guests { + if !previous.guests.iter().any(|(_, n)| n == name) { + if exact_filters.iter().any(|f| f == name) + || filters.iter().any(|f| name.to_lowercase().contains(&f.to_lowercase())) + { + let _ = tx.send(PostType::Kick(String::new(), name.clone())); + } + } + } + } { let messages = messages.lock().unwrap(); process_new_messages( @@ -1914,6 +2182,11 @@ fn get_msgs( members_tag, username, should_notify, + &current_users, + tx, + bad_usernames, + bad_exact_usernames, + bad_messages, ); // Build messages vector. Tag deleted messages. update_messages(new_messages, messages, datetime_fmt); @@ -1923,8 +2196,8 @@ fn get_msgs( messages_updated_tx.send(()).unwrap(); } { - let mut users = users.lock().unwrap(); - *users = extract_users(&doc); + let mut u = users.lock().unwrap(); + *u = current_users; } Ok(()) } @@ -1936,6 +2209,11 @@ fn process_new_messages( members_tag: &str, username: &str, should_notify: &mut bool, + users: &Users, + tx: &crossbeam_channel::Sender<PostType>, + bad_usernames: &Arc<Mutex<Vec<String>>>, + bad_exact_usernames: &Arc<Mutex<Vec<String>>>, + bad_messages: &Arc<Mutex<Vec<String>>>, ) { if let Some(last_known_msg) = messages.first() { let last_known_msg_parsed_dt = parse_date(&last_known_msg.date, datetime_fmt); @@ -1944,19 +2222,62 @@ fn process_new_messages( && !(new_msg.date == last_known_msg.date && last_known_msg.text == new_msg.text) }); for new_msg in filtered { - if let Some((_, to_opt, msg)) = get_message(&new_msg.text, members_tag) { - // Process new messages - + if let Some((from, to_opt, msg)) = get_message(&new_msg.text, members_tag) { // Notify when tagged if msg.contains(format!("@{}", &username).as_str()) { *should_notify = true; } - // Notify when PM is received - if let Some(to) = to_opt { + if let Some(ref to) = to_opt { if to == username && msg != "!up" { *should_notify = true; } } + + let is_guest = users.guests.iter().any(|(_, n)| n == &from); + if from != username && is_guest { + let bad_name = { + let filters = bad_usernames.lock().unwrap(); + filters.iter().any(|f| from.to_lowercase().contains(&f.to_lowercase())) + }; + let bad_name_exact = { + let filters = bad_exact_usernames.lock().unwrap(); + filters.iter().any(|f| f == &from) + }; + let bad_msg = { + let filters = bad_messages.lock().unwrap(); + filters.iter().any(|f| msg.to_lowercase().contains(&f.to_lowercase())) + }; + + if bad_name_exact || bad_name || bad_msg { + let _ = tx.send(PostType::Kick(String::new(), from.clone())); + } else { + let res = score_message(&msg); + if let Some(act) = action_from_score(res.score) { + match act { + Action::Warn => { + if to_opt.is_none() { + let reason = res + .reason + .map(|r| r.description()) + .unwrap_or("breaking the rules"); + let warn = format!( + "@{username} - @{from}'s message was flagged for {reason}." + ); + let _ = tx.send(PostType::Post(warn, Some("0".to_owned()))); + } + } + Action::Kick => { + let _ = tx.send(PostType::Kick(String::new(), from.clone())); + } + Action::Ban => { + let _ = tx.send(PostType::Kick(String::new(), from.clone())); + let mut f = bad_usernames.lock().unwrap(); + f.push(from.clone()); + } + } + } + } + } } } } @@ -2100,6 +2421,9 @@ fn new_default_le_chat_php_client(params: Params) -> LeChatPHPClient { rx: Arc::new(Mutex::new(rx)), color_tx, color_rx: Arc::new(Mutex::new(color_rx)), + bad_username_filters: Arc::new(Mutex::new(params.bad_usernames)), + bad_exact_username_filters: Arc::new(Mutex::new(params.bad_exact_usernames)), + bad_message_filters: Arc::new(Mutex::new(params.bad_messages)), } } @@ -2123,6 +2447,9 @@ struct Params { max_login_retry: isize, keepalive_send_to: Option<String>, session: Option<String>, + bad_usernames: Vec<String>, + bad_exact_usernames: Vec<String>, + bad_messages: Vec<String>, } #[derive(Clone)] @@ -2164,6 +2491,10 @@ fn trim_newline(s: &mut String) { } } +fn replace_newline_escape(s: &str) -> String { + s.replace("\\n", "\n") +} + fn get_guest_color(wanted: Option<String>) -> String { match wanted.as_deref() { Some("beige") => "F5F5DC", @@ -2360,6 +2691,12 @@ fn main() -> anyhow::Result<()> { opts.password = Some(default_profile.password.clone()); } } + let bad_usernames = cfg.bad_usernames.clone(); + let bad_exact_usernames = cfg.bad_exact_usernames.clone(); + let bad_messages = cfg.bad_messages.clone(); + opts.bad_usernames = Some(bad_usernames); + opts.bad_exact_usernames = Some(bad_exact_usernames); + opts.bad_messages = Some(bad_messages); } let logfile = FileAppender::builder() @@ -2406,6 +2743,9 @@ fn main() -> anyhow::Result<()> { max_login_retry: opts.max_login_retry, keepalive_send_to: opts.keepalive_send_to, session: opts.session.clone(), + bad_usernames: opts.bad_usernames.unwrap_or_default(), + bad_exact_usernames: opts.bad_exact_usernames.unwrap_or_default(), + bad_messages: opts.bad_messages.unwrap_or_default(), }; // println!("Session[2378]: {:?}", opts.session);