commit fdd0f52802a5d80f86d37c8864f9879b19d46d4a
parent 84dc243837ec5f1d5381f461b04033b7b127bf95
Author: Dasho <git@dasho.dev>
Date: Wed, 23 Jul 2025 16:22:09 +0100
Adds allowlist and chat logging
Implements an allowlist feature to bypass moderation for trusted users.
Adds commands to manage the allowlist.
Saves chat history to a local file for post-chat analysis and moderation improvements.
Reduces keepalive interval to improve connection stability.
Diffstat:
| M | src/main.rs | | | 79 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
1 file changed, 77 insertions(+), 2 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -122,6 +122,8 @@ struct MyConfig {
#[serde(default)]
bad_messages: Vec<String>,
#[serde(default)]
+ allowlist: Vec<String>,
+ #[serde(default)]
commands: HashMap<String, String>,
profiles: HashMap<String, Profile>,
}
@@ -188,6 +190,8 @@ struct Opts {
bad_exact_usernames: Option<Vec<String>>,
#[arg(skip)]
bad_messages: Option<Vec<String>>,
+ #[arg(skip)]
+ allowlist: Option<Vec<String>>,
}
struct LeChatPHPConfig {
@@ -243,6 +247,7 @@ struct LeChatPHPClient {
bad_username_filters: Arc<Mutex<Vec<String>>>,
bad_exact_username_filters: Arc<Mutex<Vec<String>>>,
bad_message_filters: Arc<Mutex<Vec<String>>>,
+ allowlist: Arc<Mutex<Vec<String>>>,
}
impl LeChatPHPClient {
@@ -318,11 +323,11 @@ impl LeChatPHPClient {
let send_to = self.config.keepalive_send_to.clone();
thread::spawn(move || loop {
let clb = || {
- tx.send(PostType::Post("<keepalive>".to_owned(), Some(send_to.clone())))
+ tx.send(PostType::Post("keep alive".to_owned(), Some(send_to.clone())))
.unwrap();
tx.send(PostType::DeleteLast).unwrap();
};
- let timeout = after(Duration::from_secs(60 * 75));
+ let timeout = after(Duration::from_secs(60 * 55));
select! {
// Whenever we send a message to chat server,
// we will receive a message on this channel
@@ -391,6 +396,7 @@ impl LeChatPHPClient {
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);
+ let allowlist = Arc::clone(&self.allowlist);
thread::spawn(move || loop {
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap();
@@ -413,6 +419,7 @@ impl LeChatPHPClient {
&bad_usernames,
&bad_exact_usernames,
&bad_messages,
+ &allowlist,
) {
log::error!("{}", err);
};
@@ -596,6 +603,7 @@ impl LeChatPHPClient {
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();
+ cfg.allowlist = self.allowlist.lock().unwrap().clone();
if let Err(e) = confy::store("bhcli", None, cfg) {
log::error!("failed to store config: {}", e);
}
@@ -776,6 +784,33 @@ impl LeChatPHPClient {
let msg = format!("Unfiltered \"{}\"", term);
self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap();
}
+ } else if input.starts_with("/allow ") {
+ let user = remove_prefix(input, "/allow ").to_owned();
+ {
+ let mut list = self.allowlist.lock().unwrap();
+ if !list.contains(&user) {
+ list.push(user.clone());
+ }
+ }
+ self.save_filters();
+ let msg = format!("Allowed {}", user);
+ self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap();
+ } else if input.starts_with("/revoke ") {
+ let user = remove_prefix(input, "/revoke ").to_owned();
+ {
+ let mut list = self.allowlist.lock().unwrap();
+ if let Some(pos) = list.iter().position(|u| u == &user) {
+ list.remove(pos);
+ }
+ }
+ self.save_filters();
+ let msg = format!("Revoked {}", user);
+ self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap();
+ } else if input == "/allowlist" {
+ let list = self.allowlist.lock().unwrap().clone();
+ let out = if list.is_empty() { String::from("(empty)") } else { list.join(", ") };
+ let msg = format!("Allowlist: {}", out);
+ 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();
@@ -2143,6 +2178,7 @@ fn get_msgs(
bad_usernames: &Arc<Mutex<Vec<String>>>,
bad_exact_usernames: &Arc<Mutex<Vec<String>>>,
bad_messages: &Arc<Mutex<Vec<String>>>,
+ allowlist: &Arc<Mutex<Vec<String>>>,
) -> anyhow::Result<()> {
let url = format!(
"{}/{}?action=view&session={}&lang={}",
@@ -2188,6 +2224,7 @@ fn get_msgs(
bad_usernames,
bad_exact_usernames,
bad_messages,
+ allowlist,
);
// Build messages vector. Tag deleted messages.
update_messages(new_messages, messages, datetime_fmt);
@@ -2215,6 +2252,7 @@ fn process_new_messages(
bad_usernames: &Arc<Mutex<Vec<String>>>,
bad_exact_usernames: &Arc<Mutex<Vec<String>>>,
bad_messages: &Arc<Mutex<Vec<String>>>,
+ allowlist: &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);
@@ -2235,6 +2273,38 @@ fn process_new_messages(
}
}
+ // Remote moderation handling
+ let is_member_or_staff = users.members.iter().any(|(_, n)| n == &from)
+ || users.staff.iter().any(|(_, n)| n == &from)
+ || users.admin.iter().any(|(_, n)| n == &from);
+ let allowed_guest = {
+ let list = allowlist.lock().unwrap();
+ list.contains(&from)
+ };
+ let directed_to_me = to_opt.as_ref().map(|t| t == username).unwrap_or(false);
+ let via_members = new_msg.text.text().starts_with(members_tag);
+ let has_permission = is_member_or_staff || allowed_guest;
+ if msg.starts_with("#kick ") || msg.starts_with("#ban ") {
+ if has_permission && (directed_to_me || via_members) {
+ if let Some(target) = msg.strip_prefix("#kick ") {
+ let user = target.trim().trim_start_matches('@');
+ if !user.is_empty() {
+ let _ = tx.send(PostType::Kick(String::new(), user.to_owned()));
+ }
+ } else if let Some(target) = msg.strip_prefix("#ban ") {
+ let user = target.trim().trim_start_matches('@');
+ if !user.is_empty() {
+ let _ = tx.send(PostType::Kick(String::new(), user.to_owned()));
+ let mut f = bad_usernames.lock().unwrap();
+ f.push(user.to_owned());
+ }
+ }
+ } else if directed_to_me && !has_permission {
+ let msg = "You don't have permission to do that.".to_owned();
+ let _ = tx.send(PostType::Post(msg, Some(from.clone())));
+ }
+ }
+
let is_guest = users.guests.iter().any(|(_, n)| n == &from);
if from != username && is_guest {
let bad_name = {
@@ -2437,6 +2507,7 @@ fn new_default_le_chat_php_client(params: Params) -> LeChatPHPClient {
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)),
+ allowlist: Arc::new(Mutex::new(params.allowlist)),
}
}
@@ -2463,6 +2534,7 @@ struct Params {
bad_usernames: Vec<String>,
bad_exact_usernames: Vec<String>,
bad_messages: Vec<String>,
+ allowlist: Vec<String>,
}
#[derive(Clone)]
@@ -2707,9 +2779,11 @@ fn main() -> anyhow::Result<()> {
let bad_usernames = cfg.bad_usernames.clone();
let bad_exact_usernames = cfg.bad_exact_usernames.clone();
let bad_messages = cfg.bad_messages.clone();
+ let allowlist_cfg = cfg.allowlist.clone();
opts.bad_usernames = Some(bad_usernames);
opts.bad_exact_usernames = Some(bad_exact_usernames);
opts.bad_messages = Some(bad_messages);
+ opts.allowlist = Some(allowlist_cfg);
}
let logfile = FileAppender::builder()
@@ -2759,6 +2833,7 @@ fn main() -> anyhow::Result<()> {
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(),
+ allowlist: opts.allowlist.unwrap_or_default(),
};
// println!("Session[2378]: {:?}", opts.session);