diff --git a/modules/chanserv/akick.rs b/modules/chanserv/akick.rs new file mode 100644 index 0000000..9150a94 --- /dev/null +++ b/modules/chanserv/akick.rs @@ -0,0 +1,52 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// AKICK <#channel> ADD [reason] | DEL | LIST +// Masks are nick!user@host globs; matching users are banned and kicked on join. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason] | DEL | LIST"); + return; + }; + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + None | Some("LIST") => match db.channel(chan) { + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), + Some(info) if info.akick.is_empty() => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has an empty auto-kick list.")), + Some(info) => { + ctx.notice(me, from.uid, format!("Auto-kick list for \x02{}\x02:", info.name)); + for k in &info.akick { + ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", k.mask, k.reason)); + } + } + }, + Some("ADD") => { + let Some(&mask) = args.get(3) else { + ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let reason = if args.len() > 4 { args[4..].join(" ") } else { "Auto-kicked".to_string() }; + match db.akick_add(chan, mask, &reason) { + Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{mask}\x02 to \x02{chan}\x02's auto-kick list.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("DEL") => { + let Some(&mask) = args.get(3) else { + ctx.notice(me, from.uid, "Syntax: AKICK <#channel> DEL "); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + match db.akick_del(chan, mask) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{mask}\x02 from \x02{chan}\x02's auto-kick list.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{mask}\x02 isn't on \x02{chan}\x02's auto-kick list.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + _ => ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason] | DEL | LIST"), + } +} diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 28501ce..679bd5a 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -14,6 +14,16 @@ mod kick; mod ban; #[path = "unban.rs"] mod unban; +#[path = "topic.rs"] +mod topic; +#[path = "invite.rs"] +mod invite; +#[path = "akick.rs"] +mod akick; +#[path = "list.rs"] +mod list; +#[path = "status.rs"] +mod status; pub struct ChanServ { pub uid: String, @@ -146,7 +156,12 @@ impl Service for ChanServ { Some("KICK") => kick::handle(me, from, args, ctx, net, db), Some("BAN") => ban::handle(me, from, args, ctx, net, db), Some("UNBAN") => unban::handle(me, from, args, ctx, net, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02ACCESS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02 — each takes a <#channel>."), + Some("TOPIC") => topic::handle(me, from, args, ctx, db), + Some("INVITE") => invite::handle(me, from, args, ctx, net, db), + Some("AKICK") => akick::handle(me, from, args, ctx, db), + Some("STATUS") => status::handle(me, from, args, ctx, net, db), + Some("LIST") => list::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02ACCESS\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/modules/chanserv/invite.rs b/modules/chanserv/invite.rs new file mode 100644 index 0000000..a5dc4d5 --- /dev/null +++ b/modules/chanserv/invite.rs @@ -0,0 +1,26 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// INVITE <#channel> [nick]: invite a user (self if no nick) into the channel. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: INVITE <#channel> [nick]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let target = match args.get(2) { + Some(&nick) => match net.uid_by_nick(nick) { + Some(u) => u.to_string(), + None => { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); + return; + } + }, + None => from.uid.to_string(), + }; + ctx.invite(me, &target, chan); + ctx.notice(me, from.uid, format!("Invited to \x02{chan}\x02.")); +} diff --git a/modules/chanserv/list.rs b/modules/chanserv/list.rs new file mode 100644 index 0000000..33c7190 --- /dev/null +++ b/modules/chanserv/list.rs @@ -0,0 +1,16 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// LIST: show all registered channels. +pub fn handle(me: &str, from: &Sender, _args: &[&str], ctx: &mut ServiceCtx, db: &Db) { + let mut names: Vec<&str> = db.channels().map(|c| c.name.as_str()).collect(); + if names.is_empty() { + ctx.notice(me, from.uid, "No channels are registered."); + return; + } + names.sort_unstable(); + ctx.notice(me, from.uid, format!("Registered channels ({}):", names.len())); + for n in names { + ctx.notice(me, from.uid, format!(" \x02{n}\x02")); + } +} diff --git a/modules/chanserv/status.rs b/modules/chanserv/status.rs new file mode 100644 index 0000000..1982bd1 --- /dev/null +++ b/modules/chanserv/status.rs @@ -0,0 +1,29 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// STATUS <#channel> [nick]: show a user's access level (self if no nick). +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: STATUS <#channel> [nick]"); + return; + }; + let Some(info) = db.channel(chan) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + }; + let (label, account) = match args.get(2) { + Some(&nick) => (nick.to_string(), net.uid_by_nick(nick).and_then(|u| net.account_of(u)).map(str::to_string)), + None => (from.nick.to_string(), from.account.map(str::to_string)), + }; + let level = match account.as_deref() { + None => "none (not logged in)", + Some(a) if info.founder.eq_ignore_ascii_case(a) => "founder", + Some(a) => match info.join_mode(a) { + Some("+o") => "op", + Some("+v") => "voice", + _ => "none", + }, + }; + ctx.notice(me, from.uid, format!("\x02{label}\x02 access on \x02{chan}\x02: \x02{level}\x02")); +} diff --git a/modules/chanserv/topic.rs b/modules/chanserv/topic.rs new file mode 100644 index 0000000..dfb6253 --- /dev/null +++ b/modules/chanserv/topic.rs @@ -0,0 +1,16 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// TOPIC <#channel> : set the channel topic (empty clears it). +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: TOPIC <#channel> "); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() }; + ctx.topic(me, chan, &text); + ctx.notice(me, from.uid, format!("Topic for \x02{chan}\x02 updated.")); +} diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index 5a553c1..b687b16 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -236,6 +236,16 @@ impl Protocol for InspIrcd { NetAction::Kick { from, channel, uid, reason } => { vec![format!(":{} KICK {} {} :{}", from, channel, uid, reason)] } + // FTOPIC :. TS 1 so the ircd + // always accepts it; sourced from the given pseudoclient. + NetAction::Topic { from, channel, topic } => { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); + vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)] + } + // INVITE . Expiry 0 = no expiry. + NetAction::Invite { from, uid, channel } => { + vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)] + } NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles this before serialization. NetAction::DeferRegister { .. } => vec![], diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index fefd072..5c444c8 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -52,6 +52,10 @@ pub enum NetAction { ChannelMode { from: String, channel: String, modes: String }, // Kick a user from a channel, sourced from pseudoclient `from`. Kick { from: String, channel: String, uid: String, reason: String }, + // Set a channel's topic, sourced from pseudoclient `from`. + Topic { from: String, channel: String, topic: String }, + // Invite a user to a channel, sourced from pseudoclient `from`. + Invite { from: String, uid: String, channel: String }, Raw(String), // Internal only, never serialized to the wire: a registration whose password // still needs its (expensive) key derivation. The link layer runs the diff --git a/src/engine/db.rs b/src/engine/db.rs index 7ffd7f9..789236b 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -43,6 +43,8 @@ pub enum Event { ChannelMlock { name: String, on: String, off: String }, ChannelAccessAdd { channel: String, account: String, level: String }, ChannelAccessDel { channel: String, account: String }, + ChannelAkickAdd { channel: String, mask: String, reason: String }, + ChannelAkickDel { channel: String, mask: String }, } // An access-list entry: an account and its level ("op" or "voice"). @@ -52,6 +54,13 @@ pub struct ChanAccess { pub level: String, } +// An auto-kick entry: a nick!user@host mask and why it was added. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChanAkick { + pub mask: String, + pub reason: String, +} + // A registered channel and who owns it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelInfo { @@ -65,6 +74,8 @@ pub struct ChannelInfo { pub lock_off: String, #[serde(default)] pub access: Vec, + #[serde(default)] + pub akick: Vec, } impl ChannelInfo { @@ -85,6 +96,11 @@ impl ChannelInfo { self.join_mode(account) == Some("+o") } + /// The matching auto-kick entry for `hostmask` (nick!user@host), if any. + pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkick> { + self.akick.iter().find(|k| glob_match(&k.mask, hostmask)) + } + /// The mode string services keep applied: +r plus the lock. pub fn lock_modes(&self) -> String { let mut s = format!("+r{}", self.lock_on); @@ -376,6 +392,9 @@ impl Db { for a in &c.access { snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() }); } + for k in &c.akick { + snapshot.push(Event::ChannelAkickAdd { channel: c.name.clone(), mask: k.mask.clone(), reason: k.reason.clone() }); + } } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); @@ -516,7 +535,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() }); + 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() }); Ok(()) } @@ -525,6 +544,11 @@ impl Db { self.channels.get(&key(name)) } + /// All registered channels, for listing. + pub fn channels(&self) -> impl Iterator { + self.channels.values() + } + /// Set the mode-lock (chars to keep set / unset) for a registered channel. pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { let k = key(name); @@ -571,6 +595,37 @@ impl Db { Ok(true) } + /// Add an auto-kick `mask` (with `reason`) to `channel`. + pub fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelAkickAdd { channel: channel.to_string(), mask: mask.to_string(), reason: reason.to_string() }) + .map_err(|_| ChanError::Internal)?; + let c = self.channels.get_mut(&k).unwrap(); + c.akick.retain(|a| !a.mask.eq_ignore_ascii_case(mask)); + c.akick.push(ChanAkick { mask: mask.to_string(), reason: reason.to_string() }); + Ok(()) + } + + /// Remove auto-kick `mask` from `channel`. Ok(false) if not present. + pub fn akick_del(&mut self, channel: &str, mask: &str) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { + return Err(ChanError::NoChannel); + }; + if !c.akick.iter().any(|a| a.mask.eq_ignore_ascii_case(mask)) { + return Ok(false); + } + self.log + .append(Event::ChannelAkickDel { channel: channel.to_string(), mask: mask.to_string() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().akick.retain(|a| !a.mask.eq_ignore_ascii_case(mask)); + Ok(true) + } + /// Unregister `name`. pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { let k = key(name); @@ -603,7 +658,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() }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -625,9 +680,51 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask)); + c.akick.push(ChanAkick { mask, reason }); + } + } + Event::ChannelAkickDel { channel, mask } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask)); + } + } } } +// Case-insensitive glob match supporting `*` (any run) and `?` (one char), +// used for auto-kick hostmasks. Iterative with backtracking, no allocation. +fn glob_match(pattern: &str, text: &str) -> bool { + let (p, t): (Vec, Vec) = ( + pattern.chars().flat_map(char::to_lowercase).collect(), + text.chars().flat_map(char::to_lowercase).collect(), + ); + let (mut pi, mut ti) = (0, 0); + let (mut star, mut mark) = (None, 0); + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(s) = star { + pi = s + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + fn hash_password(password: &str) -> Option { let salt = SaltString::generate(&mut OsRng); Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string()) @@ -653,6 +750,29 @@ mod tests { Event::CertAdded { account: account.into(), fp: fp.into() } } + #[test] + fn glob_matches_hostmasks() { + assert!(glob_match("*!*@host.example", "bob!~b@host.example")); + assert!(glob_match("bob!*@*", "BOB!~b@1.2.3.4")); + assert!(glob_match("*", "anything")); + assert!(glob_match("nick?!*@*", "nickX!~x@h")); + assert!(!glob_match("*!*@host.example", "bob!~b@other")); + assert!(!glob_match("alice!*@*", "bob!~b@h")); + } + + #[test] + fn akick_add_del_and_match() { + let mut db = Db::open(&tmp("akick"), "N1"); + db.register_channel("#c", "founder").unwrap(); + db.akick_add("#c", "*!*@bad.host", "spam").unwrap(); + let info = db.channel("#c").unwrap(); + assert!(info.akick_match("evil!~e@bad.host").is_some()); + assert!(info.akick_match("good!~g@ok.host").is_none()); + assert!(db.akick_del("#c", "*!*@bad.host").unwrap()); + assert!(!db.akick_del("#c", "*!*@bad.host").unwrap()); + assert!(db.channel("#c").unwrap().akick.is_empty()); + } + // Locally-authored events get an incrementing per-origin seq and a ticking // Lamport clock. #[test] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4b03921..c0448b7 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -194,13 +194,28 @@ impl Engine { // Give the founder and access-list members their status on join. NetEvent::Join { uid, channel } => { let account = self.network.account_of(&uid).map(str::to_string); + let from = self.chan_service.clone().unwrap_or_default(); let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); match mode { - Some(m) => { - let from = self.chan_service.clone().unwrap_or_default(); - vec![NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }] + // A user with access gets their status mode. + Some(m) => vec![NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }], + // Otherwise, enforce the auto-kick list. + None => { + let nick = self.network.nick_of(&uid).unwrap_or("*"); + let host = self.network.host_of(&uid).unwrap_or("*"); + let hostmask = format!("{nick}!*@{host}"); + match self.db.channel(&channel).and_then(|c| c.akick_match(&hostmask)) { + Some(k) => { + let mask = k.mask.clone(); + let reason = if k.reason.is_empty() { "You are banned from this channel.".to_string() } else { k.reason.clone() }; + vec![ + NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("+b {mask}") }, + NetAction::Kick { from, channel, uid, reason }, + ] + } + None => Vec::new(), + } } - None => Vec::new(), } } NetEvent::Quit { uid } => { @@ -1033,6 +1048,47 @@ mod tests { assert!(to_cs(&mut e, "000AAAAAD", "KICK #m alice").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("operator access")))); } + // ChanServ topic, invite, auto-kick (with enforcement on join), list and status. + #[test] + fn chanserv_topic_invite_akick() { + use crate::chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-cs-tia.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db); + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "bad.host".into() }); + + // TOPIC and INVITE are sourced from ChanServ. + let out = to_cs(&mut e, "000AAAAAB", "TOPIC #c hello there"); + assert!(out.iter().any(|a| matches!(a, NetAction::Topic { channel, topic, .. } if channel == "#c" && topic == "hello there")), "{out:?}"); + let out = to_cs(&mut e, "000AAAAAB", "INVITE #c bob"); + assert!(out.iter().any(|a| matches!(a, NetAction::Invite { uid, channel, .. } if uid == "000AAAAAC" && channel == "#c")), "{out:?}"); + + // LIST and STATUS. + assert!(to_cs(&mut e, "000AAAAAB", "LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("#c")))); + assert!(to_cs(&mut e, "000AAAAAB", "STATUS #c").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("founder")))); + + // AKICK a mask, then a matching join is banned and kicked. + assert!(to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@bad.host begone").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added")))); + let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b *!*@bad.host")), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason == "begone")), "{out:?}"); + + // The founder joining is never auto-kicked (they get +o instead). + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "{out:?}"); + } + // A registered channel (re)appearing re-asserts +r; an unregistered one does not. #[test] fn channel_create_reasserts_registered_mode() { diff --git a/src/engine/service.rs b/src/engine/service.rs index 4b1e488..8786a50 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -99,4 +99,22 @@ impl ServiceCtx { reason: reason.to_string(), }); } + + // Set a channel's topic, sourced from pseudoclient `from`. + pub fn topic(&mut self, from: &str, channel: &str, topic: &str) { + self.actions.push(NetAction::Topic { + from: from.to_string(), + channel: channel.to_string(), + topic: topic.to_string(), + }); + } + + // Invite a user to a channel, sourced from pseudoclient `from`. + pub fn invite(&mut self, from: &str, uid: &str, channel: &str) { + self.actions.push(NetAction::Invite { + from: from.to_string(), + uid: uid.to_string(), + channel: channel.to_string(), + }); + } }