From e785c9d8ac99b2660a602b84a693546c689b2801 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 10:07:02 +0000 Subject: [PATCH] Auto-op the founder on join Surface channel joins (FJOIN members, stripping the membid, and IJOIN) and set +o for a joining user who is identified as the channel's founder. --- src/engine/mod.rs | 30 ++++++++++++++++++++++++++ src/proto/inspircd.rs | 50 +++++++++++++++++++++++++++++++++++++------ src/proto/mod.rs | 2 ++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ac363ee..36292af 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -186,6 +186,15 @@ impl Engine { None => Vec::new(), } } + // Auto-op the founder when they join their registered channel. + NetEvent::Join { uid, channel } => { + let founder = self.db.channel(&channel).map(|c| c.founder.clone()); + let account = self.network.account_of(&uid).map(str::to_string); + match (founder, account) { + (Some(f), Some(a)) if f == a => vec![NetAction::ChannelMode { channel, modes: format!("+o {uid}") }], + _ => Vec::new(), + } + } NetEvent::Quit { uid } => { self.network.user_quit(&uid); self.sasl_sessions.remove(&uid); // drop any half-finished exchange @@ -1001,4 +1010,25 @@ mod tests { // An unrelated mode change is left alone. assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into() }).is_empty()); } + + // The founder is opped when they join their channel; others are not. + #[test] + fn founder_is_opped_on_join() { + let path = std::env::temp_dir().join("fedserv-autoop.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("#x", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let mut e = Engine::new(vec![Box::new(ns)], db); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#x" && modes == "+o 000AAAAAB")), "founder opped: {out:?}"); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); + assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped"); + } } diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 5678774..d273518 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -91,8 +91,30 @@ impl Protocol for InspIrcd { _ => vec![], }, // FJOIN [params] : — channel create/burst. - "FJOIN" => match tokens.next() { - Some(chan) if !chan.is_empty() => vec![NetEvent::ChannelCreate { channel: chan.to_string() }], + // Each member is ","; surface a Join for each. + "FJOIN" => { + let chan = tokens.next().unwrap_or("").to_string(); + if chan.is_empty() { + vec![] + } else { + let mut out = vec![NetEvent::ChannelCreate { channel: chan.clone() }]; + // Members are ",[:]"; 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 + } + } + // : IJOIN — a single user joining an existing channel. + "IJOIN" => match (source.as_deref(), tokens.next()) { + (Some(uid), Some(chan)) if chan.starts_with('#') => { + vec![NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() }] + } _ => vec![], }, // : FMODE [params] — a channel mode change. @@ -249,14 +271,13 @@ mod tests { ); } - // FJOIN (channel create/burst) surfaces as ChannelCreate for the channel. + // FJOIN (channel create/burst) surfaces as ChannelCreate for the channel, and + // the member's uid is taken without its :membid suffix. #[test] fn parses_fjoin_as_channel_create() { let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :o,0IRAAAAAB:0"); - assert!( - matches!(ev.as_slice(), [NetEvent::ChannelCreate { channel }] if channel == "#chan"), - "{ev:?}" - ); + assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan"), "{ev:?}"); + assert!(ev.iter().any(|e| matches!(e, NetEvent::Join { uid, .. } if uid == "0IRAAAAAB")), "{ev:?}"); } // A peer FMODE surfaces as a mode change; our own (sid 42S) is filtered out. @@ -270,4 +291,19 @@ mod tests { let ev = proto().parse(":42S FMODE #chan 1783845132 -m"); assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}"); } + + // FJOIN members and IJOIN both surface as Join events. + #[test] + fn parses_joins() { + let ev = proto().parse(":0IR FJOIN #chan 1783845132 +nt :o,0IRAAAAAB ,0IRAAAAAC"); + assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan")); + let joins: Vec<&str> = ev.iter().filter_map(|e| match e { + NetEvent::Join { uid, .. } => Some(uid.as_str()), + _ => None, + }).collect(); + assert_eq!(joins, ["0IRAAAAAB", "0IRAAAAAC"]); + + let ev = proto().parse(":0IRAAAAAD IJOIN #chan"); + assert!(matches!(ev.as_slice(), [NetEvent::Join { uid, channel }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}"); + } } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index a640c1f..4f7db90 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -15,6 +15,8 @@ pub enum NetEvent { // A channel was created or bursted (InspIRCd FJOIN). Subsequent single joins // arrive as IJOIN and are not surfaced. ChannelCreate { channel: String }, + // A user joined a channel (an FJOIN member or an IJOIN), for auto-op. + Join { 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 },