From ca95184359ffda3db31763e137e421efd42f8dd2 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 16:16:44 +0000 Subject: [PATCH] BotServ BADWORDS: user-configurable regex kicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BADWORDS <#channel> ADD|DEL|LIST|CLEAR manages a channel's badword patterns, each a regular expression, so a channel matches whatever it wants — an improvement over Anope's fixed ANY/SINGLE/START/END. Enable with KICK <#channel> BADWORDS ON. Uses the regex crate: it's finite-automata based with linear-time matching, so untrusted user patterns can't cause catastrophic backtracking (ReDoS). A channel's patterns compile into one RegexSet (single-pass match against all of them), cached in the engine and rebuilt only when the list's revision changes — so the hot path is one is_match call, no per-message compilation or allocation. Patterns are validated (and size-limited) at add time; matching is case-insensitive by default. --- Cargo.lock | 1 + Cargo.toml | 1 + api/src/lib.rs | 14 ++++-- botserv/src/badwords.rs | 63 ++++++++++++++++++++++++++ botserv/src/kick.rs | 2 + botserv/src/lib.rs | 5 ++- src/engine/db.rs | 99 +++++++++++++++++++++++++++++++++++++++-- src/engine/mod.rs | 59 ++++++++++++++++++++++++ src/grpc.rs | 1 + 9 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 botserv/src/badwords.rs diff --git a/Cargo.lock b/Cargo.lock index b1acbbc..38691ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,7 @@ dependencies = [ "hmac", "pbkdf2", "prost", + "regex", "rustls-pemfile", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ae697c2..15b1066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ sha2 = "0.10" hmac = "0.12" pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } subtle = "2" +regex = "1" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/api/src/lib.rs b/api/src/lib.rs index c7843e7..a83e64c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -414,6 +414,8 @@ pub enum Kicker { Italics, Flood, Repeat, + // Kick lines matching a configured badword regex (patterns via BADWORDS). + Badwords, // Exemption, not a rule: never kick channel operators. DontKickOps, } @@ -488,9 +490,10 @@ pub enum CertError { #[derive(Debug)] pub enum ChanError { - Exists, // channel already registered - NoChannel, // channel is not registered - Internal, // persistence failed + Exists, // channel already registered + NoChannel, // channel is not registered + InvalidPattern, // a badword regex didn't compile + Internal, // persistence failed } // What an emailed code authorises. @@ -553,6 +556,11 @@ pub trait Store { fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>; fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>; fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>; + // BADWORDS list (regex patterns). add validates the pattern compiles. + fn badword_add(&mut self, channel: &str, pattern: &str) -> Result; + fn badword_del(&mut self, channel: &str, pattern: &str) -> Result; + fn badword_clear(&mut self, channel: &str) -> Result; + fn badwords(&self, channel: &str) -> Vec; fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>; fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option) -> Result<(), ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result; diff --git a/botserv/src/badwords.rs b/botserv/src/badwords.rs new file mode 100644 index 0000000..17f2f14 --- /dev/null +++ b/botserv/src/badwords.rs @@ -0,0 +1,63 @@ +use fedserv_api::{ChanError, Sender, ServiceCtx, Store}; + +// BADWORDS <#channel> ADD | DEL | LIST | CLEAR: manage the +// channel's badword patterns. Each entry is a regular expression, so a channel +// can match whatever it likes (improving on Anope's fixed ANY/SINGLE/START/END). +// Enable the kicker itself with KICK <#channel> BADWORDS ON. Founder-or-admin. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]"); + return; + }; + // LIST is readable by the founder/admin too; gate everything the same way. + if !super::require_channel_admin(me, from, chan, ctx, db) { + return; + } + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => { + if args.len() < 4 { + ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD "); + return; + } + let pattern = args[3..].join(" "); + match db.badword_add(chan, &pattern) { + Ok(true) => ctx.notice(me, from.uid, format!("Added badword pattern to \x02{chan}\x02: {pattern}")), + Ok(false) => ctx.notice(me, from.uid, "That pattern is already on the list."), + Err(ChanError::InvalidPattern) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")), + Err(_) => reg_error(me, from, chan, ctx), + } + } + Some("DEL") => { + if args.len() < 4 { + ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> DEL "); + return; + } + let pattern = args[3..].join(" "); + match db.badword_del(chan, &pattern) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed badword pattern from \x02{chan}\x02.")), + Ok(false) => ctx.notice(me, from.uid, "That pattern isn't on the list."), + Err(_) => reg_error(me, from, chan, ctx), + } + } + Some("CLEAR") => match db.badword_clear(chan) { + Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 badword pattern(s) from \x02{chan}\x02.")), + Err(_) => reg_error(me, from, chan, ctx), + }, + None | Some("LIST") => { + let words = db.badwords(chan); + if words.is_empty() { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no badword patterns.")); + return; + } + ctx.notice(me, from.uid, format!("Badword patterns for \x02{chan}\x02 ({}):", words.len())); + for (i, w) in words.iter().enumerate() { + ctx.notice(me, from.uid, format!(" {}. {w}", i + 1)); + } + } + Some(other) => ctx.notice(me, from.uid, format!("Unknown BADWORDS command \x02{other}\x02. Use ADD, DEL, LIST or CLEAR.")), + } +} + +fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment.")); +} diff --git a/botserv/src/kick.rs b/botserv/src/kick.rs index 25e4515..ef9bd14 100644 --- a/botserv/src/kick.rs +++ b/botserv/src/kick.rs @@ -66,6 +66,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: "UNDERLINES" => Kicker::Underlines, "REVERSES" => Kicker::Reverses, "ITALICS" => Kicker::Italics, + "BADWORDS" => Kicker::Badwords, "DONTKICKOPS" => Kicker::DontKickOps, other => { ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS.")); @@ -85,6 +86,7 @@ fn label(kicker: Kicker) -> &'static str { Kicker::Italics => "italics", Kicker::Flood => "flood", Kicker::Repeat => "repeat", + Kicker::Badwords => "badwords", Kicker::DontKickOps => "dontkickops", } } diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index d1ffde9..baeda37 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -16,6 +16,8 @@ mod say; mod set; #[path = "kick.rs"] mod kick; +#[path = "badwords.rs"] +mod badwords; // Shared gate: the sender may administer 's bot options only as its // founder or a services admin. Notices and returns false on failure. @@ -57,7 +59,8 @@ impl Service for BotServ { Some("ACT") => say::handle(me, from, args, ctx, net, db, true), Some("SET") => set::handle(me, from, args, ctx, db), Some("KICK") => kick::handle(me, from, args, ctx, db), - Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> configures kickers. Operators also have \x02BOT\x02 ADD|DEL|LIST."), + Some("BADWORDS") => badwords::handle(me, from, args, ctx, db), + Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> configures kickers, \x02BADWORDS\x02 <#channel> ADD|DEL|LIST manages badword regexes. Operators also have \x02BOT\x02 ADD|DEL|LIST."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } } diff --git a/src/engine/db.rs b/src/engine/db.rs index 8087ad5..2907a73 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -1,6 +1,10 @@ use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; + +// Ceiling on a single compiled badword regex, so one pattern can't consume huge +// memory. Shared by add-time validation and the engine's match-time build. +pub const BADWORD_SIZE_LIMIT: usize = 1 << 20; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use argon2::password_hash::rand_core::{OsRng, RngCore}; @@ -99,6 +103,7 @@ pub enum Event { ChannelEntryMsgSet { channel: String, msg: String }, ChannelSettingsSet { channel: String, settings: ChanSettings }, ChannelKickerSet { channel: String, kickers: KickerSettings }, + ChannelBadwordsSet { channel: String, badwords: Vec }, ChannelTopicSet { channel: String, topic: String }, ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option }, ChannelUnsuspended { channel: String }, @@ -150,6 +155,7 @@ impl Event { | Event::ChannelEntryMsgSet { .. } | Event::ChannelSettingsSet { .. } | Event::ChannelKickerSet { .. } + | Event::ChannelBadwordsSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } | Event::ChannelUnsuspended { .. } @@ -277,6 +283,13 @@ pub struct ChannelInfo { // BotServ kicker configuration (the bot kicks on caps/formatting/etc.). #[serde(default)] pub kickers: KickerSettings, + // BADWORDS: user-configured regex patterns the badwords kicker matches. + #[serde(default)] + pub badwords: Vec, + // Bumped whenever `badwords` changes, so the engine's compiled-RegexSet + // cache knows to rebuild. Session-local; never serialized. + #[serde(skip)] + pub badwords_rev: u64, } // BotServ's per-channel "kickers": the assigned bot kicks a message that trips @@ -315,6 +328,9 @@ pub struct KickerSettings { // Consecutive repeats that trip the repeat kicker (0 = default, 3). #[serde(default)] pub repeat_times: u16, + // Kick lines matching one of the channel's badword regexes. + #[serde(default)] + pub badwords: bool, // Don't kick channel operators, whatever they send. #[serde(default)] pub dontkickops: bool, @@ -323,7 +339,7 @@ pub struct KickerSettings { impl KickerSettings { // Any kicker enabled? (dontkickops alone doesn't count.) pub fn any(&self) -> bool { - self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat + self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords } // Resolved thresholds, applying Anope's defaults for a 0 (unset) value. @@ -786,6 +802,9 @@ impl Db { if c.kickers.any() || c.kickers.dontkickops { snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() }); } + if !c.badwords.is_empty() { + snapshot.push(Event::ChannelBadwordsSet { channel: c.name.clone(), badwords: c.badwords.clone() }); + } } for (nick, account) in &self.grouped { snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); @@ -1228,7 +1247,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 }); Ok(()) } @@ -1397,6 +1416,7 @@ impl Db { Kicker::Italics => k.italics = on, Kicker::Flood => k.flood = on, Kicker::Repeat => k.repeat = on, + Kicker::Badwords => k.badwords = on, Kicker::DontKickOps => k.dontkickops = on, }) } @@ -1427,6 +1447,61 @@ impl Db { }) } + /// Add a badword regex. Validates it compiles within the size limit; returns + /// Ok(false) if the exact pattern is already present. + pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result { + if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() { + return Err(ChanError::InvalidPattern); + } + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + if c.badwords.iter().any(|w| w == pattern) { + return Ok(false); + } + let mut list = c.badwords.clone(); + list.push(pattern.to_string()); + self.write_badwords(channel, &k, list)?; + Ok(true) + } + + /// Remove a badword by exact pattern. Ok(false) if it wasn't listed. + pub fn badword_del(&mut self, channel: &str, pattern: &str) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + if !c.badwords.iter().any(|w| w == pattern) { + return Ok(false); + } + let list: Vec = c.badwords.iter().filter(|w| *w != pattern).cloned().collect(); + self.write_badwords(channel, &k, list)?; + Ok(true) + } + + /// Clear all badwords; returns how many were removed. + pub fn badword_clear(&mut self, channel: &str) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + let n = c.badwords.len(); + if n > 0 { + self.write_badwords(channel, &k, Vec::new())?; + } + Ok(n) + } + + pub fn badwords(&self, channel: &str) -> &[String] { + self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice()) + } + + // Persist a new badword list (whole-list event) and apply it. + fn write_badwords(&mut self, channel: &str, k: &str, list: Vec) -> Result<(), ChanError> { + self.log + .append(Event::ChannelBadwordsSet { channel: channel.to_string(), badwords: list.clone() }) + .map_err(|_| ChanError::Internal)?; + let c = self.channels.get_mut(k).unwrap(); + c.badwords = list; + c.badwords_rev = c.badwords_rev.wrapping_add(1); + Ok(()) + } + // Read-modify-write the whole kicker struct through one event. fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> { let k = key(channel); @@ -1725,7 +1800,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -1778,6 +1853,12 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.badwords = badwords; + c.badwords_rev = c.badwords_rev.wrapping_add(1); + } + } Event::ChannelTopicSet { channel, topic } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.topic = topic; @@ -2011,6 +2092,18 @@ impl Store for Db { fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> { Db::set_repeat_kicker(self, channel, times) } + fn badword_add(&mut self, channel: &str, pattern: &str) -> Result { + Db::badword_add(self, channel, pattern) + } + fn badword_del(&mut self, channel: &str, pattern: &str) -> Result { + Db::badword_del(self, channel, pattern) + } + fn badword_clear(&mut self, channel: &str) -> Result { + Db::badword_clear(self, channel) + } + fn badwords(&self, channel: &str) -> Vec { + Db::badwords(self, channel).to_vec() + } fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { Db::set_channel_topic(self, channel, topic) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ca4b70b..5a42d28 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -104,6 +104,16 @@ pub struct Engine { // Keyed by the uplink-canonical channel string (never event-logged). chatter: HashMap>, now_override: Option, // test clock (unix secs); None = real wall time + // Compiled BADWORDS matchers, one RegexSet per channel, rebuilt lazily when + // the channel's badword revision changes. A RegexSet matches a line against + // every pattern in a single linear pass (and the regex crate is backtracking- + // free, so user patterns can't blow up). Never event-logged. + badword_cache: HashMap, +} + +struct CachedBadwords { + rev: u64, + set: regex::RegexSet, } // One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers). @@ -135,6 +145,7 @@ impl Engine { bot_channels: std::collections::HashSet::new(), chatter: HashMap::new(), now_override: None, + badword_cache: HashMap::new(), } } @@ -948,6 +959,18 @@ impl Engine { if let Some(reason) = k.violation(text) { return kick(reason); } + // BADWORDS: match the line against the channel's compiled regex set, + // rebuilding the cache only when the badword list has changed. + if k.badwords && !c.badwords.is_empty() { + let rev = c.badwords_rev; + if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) { + let set = build_badword_set(&c.badwords); + self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set }); + } + if self.badword_cache.get(channel).unwrap().set.is_match(text) { + return kick("Watch your language!"); + } + } if !k.flood && !k.repeat { return None; } @@ -1079,6 +1102,17 @@ fn ci_hash(text: &str) -> u64 { h.finish() } +// Compile a channel's badword patterns into one RegexSet. Case-insensitive by +// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited, +// and tolerant of a bad entry so one pattern can't disable the whole set. +fn build_badword_set(patterns: &[String]) -> regex::RegexSet { + regex::RegexSetBuilder::new(patterns) + .case_insensitive(true) + .size_limit(db::BADWORD_SIZE_LIMIT) + .build() + .unwrap_or_else(|_| regex::RegexSet::empty()) +} + fn login_plain(b64: &str, db: &Db) -> Option { let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); @@ -2091,6 +2125,31 @@ mod tests { assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks"); } + // The badwords kicker matches user-supplied regexes (case-insensitively), + // rebuilds its compiled set when the list changes, and rejects bad patterns. + #[test] + fn botserv_badwords_kicker_kicks() { + let (mut e, _p) = kicker_fixture("bsbadwords"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS")); + + bs(&mut e, "BADWORDS #c ADD fr[ao]g"); + bs(&mut e, "KICK #c BADWORDS ON"); + // Matching line (case-insensitive) is kicked; clean line is not. + assert!(kicked(&say(&mut e, "i love FROGS")), "matched badword kicked"); + assert!(!kicked(&say(&mut e, "hello world")), "clean line ok"); + + // Adding a pattern bumps the revision, so the cached set rebuilds. + bs(&mut e, "BADWORDS #c ADD wibble"); + assert!(kicked(&say(&mut e, "wibble wobble")), "new pattern matches after rebuild"); + + // An invalid regex is rejected and not stored. + let out = bs(&mut e, "BADWORDS #c ADD [unclosed"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("valid regular expression"))), "invalid rejected: {out:?}"); + assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored"); + } + // Registers #c with founder boss (admin), a live assigned bot Bendy, and // returns the engine ready for KICK configuration + a spammer uid 000AAAAAS. fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) { diff --git a/src/grpc.rs b/src/grpc.rs index 1224905..7142fee 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -138,6 +138,7 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelEntryMsgSet { .. } | Event::ChannelSettingsSet { .. } | Event::ChannelKickerSet { .. } + | Event::ChannelBadwordsSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } | Event::ChannelUnsuspended { .. }