chanserv: entrymsg, enforce, getkey, seen, clone, aop/sop/vop
Tracks channel membership (join/part/kick), the current key, and per-nick last-seen in the network view. Entry messages greet joiners; enforce re-applies the lock, access modes and auto-kicks to everyone present; getkey reports the key; seen reports last activity; clone copies a channel's settings; aop/sop/vop are tiered shortcuts over the access list.
This commit is contained in:
parent
674f543b40
commit
e8b55bdb27
12 changed files with 479 additions and 26 deletions
|
|
@ -26,6 +26,18 @@ mod list;
|
|||
mod status;
|
||||
#[path = "set.rs"]
|
||||
mod set;
|
||||
#[path = "entrymsg.rs"]
|
||||
mod entrymsg;
|
||||
#[path = "getkey.rs"]
|
||||
mod getkey;
|
||||
#[path = "seen.rs"]
|
||||
mod seen;
|
||||
#[path = "enforce.rs"]
|
||||
mod enforce;
|
||||
#[path = "clone.rs"]
|
||||
mod clone;
|
||||
#[path = "xop.rs"]
|
||||
mod xop;
|
||||
|
||||
pub struct ChanServ {
|
||||
pub uid: String,
|
||||
|
|
@ -167,13 +179,36 @@ impl Service for ChanServ {
|
|||
Some("STATUS") => status::handle(me, from, args, ctx, net, db),
|
||||
Some("LIST") => list::handle(me, from, args, ctx, db),
|
||||
Some("SET") => set::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, \x02SET\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("ENTRYMSG") => entrymsg::handle(me, from, args, ctx, db),
|
||||
Some("GETKEY") => getkey::handle(me, from, args, ctx, net, db),
|
||||
Some("SEEN") => seen::handle(me, from, args, ctx, net),
|
||||
Some("ENFORCE") => enforce::handle(me, from, args, ctx, net, db),
|
||||
Some("CLONE") => clone::handle(me, from, args, ctx, db),
|
||||
Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db),
|
||||
Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db),
|
||||
Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db),
|
||||
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// True if `from` is the channel's founder; otherwise notices why and returns false.
|
||||
fn require_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool {
|
||||
match db.channel(chan) {
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
false
|
||||
}
|
||||
Some(info) if from.account == Some(info.founder.as_str()) => true,
|
||||
_ => {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can do that."));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// True if `from` is the founder or an access-list op of `chan`; else notices why.
|
||||
fn require_op(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool {
|
||||
match (from.account, db.channel(chan)) {
|
||||
|
|
|
|||
32
modules/chanserv/clone.rs
Normal file
32
modules/chanserv/clone.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// CLONE <source> <target>: copy a channel's settings (mode lock, access,
|
||||
// auto-kick, description, entry message) into another. Founder of both.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||
let (Some(&src), Some(&dest)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CLONE <source> <target>");
|
||||
return;
|
||||
};
|
||||
let (Some(sinfo), Some(dinfo)) = (db.channel(src).cloned(), db.channel(dest).cloned()) else {
|
||||
ctx.notice(me, from.uid, "Both channels must be registered.");
|
||||
return;
|
||||
};
|
||||
if from.account != Some(sinfo.founder.as_str()) || from.account != Some(dinfo.founder.as_str()) {
|
||||
ctx.notice(me, from.uid, "You must be the founder of both channels.");
|
||||
return;
|
||||
}
|
||||
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off);
|
||||
for a in &sinfo.access {
|
||||
let _ = db.access_add(dest, &a.account, &a.level);
|
||||
}
|
||||
for k in &sinfo.akick {
|
||||
let _ = db.akick_add(dest, &k.mask, &k.reason);
|
||||
}
|
||||
let _ = db.set_desc(dest, &sinfo.desc);
|
||||
let _ = db.set_entrymsg(dest, &sinfo.entrymsg);
|
||||
if let Some(info) = db.channel(dest) {
|
||||
ctx.channel_mode(me, dest, &info.lock_modes());
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Copied \x02{src}\x02's settings to \x02{dest}\x02."));
|
||||
}
|
||||
35
modules/chanserv/enforce.rs
Normal file
35
modules/chanserv/enforce.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::Network;
|
||||
|
||||
// ENFORCE <#channel>: re-apply the channel's settings to everyone present —
|
||||
// the mode lock, access status modes, and the auto-kick list.
|
||||
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: ENFORCE <#channel>");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let Some(info) = db.channel(chan).cloned() else {
|
||||
return;
|
||||
};
|
||||
ctx.channel_mode(me, chan, &info.lock_modes());
|
||||
let members: Vec<String> = net.channel_members(chan).map(str::to_string).collect();
|
||||
for uid in members {
|
||||
match net.account_of(&uid).and_then(|a| info.join_mode(a)) {
|
||||
Some(m) => ctx.channel_mode(me, chan, &format!("{m} {uid}")),
|
||||
None => {
|
||||
let nick = net.nick_of(&uid).unwrap_or("*");
|
||||
let host = net.host_of(&uid).unwrap_or("*");
|
||||
if let Some(k) = info.akick_match(&format!("{nick}!*@{host}")) {
|
||||
ctx.channel_mode(me, chan, &format!("+b {}", k.mask));
|
||||
let reason = if k.reason.is_empty() { "You are banned from this channel." } else { &k.reason };
|
||||
ctx.kick(me, chan, &uid, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Re-applied \x02{chan}\x02's settings to everyone present."));
|
||||
}
|
||||
36
modules/chanserv/entrymsg.rs
Normal file
36
modules/chanserv/entrymsg.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// ENTRYMSG <#channel> [CLEAR | <text>]: message noticed to users as they join.
|
||||
// With no argument, show the current message.
|
||||
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: ENTRYMSG <#channel> [CLEAR | <text>]");
|
||||
return;
|
||||
};
|
||||
match args.get(2) {
|
||||
None => match db.channel(chan) {
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||
Some(info) if info.entrymsg.is_empty() => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no entry message.")),
|
||||
Some(info) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02: {}", info.entrymsg)),
|
||||
},
|
||||
Some(&kw) if kw.eq_ignore_ascii_case("CLEAR") => {
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.set_entrymsg(chan, "") {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 cleared.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.set_entrymsg(chan, &args[2..].join(" ")) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 updated.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
modules/chanserv/getkey.rs
Normal file
19
modules/chanserv/getkey.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::Network;
|
||||
|
||||
// GETKEY <#channel>: report the channel key (+k), for ops who need to let
|
||||
// someone in.
|
||||
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: GETKEY <#channel>");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match net.channel_key(chan) {
|
||||
Some(key) => ctx.notice(me, from.uid, format!("Key for \x02{chan}\x02 is \x02{key}\x02.")),
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no key set.")),
|
||||
}
|
||||
}
|
||||
18
modules/chanserv/seen.rs
Normal file
18
modules/chanserv/seen.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::Network;
|
||||
|
||||
// SEEN <nick>: when a nick was last seen, and doing what.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network) {
|
||||
let Some(&nick) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SEEN <nick>");
|
||||
return;
|
||||
};
|
||||
if net.uid_by_nick(nick).is_some() {
|
||||
ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online."));
|
||||
return;
|
||||
}
|
||||
match net.last_seen(nick) {
|
||||
Some(s) => ctx.notice(me, from.uid, format!("\x02{}\x02 was last seen {} ({}).", s.nick, super::human_time(s.ts), s.what)),
|
||||
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
|
||||
}
|
||||
}
|
||||
51
modules/chanserv/xop.rs
Normal file
51
modules/chanserv/xop.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// AOP/SOP/VOP <#channel> ADD <account> | DEL <account> | LIST — tiered
|
||||
// shortcuts over the access list. `level` is the access level they map to
|
||||
// ("op" for AOP/SOP, "voice" for VOP); `word` is what the user typed.
|
||||
pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account> | DEL <account> | 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) => {
|
||||
ctx.notice(me, from.uid, format!("{word} list for \x02{}\x02:", info.name));
|
||||
for a in info.access.iter().filter(|a| a.level == level) {
|
||||
ctx.notice(me, from.uid, format!(" \x02{}\x02", a.account));
|
||||
}
|
||||
}
|
||||
},
|
||||
Some("ADD") => {
|
||||
let Some(&account) = args.get(3) else {
|
||||
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account>"));
|
||||
return;
|
||||
};
|
||||
if !super::require_founder(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.access_add(chan, account, level) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02's {word} list.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("DEL") => {
|
||||
let Some(&account) = args.get(3) else {
|
||||
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> DEL <account>"));
|
||||
return;
|
||||
};
|
||||
if !super::require_founder(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.access_del(chan, account) {
|
||||
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02's {word} list.")),
|
||||
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account> | DEL <account> | LIST")),
|
||||
}
|
||||
}
|
||||
|
|
@ -96,17 +96,24 @@ impl Protocol for InspIrcd {
|
|||
// FJOIN <chan> <ts> <modes> [params] :<members> — channel create/burst.
|
||||
// Each member is "<prefixes>,<uid>"; surface a Join for each.
|
||||
"FJOIN" => {
|
||||
let chan = tokens.next().unwrap_or("").to_string();
|
||||
// Mode section is everything before the " :<members>" trailing.
|
||||
let head: Vec<&str> = rest.split(" :").next().unwrap_or(rest).split_whitespace().collect();
|
||||
let chan = head.get(1).copied().unwrap_or("");
|
||||
if chan.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
let mut out = vec![NetEvent::ChannelCreate { channel: chan.clone() }];
|
||||
let mut out = vec![NetEvent::ChannelCreate { channel: chan.to_string() }];
|
||||
if let Some(modes) = head.get(3) {
|
||||
if let Some(key) = scan_key(modes, head.get(4..).unwrap_or(&[])) {
|
||||
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
|
||||
}
|
||||
}
|
||||
// Members are "<prefixes>,<uid>[:<membid>]"; take the uid.
|
||||
for m in trailing(rest).split_whitespace() {
|
||||
if let Some((_, member)) = m.split_once(',') {
|
||||
let uid = member.split(':').next().unwrap_or("");
|
||||
if !uid.is_empty() {
|
||||
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.clone() });
|
||||
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +127,23 @@ impl Protocol for InspIrcd {
|
|||
}
|
||||
_ => vec![],
|
||||
},
|
||||
// :<uid> PART <chan> [:reason] — a user leaving a channel.
|
||||
"PART" => match (source.as_deref(), tokens.next()) {
|
||||
(Some(uid), Some(chan)) if chan.starts_with('#') => {
|
||||
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }]
|
||||
}
|
||||
_ => vec![],
|
||||
},
|
||||
// :<src> KICK <chan> <uid> :<reason> — a user removed from a channel.
|
||||
"KICK" => {
|
||||
let chan = tokens.next().unwrap_or("");
|
||||
match tokens.next() {
|
||||
Some(uid) if chan.starts_with('#') => {
|
||||
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
// :<src> FMODE <chan> <ts> <modes> [params] — a channel mode change.
|
||||
// Skip changes we made ourselves so enforcement can't loop.
|
||||
"FMODE" => {
|
||||
|
|
@ -127,7 +151,11 @@ impl Protocol for InspIrcd {
|
|||
match (source.as_deref(), a.first(), a.get(2)) {
|
||||
// Our own uids (server SID + pseudoclients) share our SID prefix.
|
||||
(Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
|
||||
vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]
|
||||
let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }];
|
||||
if let Some(key) = scan_key(modes, a.get(3..).unwrap_or(&[])) {
|
||||
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
|
|
@ -257,6 +285,38 @@ impl Protocol for InspIrcd {
|
|||
}
|
||||
}
|
||||
|
||||
// Whether a channel mode consumes a parameter, for standard InspIRCd chanmodes.
|
||||
// List and prefix modes take one on both set and unset; the key takes one on
|
||||
// both; the rest that take one do so only on set.
|
||||
fn takes_param(m: char, adding: bool) -> bool {
|
||||
match m {
|
||||
'b' | 'e' | 'I' | 'q' | 'a' | 'o' | 'h' | 'v' | 'k' => true,
|
||||
'l' | 'L' | 'f' | 'j' | 'J' | 'F' | 'H' => adding,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Walk a mode change and its params for a key (+k/-k). Returns Some(Some(key))
|
||||
// when a key is set, Some(None) when cleared, None when `k` isn't in the change.
|
||||
fn scan_key(modes: &str, params: &[&str]) -> Option<Option<String>> {
|
||||
let mut adding = true;
|
||||
let mut pi = 0;
|
||||
let mut result = None;
|
||||
for m in modes.chars() {
|
||||
match m {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
'k' => {
|
||||
result = Some(if adding { params.get(pi).map(|s| s.to_string()) } else { None });
|
||||
pi += 1;
|
||||
}
|
||||
c if takes_param(c, adding) => pi += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// The IRC "trailing" argument: everything after the first " :", else the last word.
|
||||
fn trailing(rest: &str) -> String {
|
||||
match rest.find(" :") {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,13 @@ pub enum NetEvent {
|
|||
ChannelCreate { channel: String },
|
||||
// A user joined a channel (an FJOIN member or an IJOIN), for auto-op.
|
||||
Join { uid: String, channel: String },
|
||||
// A user left a channel (PART) or was removed (KICK), for membership tracking.
|
||||
Part { uid: String, channel: String },
|
||||
// A channel's modes changed (FMODE), for enforcing mode locks. Our own
|
||||
// changes are filtered out by the protocol layer.
|
||||
ChannelModeChange { channel: String, modes: String },
|
||||
// A channel's key (+k/-k) changed, tracked so GETKEY can report it.
|
||||
ChannelKey { channel: String, key: Option<String> },
|
||||
Quit { uid: String },
|
||||
// An ircd relaying an IRCv3 account-registration request to us as the authority.
|
||||
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub enum Event {
|
|||
ChannelAkickDel { channel: String, mask: String },
|
||||
ChannelFounderSet { channel: String, founder: String },
|
||||
ChannelDescSet { channel: String, desc: String },
|
||||
ChannelEntryMsgSet { channel: String, msg: String },
|
||||
}
|
||||
|
||||
// An access-list entry: an account and its level ("op" or "voice").
|
||||
|
|
@ -81,6 +82,9 @@ pub struct ChannelInfo {
|
|||
// Free-text description, shown in INFO.
|
||||
#[serde(default)]
|
||||
pub desc: String,
|
||||
// Message noticed to users as they join.
|
||||
#[serde(default)]
|
||||
pub entrymsg: String,
|
||||
}
|
||||
|
||||
impl ChannelInfo {
|
||||
|
|
@ -403,6 +407,9 @@ impl Db {
|
|||
if !c.desc.is_empty() {
|
||||
snapshot.push(Event::ChannelDescSet { channel: c.name.clone(), desc: c.desc.clone() });
|
||||
}
|
||||
if !c.entrymsg.is_empty() {
|
||||
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
|
||||
}
|
||||
}
|
||||
self.log.compact(snapshot)?;
|
||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||
|
|
@ -543,7 +550,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() });
|
||||
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() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -660,6 +667,19 @@ impl Db {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Set `channel`'s entry message (empty clears it).
|
||||
pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> {
|
||||
let k = key(channel);
|
||||
if !self.channels.contains_key(&k) {
|
||||
return Err(ChanError::NoChannel);
|
||||
}
|
||||
self.log
|
||||
.append(Event::ChannelEntryMsgSet { channel: channel.to_string(), msg: msg.to_string() })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
self.channels.get_mut(&k).unwrap().entrymsg = msg.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unregister `name`.
|
||||
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
||||
let k = key(name);
|
||||
|
|
@ -692,7 +712,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
|||
}
|
||||
}
|
||||
Event::ChannelRegistered { name, founder, ts } => {
|
||||
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() });
|
||||
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() });
|
||||
}
|
||||
Event::ChannelDropped { name } => {
|
||||
channels.remove(&key(&name));
|
||||
|
|
@ -735,6 +755,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
|||
c.desc = desc;
|
||||
}
|
||||
}
|
||||
Event::ChannelEntryMsgSet { channel, msg } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.entrymsg = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,33 +191,56 @@ impl Engine {
|
|||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
// Give the founder and access-list members their status on join.
|
||||
// On join: record membership, enforce the auto-kick list, give access
|
||||
// members their status mode, and send the entry message.
|
||||
NetEvent::Join { uid, channel } => {
|
||||
self.network.channel_join(&channel, &uid);
|
||||
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)));
|
||||
let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty());
|
||||
let mut out = Vec::new();
|
||||
match mode {
|
||||
// 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.
|
||||
// A user with access gets their status mode, plus the entry message.
|
||||
Some(m) => {
|
||||
if let Some(msg) = entrymsg {
|
||||
out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg });
|
||||
}
|
||||
out.push(NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") });
|
||||
}
|
||||
// No access: an auto-kick match is banned and kicked, else greeted.
|
||||
None => {
|
||||
let nick = self.network.nick_of(&uid).unwrap_or("*");
|
||||
let host = self.network.host_of(&uid).unwrap_or("*");
|
||||
let nick = self.network.nick_of(&uid).unwrap_or("*").to_string();
|
||||
let host = self.network.host_of(&uid).unwrap_or("*").to_string();
|
||||
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 akick = self.db.channel(&channel).and_then(|c| c.akick_match(&hostmask)).map(|k| {
|
||||
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 },
|
||||
]
|
||||
(k.mask.clone(), reason)
|
||||
});
|
||||
match akick {
|
||||
Some((mask, reason)) => {
|
||||
self.network.channel_part(&channel, &uid);
|
||||
out.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("+b {mask}") });
|
||||
out.push(NetAction::Kick { from, channel, uid, reason });
|
||||
}
|
||||
None => Vec::new(),
|
||||
None => {
|
||||
if let Some(msg) = entrymsg {
|
||||
out.push(NetAction::Notice { from, to: uid, text: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
NetEvent::Part { uid, channel } => {
|
||||
self.network.channel_part(&channel, &uid);
|
||||
Vec::new()
|
||||
}
|
||||
NetEvent::ChannelKey { channel, key } => {
|
||||
self.network.set_channel_key(&channel, key);
|
||||
Vec::new()
|
||||
}
|
||||
NetEvent::Quit { uid } => {
|
||||
self.network.user_quit(&uid);
|
||||
self.sasl_sessions.remove(&uid); // drop any half-finished exchange
|
||||
|
|
@ -1124,6 +1147,61 @@ mod tests {
|
|||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC nope"), "founder can change"));
|
||||
}
|
||||
|
||||
// ChanServ entrymsg, enforce, getkey, seen, clone and the xop lists.
|
||||
#[test]
|
||||
fn chanserv_extended() {
|
||||
use crate::chanserv::ChanServ;
|
||||
let path = std::env::temp_dir().join("fedserv-cs-ext.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("carol", "pw", 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() })
|
||||
};
|
||||
let notice = |out: &[NetAction], needle: &str| {
|
||||
out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)))
|
||||
};
|
||||
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() });
|
||||
|
||||
// ENTRYMSG: set it, then a joining user is noticed it.
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ENTRYMSG #c welcome aboard"), "updated"));
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h2".into() });
|
||||
let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into() });
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAC" && text == "welcome aboard")), "{out:?}");
|
||||
|
||||
// XOP: AOP add shows in the AOP list and grants op access.
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c ADD carol"), "Added"));
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol"));
|
||||
|
||||
// ENFORCE: alice present gets +o, the akick-matching guest is kicked.
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into() });
|
||||
to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out");
|
||||
let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c");
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "{out:?}");
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}");
|
||||
|
||||
// GETKEY: reflects a tracked key change.
|
||||
e.handle(NetEvent::ChannelKey { channel: "#c".into(), key: Some("s3cret".into()) });
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "GETKEY #c"), "s3cret"));
|
||||
|
||||
// SEEN: an online user vs one who quit.
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN alice"), "currently online"));
|
||||
e.handle(NetEvent::Quit { uid: "000AAAAAC".into() });
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen"));
|
||||
|
||||
// CLONE: copy #c's settings (incl. the AOP) into a second owned channel.
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "REGISTER #d".into() });
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "CLONE #c #d"), "Copied"));
|
||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #d LIST"), "carol"));
|
||||
}
|
||||
|
||||
// A registered channel (re)appearing re-asserts +r; an unregistered one does not.
|
||||
#[test]
|
||||
fn channel_create_reasserts_registered_mode() {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Live network view, rebuilt from the uplink's burst each connect (ephemeral —
|
||||
// unlike the account store, which persists).
|
||||
#[derive(Default)]
|
||||
pub struct Network {
|
||||
pub users: HashMap<String, User>, // keyed by UID
|
||||
pub channels: HashMap<String, Channel>,
|
||||
channels: HashMap<String, Channel>, // keyed by lowercase name
|
||||
accounts: HashMap<String, String>, // UID -> logged-in account, until logout/quit
|
||||
seen: HashMap<String, Seen>, // lowercase nick -> last activity
|
||||
}
|
||||
|
||||
pub struct User {
|
||||
|
|
@ -15,10 +17,26 @@ pub struct User {
|
|||
pub host: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
// A channel's live membership and current key (+k), tracked from the burst.
|
||||
#[derive(Default)]
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub members: HashSet<String>, // uids
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
// The last time a nick was seen, and doing what.
|
||||
pub struct Seen {
|
||||
pub nick: String,
|
||||
pub ts: u64,
|
||||
pub what: String,
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn lc(s: &str) -> String {
|
||||
s.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
impl Network {
|
||||
|
|
@ -37,13 +55,20 @@ impl Network {
|
|||
|
||||
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
||||
if let Some(user) = self.users.get_mut(uid) {
|
||||
self.seen.insert(lc(&nick), Seen { nick: nick.clone(), ts: now(), what: format!("changing nick from {}", user.nick) });
|
||||
user.nick = nick;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_quit(&mut self, uid: &str) {
|
||||
if let Some(u) = self.users.get(uid) {
|
||||
self.seen.insert(lc(&u.nick), Seen { nick: u.nick.clone(), ts: now(), what: "quitting".to_string() });
|
||||
}
|
||||
self.users.remove(uid);
|
||||
self.accounts.remove(uid);
|
||||
for c in self.channels.values_mut() {
|
||||
c.members.remove(uid);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nick_of(&self, uid: &str) -> Option<&str> {
|
||||
|
|
@ -63,4 +88,39 @@ impl Network {
|
|||
pub fn clear_account(&mut self, uid: &str) {
|
||||
self.accounts.remove(uid);
|
||||
}
|
||||
|
||||
// A user joined a channel: record membership and last-seen.
|
||||
pub fn channel_join(&mut self, channel: &str, uid: &str) {
|
||||
self.channels.entry(lc(channel)).or_default().members.insert(uid.to_string());
|
||||
if let Some(nick) = self.nick_of(uid) {
|
||||
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("joining {channel}") });
|
||||
}
|
||||
}
|
||||
|
||||
// A user left a channel (part/kick): drop membership and record last-seen.
|
||||
pub fn channel_part(&mut self, channel: &str, uid: &str) {
|
||||
if let Some(c) = self.channels.get_mut(&lc(channel)) {
|
||||
c.members.remove(uid);
|
||||
}
|
||||
if let Some(nick) = self.nick_of(uid) {
|
||||
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") });
|
||||
}
|
||||
}
|
||||
|
||||
// Uids currently in `channel`.
|
||||
pub fn channel_members(&self, channel: &str) -> impl Iterator<Item = &str> {
|
||||
self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
pub fn set_channel_key(&mut self, channel: &str, key: Option<String>) {
|
||||
self.channels.entry(lc(channel)).or_default().key = key;
|
||||
}
|
||||
|
||||
pub fn channel_key(&self, channel: &str) -> Option<&str> {
|
||||
self.channels.get(&lc(channel)).and_then(|c| c.key.as_deref())
|
||||
}
|
||||
|
||||
pub fn last_seen(&self, nick: &str) -> Option<&Seen> {
|
||||
self.seen.get(&lc(nick))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue