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:
Jean Chevronnet 2026-07-14 04:33:48 +00:00
parent 09be99785e
commit 06227e73da
No known key found for this signature in database
8 changed files with 260 additions and 1 deletions

View file

@ -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]

View file

@ -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)
}
}

View file

@ -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