Add ChanFix: recover opless channels from op-time scores
The engine samples live op state on a short cadence, scoring op-time per identity (account, else host) on the network view — a net +1 per pass while opped, -1 while idle, so trusted regulars rise and stale ops fade. ChanFix (operator-only) reads those scores: SCORES <#chan> shows the standings, CHANFIX <#chan> reops the present regulars scoring above a share of the top score, once the channel is opless enough and has real history. It defers to ChanServ — a registered channel is refused. Opt-in. C++ note: the original persists scores to the services DB and runs its own timers; here scoring rides the existing periodic loop and lives on the ephemeral network view, so it's node-local and rebuilds itself.
This commit is contained in:
parent
09be99785e
commit
06227e73da
8 changed files with 260 additions and 1 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -327,6 +327,7 @@ dependencies = [
|
|||
"base64",
|
||||
"fedserv-api",
|
||||
"fedserv-botserv",
|
||||
"fedserv-chanfix",
|
||||
"fedserv-chanserv",
|
||||
"fedserv-diceserv",
|
||||
"fedserv-example",
|
||||
|
|
@ -369,6 +370,13 @@ dependencies = [
|
|||
"fedserv-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fedserv-chanfix"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"fedserv-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fedserv-chanserv"
|
||||
version = "0.0.1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv"]
|
||||
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv", "chanfix"]
|
||||
|
||||
[package]
|
||||
name = "fedserv"
|
||||
|
|
@ -22,6 +22,7 @@ fedserv-diceserv = { path = "diceserv" }
|
|||
fedserv-infoserv = { path = "infoserv" }
|
||||
fedserv-reportserv = { path = "reportserv" }
|
||||
fedserv-groupserv = { path = "groupserv" }
|
||||
fedserv-chanfix = { path = "chanfix" }
|
||||
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,10 @@ pub trait NetView {
|
|||
// Session limiting: live sessions from an IP, and IPs at/over a threshold.
|
||||
fn session_count(&self, ip: &str) -> u32;
|
||||
fn sessions_over(&self, min: u32) -> Vec<(String, u32)>;
|
||||
// ChanFix: op-time scores (identity -> score, highest first) and the current
|
||||
// op count for a channel.
|
||||
fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)>;
|
||||
fn op_count(&self, channel: &str) -> usize;
|
||||
// Search the recent moderation/action incident log (newest first, capped at
|
||||
// `limit`). An empty pattern returns the most recent; otherwise matches the
|
||||
// id or a case-insensitive substring of the summary.
|
||||
|
|
|
|||
8
chanfix/Cargo.toml
Normal file
8
chanfix/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "fedserv-chanfix"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
description = "ChanFix: reop opless unregistered channels from accumulated op-time scores."
|
||||
|
||||
[dependencies]
|
||||
fedserv-api = { path = "../api" }
|
||||
110
chanfix/src/lib.rs
Normal file
110
chanfix/src/lib.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! ChanFix helps recover an opless channel. The engine continuously scores
|
||||
//! op-time per identity from live network state; ChanFix reads those scores to
|
||||
//! reop the channel's trusted regulars. It **defers to ChanServ**: a registered
|
||||
//! channel is ChanServ's job, so ChanFix refuses to touch one. Operator-only.
|
||||
//!
|
||||
//! SCORES <#channel> shows the standings; CHANFIX <#channel> performs the fix.
|
||||
|
||||
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
|
||||
|
||||
// A channel with this many ops isn't opless and needs no fix.
|
||||
const OP_THRESHOLD: usize = 3;
|
||||
// The top score must reach this before a channel has enough history to fix.
|
||||
const MIN_FIX_SCORE: u32 = 12;
|
||||
// Reop identities scoring at least this fraction of the top score.
|
||||
const FIX_STEP: f64 = 0.30;
|
||||
|
||||
pub struct ChanFix {
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
impl Service for ChanFix {
|
||||
fn nick(&self) -> &str {
|
||||
"ChanFix"
|
||||
}
|
||||
fn uid(&self) -> &str {
|
||||
&self.uid
|
||||
}
|
||||
fn gecos(&self) -> &str {
|
||||
"Channel Fixer"
|
||||
}
|
||||
|
||||
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||
let me = self.uid.as_str();
|
||||
// The whole service is operator-only.
|
||||
if !from.privs.any() {
|
||||
ctx.notice(me, from.uid, "Access denied — ChanFix is for services operators.");
|
||||
return;
|
||||
}
|
||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("SCORES") => scores(me, from, args.get(1).copied(), ctx, net),
|
||||
Some("CHANFIX") | Some("FIX") => fix(me, from, args.get(1).copied(), ctx, net, db),
|
||||
Some("HELP") | None => help(me, from, ctx),
|
||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02SCORES\x02 or \x02CHANFIX\x02 <#channel>.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
|
||||
ctx.notice(me, from.uid, "ChanFix recovers opless channels from accumulated op-time. \x02SCORES\x02 <#channel> shows the standings; \x02CHANFIX\x02 <#channel> reops its trusted regulars. It won't touch a ChanServ-registered channel.");
|
||||
}
|
||||
|
||||
fn scores(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView) {
|
||||
let Some(chan) = chan else {
|
||||
ctx.notice(me, from.uid, "Syntax: SCORES <#channel>");
|
||||
return;
|
||||
};
|
||||
let scores = net.chanfix_scores(chan);
|
||||
if scores.is_empty() {
|
||||
ctx.notice(me, from.uid, format!("No op-time recorded for \x02{chan}\x02 yet."));
|
||||
return;
|
||||
}
|
||||
for (id, score) in scores.iter().take(15) {
|
||||
ctx.notice(me, from.uid, format!(" \x02{score}\x02 {id}"));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Top {} of {} scored identit{} for \x02{chan}\x02.", scores.len().min(15), scores.len(), if scores.len() == 1 { "y" } else { "ies" }));
|
||||
}
|
||||
|
||||
fn fix(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||
let Some(chan) = chan else {
|
||||
ctx.notice(me, from.uid, "Syntax: CHANFIX <#channel>");
|
||||
return;
|
||||
};
|
||||
// Defer to ChanServ for anything it owns.
|
||||
if db.channel(chan).is_some() {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is registered — use \x02ChanServ\x02 to manage it."));
|
||||
return;
|
||||
}
|
||||
if net.op_count(chan) >= OP_THRESHOLD {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 already has ops — nothing to fix."));
|
||||
return;
|
||||
}
|
||||
let scores = net.chanfix_scores(chan);
|
||||
let highscore = scores.first().map(|(_, s)| *s).unwrap_or(0);
|
||||
if highscore < MIN_FIX_SCORE {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 doesn't have enough op-time history to fix yet."));
|
||||
return;
|
||||
}
|
||||
let threshold = ((highscore as f64 * FIX_STEP) as u32).max(1);
|
||||
|
||||
// Reop present members whose identity scores at or above the threshold and who
|
||||
// aren't already opped.
|
||||
let members: Vec<String> = net.channel_members(chan);
|
||||
let mut opped = 0;
|
||||
for uid in &members {
|
||||
if net.is_op(chan, uid) {
|
||||
continue;
|
||||
}
|
||||
let id = net.account_of(uid).or_else(|| net.host_of(uid));
|
||||
let qualifies = id.is_some_and(|i| scores.iter().any(|(sid, s)| sid.eq_ignore_ascii_case(i) && *s >= threshold));
|
||||
if qualifies {
|
||||
ctx.channel_mode(me, chan, &format!("+o {uid}"));
|
||||
opped += 1;
|
||||
}
|
||||
}
|
||||
if opped == 0 {
|
||||
ctx.notice(me, from.uid, format!("None of \x02{chan}\x02's trusted regulars are here right now."));
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("Reopped \x02{opped}\x02 trusted regular(s) in \x02{chan}\x02."));
|
||||
}
|
||||
}
|
||||
|
|
@ -575,6 +575,11 @@ impl Engine {
|
|||
Ok(false)
|
||||
}
|
||||
|
||||
// One ChanFix scoring pass over the live network (run periodically).
|
||||
pub fn chanfix_tick(&mut self) {
|
||||
self.network.chanfix_sample();
|
||||
}
|
||||
|
||||
// Drop accounts and channels left inactive past the configured thresholds.
|
||||
// Lazy: computed from stored last-activity stamps on this periodic pass, with
|
||||
// no per-record timer. Opers, accounts with a live session, and occupied
|
||||
|
|
@ -4844,6 +4849,60 @@ mod tests {
|
|||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
|
||||
}
|
||||
|
||||
// ChanFix: op-time is scored from live state, and CHANFIX reops the trusted
|
||||
// regulars of an opless, unregistered channel; it defers to ChanServ.
|
||||
#[test]
|
||||
fn chanfix_scores_and_fixes_opless_channels() {
|
||||
use fedserv_chanfix::ChanFix;
|
||||
let path = std::env::temp_dir().join("fedserv-chanfix.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut db = Db::open(&path, "42S");
|
||||
db.scram_iterations = 4096;
|
||||
for a in ["alice", "bob", "staff"] {
|
||||
db.register(a, "password1", None).unwrap();
|
||||
}
|
||||
db.register_channel("#owned", "staff").unwrap();
|
||||
let mut e = Engine::new(
|
||||
vec![
|
||||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||
Box::new(ChanFix { uid: "42SAAAAAM".into() }),
|
||||
],
|
||||
db,
|
||||
);
|
||||
e.set_sid("42S".into());
|
||||
let mut opers = std::collections::HashMap::new();
|
||||
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Auspex));
|
||||
e.set_opers(opers);
|
||||
let cf = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAM".into(), text: t.into() });
|
||||
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||||
let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}")));
|
||||
|
||||
for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAS", "staff")] {
|
||||
e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||
}
|
||||
// alice and bob hold ops in an unregistered #lobby; score their op-time.
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAA".into(), channel: "#lobby".into(), op: true });
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#lobby".into(), op: true });
|
||||
for _ in 0..15 {
|
||||
e.chanfix_tick();
|
||||
}
|
||||
|
||||
// SCORES shows the accumulated standings.
|
||||
assert!(has(&cf(&mut e, "000AAAAAS", "SCORES #lobby"), "alice"), "scores recorded");
|
||||
// A non-oper can't use ChanFix at all.
|
||||
assert!(has(&cf(&mut e, "000AAAAAA", "SCORES #lobby"), "Access denied"), "oper-only");
|
||||
|
||||
// The channel goes opless; CHANFIX reops the trusted regulars still present.
|
||||
e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAA".into(), op: false });
|
||||
e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAB".into(), op: false });
|
||||
let out = cf(&mut e, "000AAAAAS", "CHANFIX #lobby");
|
||||
assert!(opped(&out, "000AAAAAA") && opped(&out, "000AAAAAB"), "reopped the regulars: {out:?}");
|
||||
|
||||
// It won't touch a ChanServ-registered channel.
|
||||
assert!(has(&cf(&mut e, "000AAAAAS", "CHANFIX #owned"), "registered"), "defers to ChanServ");
|
||||
}
|
||||
|
||||
// GroupServ interconnection: a channel grants access to a !group, and every
|
||||
// group member inherits that channel access (auto-op on join).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,8 +28,15 @@ pub struct Network {
|
|||
// Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH.
|
||||
incidents: VecDeque<Incident>,
|
||||
incident_seq: u64,
|
||||
// ChanFix op-time scores: channel (lowercase) -> identity (account or host) ->
|
||||
// score. Sampled from live op state; ephemeral (node-local, rebuilt over time).
|
||||
chanfix: HashMap<String, HashMap<String, u32>>,
|
||||
}
|
||||
|
||||
// ChanFix scoring caps and rates.
|
||||
const CHANFIX_MAX: u32 = 5000;
|
||||
const CHANFIX_BUMP: u32 = 2; // per sample while opped (net +1 after the -1 decay)
|
||||
|
||||
// One recorded action: a short id (also stamped into the action's reason), the
|
||||
// unix time it happened, and a human summary.
|
||||
struct Incident {
|
||||
|
|
@ -303,6 +310,45 @@ impl Network {
|
|||
self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
// How many uids currently hold op in `channel`.
|
||||
pub fn op_count(&self, channel: &str) -> usize {
|
||||
self.channels.get(&lc(channel)).map_or(0, |c| c.ops.len())
|
||||
}
|
||||
|
||||
// One ChanFix sampling pass: every tracked score decays by 1; every currently-
|
||||
// opped user's identity (its account, else its host) gains CHANFIX_BUMP — a net
|
||||
// +1 while opped, -1 while idle, so trusted regulars rise and stale ops fade.
|
||||
pub fn chanfix_sample(&mut self) {
|
||||
for scores in self.chanfix.values_mut() {
|
||||
for v in scores.values_mut() {
|
||||
*v = v.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
let mut samples: Vec<(String, String)> = Vec::new();
|
||||
for (chan, c) in &self.channels {
|
||||
for uid in &c.ops {
|
||||
if let Some(id) = self.accounts.get(uid).cloned().or_else(|| self.users.get(uid).map(|u| u.host.clone())) {
|
||||
samples.push((chan.clone(), id));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (chan, id) in samples {
|
||||
let s = self.chanfix.entry(chan).or_default().entry(id).or_insert(0);
|
||||
*s = (*s + CHANFIX_BUMP).min(CHANFIX_MAX);
|
||||
}
|
||||
for scores in self.chanfix.values_mut() {
|
||||
scores.retain(|_, v| *v > 0);
|
||||
}
|
||||
self.chanfix.retain(|_, m| !m.is_empty());
|
||||
}
|
||||
|
||||
// A channel's ChanFix scores (identity -> score), highest first.
|
||||
pub fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)> {
|
||||
let mut v: Vec<(String, u32)> = self.chanfix.get(&lc(channel)).into_iter().flat_map(|m| m.iter().map(|(k, &s)| (k.clone(), s))).collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn set_channel_key(&mut self, channel: &str, key: Option<String>) {
|
||||
self.channels.entry(lc(channel)).or_default().key = key;
|
||||
}
|
||||
|
|
@ -365,4 +411,10 @@ impl NetView for Network {
|
|||
fn search_incidents(&self, pattern: &str, limit: usize) -> Vec<IncidentView> {
|
||||
Network::search_incidents(self, pattern, limit)
|
||||
}
|
||||
fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)> {
|
||||
Network::chanfix_scores(self, channel)
|
||||
}
|
||||
fn op_count(&self, channel: &str) -> usize {
|
||||
Network::op_count(self, channel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/main.rs
17
src/main.rs
|
|
@ -25,6 +25,7 @@ use fedserv_diceserv::DiceServ;
|
|||
use fedserv_infoserv::InfoServ;
|
||||
use fedserv_reportserv::ReportServ;
|
||||
use fedserv_groupserv::GroupServ;
|
||||
use fedserv_chanfix::ChanFix;
|
||||
use fedserv_example::ExampleServ;
|
||||
use fedserv_inspircd::InspIrcd;
|
||||
use fedserv_nickserv::NickServ;
|
||||
|
|
@ -113,6 +114,11 @@ async fn main() -> Result<()> {
|
|||
uid: format!("{}AAAAAL", cfg.server.sid),
|
||||
}));
|
||||
}
|
||||
if enabled("chanfix") {
|
||||
services.push(Box::new(ChanFix {
|
||||
uid: format!("{}AAAAAM", cfg.server.sid),
|
||||
}));
|
||||
}
|
||||
if enabled("example") {
|
||||
services.push(Box::new(ExampleServ {
|
||||
uid: format!("{}AAAAAC", cfg.server.sid),
|
||||
|
|
@ -176,6 +182,17 @@ async fn main() -> Result<()> {
|
|||
});
|
||||
}
|
||||
|
||||
// ChanFix scores op-time on a shorter cadence so the fix has fresh data.
|
||||
if enabled("chanfix") {
|
||||
let engine = engine.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
engine.lock().await.chanfix_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
||||
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");
|
||||
link::run(proto, engine, &addr, irc_rx, cfg.email.clone()).await
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue