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:
| A | CMDS.md | | | 34 | ++++++++++++++++++++++++++++++++++ |
| M | Cargo.lock | | | 14 | +++++++------- |
| M | Cargo.toml | | | 2 | +- |
| M | README.md | | | 28 | ++++++++++++++++++++++++++++ |
| A | src/harm.rs | | | 98 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/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 ¤t_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,
+ ¤t_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);