modules: split services and the ircd link into external crates
nickserv, chanserv and the InspIRCd protocol move out of the binary into their own workspace crates (fedserv-nickserv, fedserv-chanserv, fedserv-inspircd), each depending only on fedserv-api. human_time and the branded account emails move into the SDK crate so a module needs nothing from core; the engine keeps its own inherent methods and builds emails via fedserv-api too. The bin now constructs each module from its crate instead of an in-tree #[path] include. Proves the SDK is self-sufficient: a third-party module is the same shape.
This commit is contained in:
parent
8ed1a9ab70
commit
596630df53
52 changed files with 197 additions and 162 deletions
|
|
@ -1,19 +0,0 @@
|
|||
# modules/
|
||||
|
||||
The pluggable parts of fedserv: one directory per protocol and per pseudoclient.
|
||||
The core (engine, event log, gossip, link loop) stays in `../src/`.
|
||||
|
||||
```
|
||||
modules/
|
||||
protocol/ ircd link protocols (inspircd.rs)
|
||||
nickserv/ NickServ pseudoclient
|
||||
chanserv/ ChanServ pseudoclient
|
||||
```
|
||||
|
||||
`src/main.rs` pulls each in with `#[path = "../modules/..."]`, so crate paths
|
||||
stay flat (`crate::proto`, `crate::nickserv`, `crate::chanserv`).
|
||||
|
||||
## Naming
|
||||
|
||||
A service directory holds its pseudoclient file plus, as commands are split out,
|
||||
one file per command: `register.rs`, `mode.rs`, etc.
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>");
|
||||
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!("Access list for \x02{}\x02:", info.name));
|
||||
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder)", info.founder));
|
||||
for a in &info.access {
|
||||
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", a.account, a.level));
|
||||
}
|
||||
}
|
||||
},
|
||||
Some("ADD") => {
|
||||
let (Some(&account), Some(&level)) = (args.get(3), args.get(4)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> ADD <account> <op|voice>");
|
||||
return;
|
||||
};
|
||||
let level = level.to_ascii_lowercase();
|
||||
if level != "op" && level != "voice" {
|
||||
ctx.notice(me, from.uid, "Level must be \x02op\x02 or \x02voice\x02.");
|
||||
return;
|
||||
}
|
||||
if !is_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 as \x02{level}\x02.")),
|
||||
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, "Syntax: ACCESS <#channel> DEL <account>");
|
||||
return;
|
||||
};
|
||||
if !is_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.")),
|
||||
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, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>"),
|
||||
}
|
||||
}
|
||||
|
||||
// True if `from` is the channel's founder; otherwise notices why and returns false.
|
||||
fn is_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> 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()) => {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change access."));
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// AKICK <#channel> ADD <mask> [reason] | DEL <mask> | 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 dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD <mask> [reason] | DEL <mask> | 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 <mask> [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 <mask>");
|
||||
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 <mask> [reason] | DEL <mask> | LIST"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// BAN <#channel> <nick> [reason]: ban *!*@host and kick the user.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: BAN <#channel> <nick> [reason]");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let Some(target) = net.uid_by_nick(nick).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
|
||||
return;
|
||||
};
|
||||
let host = net.host_of(&target).unwrap_or("*");
|
||||
ctx.channel_mode(me, chan, &format!("+b *!*@{host}"));
|
||||
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() };
|
||||
ctx.kick(me, chan, &target, &reason);
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
use crate::engine::db::{ChanError, ChannelView, Store};
|
||||
use crate::engine::service::{Sender, Service, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
#[path = "mode.rs"]
|
||||
mod mode;
|
||||
#[path = "access.rs"]
|
||||
mod access;
|
||||
#[path = "op.rs"]
|
||||
mod op;
|
||||
#[path = "kick.rs"]
|
||||
mod kick;
|
||||
#[path = "ban.rs"]
|
||||
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;
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl Service for ChanServ {
|
||||
fn nick(&self) -> &str {
|
||||
"ChanServ"
|
||||
}
|
||||
fn uid(&self) -> &str {
|
||||
&self.uid
|
||||
}
|
||||
fn gecos(&self) -> &str {
|
||||
"Channel Services"
|
||||
}
|
||||
fn manages_channels(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
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();
|
||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("REGISTER") => {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: REGISTER <#channel>");
|
||||
return;
|
||||
};
|
||||
if !chan.starts_with('#') {
|
||||
ctx.notice(me, from.uid, "Channel names start with \x02#\x02.");
|
||||
return;
|
||||
}
|
||||
// The founder is the account the sender is identified to.
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in to register a channel. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
// You can only register a channel you actually control right now.
|
||||
if !net.is_op(chan, from.uid) {
|
||||
ctx.notice(me, from.uid, format!("You must be a channel operator (\x02@\x02) in \x02{chan}\x02 to register it."));
|
||||
return;
|
||||
}
|
||||
match db.register_channel(chan, account) {
|
||||
Ok(()) => {
|
||||
ctx.channel_mode(me, chan, "+r"); // mark the channel registered
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is now registered and you are its founder. Enjoy!"));
|
||||
}
|
||||
Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is already registered. Try \x02INFO {chan}\x02 to see who owns it.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("INFO") => {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: INFO <#channel>");
|
||||
return;
|
||||
};
|
||||
match db.channel(chan) {
|
||||
Some(info) => {
|
||||
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", info.name));
|
||||
ctx.notice(me, from.uid, format!(" Founder : \x02{}\x02", info.founder));
|
||||
if !info.desc.is_empty() {
|
||||
ctx.notice(me, from.uid, format!(" Description: {}", info.desc));
|
||||
}
|
||||
ctx.notice(me, from.uid, format!(" Registered : {}", crate::engine::db::human_time(info.ts)));
|
||||
}
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||
}
|
||||
}
|
||||
Some("DROP") => {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: DROP <#channel>");
|
||||
return;
|
||||
};
|
||||
// Read the founder, then drop, without holding the borrow across the mutation.
|
||||
let founder = match db.channel(chan) {
|
||||
Some(info) => info.founder.clone(),
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if from.account != Some(founder.as_str()) {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can drop it."));
|
||||
return;
|
||||
}
|
||||
match db.drop_channel(chan) {
|
||||
Ok(()) => {
|
||||
ctx.channel_mode(me, chan, "-r"); // no longer registered
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has been dropped and is no longer registered."));
|
||||
}
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("MLOCK") => {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: MLOCK <#channel> [modes], e.g. MLOCK #chan +nt-s");
|
||||
return;
|
||||
};
|
||||
// No modes given: show the current lock.
|
||||
if args.len() <= 2 {
|
||||
match db.channel(chan) {
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||
Some(info) if info.lock_on.is_empty() && info.lock_off.is_empty() => {
|
||||
ctx.notice(me, from.uid, format!("\x02{}\x02 has no mode lock set.", info.name));
|
||||
}
|
||||
Some(info) => ctx.notice(me, from.uid, format!("Mode lock for \x02{}\x02: \x02{}\x02", info.name, show_mlock(&info))),
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Setting the lock: founder only.
|
||||
let founder = match db.channel(chan) {
|
||||
Some(info) => info.founder.clone(),
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if from.account != Some(founder.as_str()) {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can set its mode lock."));
|
||||
return;
|
||||
}
|
||||
let (on, off) = parse_mlock(&args[2..].concat());
|
||||
match db.set_mlock(chan, &on, &off) {
|
||||
Ok(()) => {
|
||||
if let Some(info) = db.channel(chan) {
|
||||
ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Mode lock for \x02{chan}\x02 updated."));
|
||||
}
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("MODE") => mode::handle(me, from, args, ctx, db),
|
||||
Some("ACCESS") => access::handle(me, from, args, ctx, db),
|
||||
Some("OP") => op::handle(me, from, "+o", args, ctx, net, db),
|
||||
Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db),
|
||||
Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db),
|
||||
Some("DEVOICE") => op::handle(me, from, "-v", args, ctx, net, db),
|
||||
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("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("SET") => set::handle(me, from, args, ctx, db),
|
||||
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: &dyn Store) -> 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: &dyn Store) -> bool {
|
||||
match (from.account, db.channel(chan)) {
|
||||
(_, None) => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
false
|
||||
}
|
||||
(Some(acc), Some(info)) if info.is_op(acc) => true,
|
||||
_ => {
|
||||
ctx.notice(me, from.uid, format!("You need operator access to \x02{chan}\x02."));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is
|
||||
// implicit for a registered channel and is ignored here.
|
||||
fn parse_mlock(spec: &str) -> (String, String) {
|
||||
let (mut on, mut off) = (String::new(), String::new());
|
||||
let mut adding = true;
|
||||
for ch in spec.chars() {
|
||||
match ch {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
'r' => {}
|
||||
m if m.is_ascii_alphabetic() => {
|
||||
on.retain(|c| c != m);
|
||||
off.retain(|c| c != m);
|
||||
if adding {
|
||||
on.push(m);
|
||||
} else {
|
||||
off.push(m);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(on, off)
|
||||
}
|
||||
|
||||
// Render a channel's lock as "+on-off" for display.
|
||||
fn show_mlock(info: &ChannelView) -> String {
|
||||
let mut s = String::new();
|
||||
if !info.lock_on.is_empty() {
|
||||
s.push('+');
|
||||
s.push_str(&info.lock_on);
|
||||
}
|
||||
if !info.lock_off.is_empty() {
|
||||
s.push('-');
|
||||
s.push_str(&info.lock_off);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
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 dyn Store) {
|
||||
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), db.channel(dest)) 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."));
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// 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: &dyn NetView, db: &dyn Store) {
|
||||
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) else {
|
||||
return;
|
||||
};
|
||||
ctx.channel_mode(me, chan, &info.lock_modes());
|
||||
let members: Vec<String> = net.channel_members(chan);
|
||||
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."));
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
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 dyn Store) {
|
||||
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."),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// 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: &dyn NetView, db: &dyn Store) {
|
||||
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.")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// 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: &dyn NetView, db: &dyn Store) {
|
||||
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."));
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// KICK <#channel> <nick> [reason]: kick a user.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <nick> [reason]");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let Some(target) = net.uid_by_nick(nick) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
|
||||
return;
|
||||
};
|
||||
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() };
|
||||
ctx.kick(me, chan, target, &reason);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// LIST: show all registered channels.
|
||||
pub fn handle(me: &str, from: &Sender, _args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let mut names: Vec<String> = db.channels().into_iter().map(|c| c.name).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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// MODE <#channel> <modes>: the founder sets channel modes via ChanServ.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: MODE <#channel> <modes>, e.g. MODE #chan +nt");
|
||||
return;
|
||||
};
|
||||
if args.len() <= 2 {
|
||||
ctx.notice(me, from.uid, "Syntax: MODE <#channel> <modes>, e.g. MODE #chan +nt");
|
||||
return;
|
||||
}
|
||||
let founder = match db.channel(chan) {
|
||||
Some(info) => info.founder.clone(),
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if from.account != Some(founder.as_str()) {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change its modes."));
|
||||
return;
|
||||
}
|
||||
let modes = args[2..].join(" ");
|
||||
ctx.channel_mode(me, chan, &modes);
|
||||
ctx.notice(me, from.uid, format!("Set \x02{modes}\x02 on \x02{chan}\x02."));
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// OP/DEOP/VOICE/DEVOICE <#channel> [nick]: set a status mode on a user (self if
|
||||
// no nick given). `mode` is the mode to apply, e.g. "+o".
|
||||
pub fn handle(me: &str, from: &Sender, mode: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: OP/DEOP/VOICE/DEVOICE <#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.channel_mode(me, chan, &format!("{mode} {target}"));
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// SEEN <nick>: when a nick was last seen, and doing what.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
|
||||
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, crate::engine::db::human_time(s.ts), s.what)),
|
||||
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// SET <#channel> FOUNDER <account> | DESC <text>: founder-only channel settings.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text>");
|
||||
return;
|
||||
};
|
||||
let founder = match db.channel(chan) {
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
return;
|
||||
}
|
||||
Some(info) => info.founder.clone(),
|
||||
};
|
||||
if from.account != Some(founder.as_str()) {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change its settings."));
|
||||
return;
|
||||
}
|
||||
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("FOUNDER") => {
|
||||
let Some(&account) = args.get(3) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account>");
|
||||
return;
|
||||
};
|
||||
if !db.exists(account) {
|
||||
ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't a registered account."));
|
||||
return;
|
||||
}
|
||||
match db.set_founder(chan, account) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Founder of \x02{chan}\x02 transferred to \x02{account}\x02.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("DESC") => {
|
||||
let desc = if args.len() > 3 { args[3..].join(" ") } else { String::new() };
|
||||
match db.set_desc(chan, &desc) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Description for \x02{chan}\x02 updated.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text>"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// 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: &dyn NetView, db: &dyn Store) {
|
||||
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"));
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// TOPIC <#channel> <text>: set the channel topic (empty clears it).
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: TOPIC <#channel> <text>");
|
||||
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."));
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// UNBAN <#channel> [nick]: remove the *!*@host ban of a user (self if no nick).
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: UNBAN <#channel> [nick]");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let target = args
|
||||
.get(2)
|
||||
.and_then(|&n| net.uid_by_nick(n))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| from.uid.to_string());
|
||||
let host = net.host_of(&target).unwrap_or("*");
|
||||
ctx.channel_mode(me, chan, &format!("-b *!*@{host}"));
|
||||
ctx.notice(me, from.uid, format!("Cleared the *!*@{host} ban on \x02{chan}\x02."));
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
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 dyn Store) {
|
||||
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")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// ALIST: list the channels the sender's account founds or has access on.
|
||||
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
let mut rows: Vec<(String, &str)> = Vec::new();
|
||||
for c in db.channels() {
|
||||
if c.founder.eq_ignore_ascii_case(account) {
|
||||
rows.push((c.name.clone(), "founder"));
|
||||
} else if let Some(a) = c.access.iter().find(|a| a.account.eq_ignore_ascii_case(account)) {
|
||||
rows.push((c.name.clone(), if a.level == "voice" { "voice" } else { "op" }));
|
||||
}
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ctx.notice(me, from.uid, "You have access on no channels.");
|
||||
return;
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
ctx.notice(me, from.uid, format!("Channels you have access on ({}):", rows.len()));
|
||||
for (chan, role) in rows {
|
||||
ctx.notice(me, from.uid, format!(" \x02{chan}\x02 ({role})"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
use crate::engine::db::{CertError, Store};
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
|
||||
// fingerprints that may log in to your account via SASL EXTERNAL. Each
|
||||
// subcommand is password-gated, so it needs no identified-session state.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string);
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("LIST") => {
|
||||
let Some(password) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
let fps = db.certfps(&account);
|
||||
if fps.is_empty() {
|
||||
ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account.");
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", ")));
|
||||
}
|
||||
}
|
||||
Some("ADD") => {
|
||||
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
match db.certfp_add(&account, fp) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")),
|
||||
Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."),
|
||||
Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."),
|
||||
Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
|
||||
}
|
||||
}
|
||||
Some("DEL") | Some("REMOVE") => {
|
||||
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
match db.certfp_del(&account, fp) {
|
||||
Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")),
|
||||
Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."),
|
||||
Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."),
|
||||
}
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
use crate::engine::db::{CodeKind, Store};
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// CONFIRM <code>: confirm your account's email with the code you were emailed.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(&code) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CONFIRM <code>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else {
|
||||
ctx.notice(me, from.uid, "You don't have an account to confirm.");
|
||||
return;
|
||||
};
|
||||
if db.is_verified(&account) {
|
||||
ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed."));
|
||||
return;
|
||||
}
|
||||
if !db.take_code(&account, CodeKind::Confirm, code) {
|
||||
ctx.notice(me, from.uid, "Invalid or expired confirmation code.");
|
||||
return;
|
||||
}
|
||||
match db.verify_account(&account) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// DROP <password>: delete your account. Re-authenticates as confirmation, releases
|
||||
// and drops the channels you found, and logs you out.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
let Some(&password) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: DROP <password>");
|
||||
return;
|
||||
};
|
||||
if db.authenticate(account, password).is_none() {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
}
|
||||
let channels = db.channels_owned_by(account);
|
||||
for chan in &channels {
|
||||
let _ = db.drop_channel(chan);
|
||||
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
|
||||
}
|
||||
let _ = db.drop_account(account);
|
||||
for uid in net.uids_logged_into(account) {
|
||||
ctx.logout(&uid);
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
|
||||
if !channels.is_empty() {
|
||||
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
// GHOST/RECOVER <nick> [password]: rename off a session using a nick you own,
|
||||
// either by being identified to its account or giving that account's password.
|
||||
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&target) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: GHOST <nick> [password]");
|
||||
return;
|
||||
};
|
||||
let Some(account) = db.resolve_account(target).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
|
||||
return;
|
||||
};
|
||||
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
|
||||
if !owns {
|
||||
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
|
||||
return;
|
||||
}
|
||||
let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02."));
|
||||
return;
|
||||
};
|
||||
if ghost == from.uid {
|
||||
ctx.notice(me, from.uid, "That's you.");
|
||||
return;
|
||||
}
|
||||
let guest = format!("{guest_nick}{guest_seq}");
|
||||
*guest_seq = guest_seq.wrapping_add(1);
|
||||
ctx.force_nick(&ghost, &guest);
|
||||
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed."));
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// GLIST: list the nicks grouped to your account.
|
||||
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
let mut nicks = db.grouped_nicks(account);
|
||||
nicks.sort();
|
||||
ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:"));
|
||||
ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)"));
|
||||
for n in nicks {
|
||||
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// GROUP <account> <password>: link your current nick to an existing account, so
|
||||
// you can identify to it under this nick too.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
|
||||
return;
|
||||
};
|
||||
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, "Invalid account or password.");
|
||||
return;
|
||||
};
|
||||
if db.account(from.nick).is_some() {
|
||||
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
|
||||
return;
|
||||
}
|
||||
match db.group_nick(from.nick, &canonical) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// IDENTIFY [account] <password>: log in. The account defaults to the current
|
||||
// nick, so both the bare-password and account+password forms work.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let (account_name, password) = match (args.get(1), args.get(2)) {
|
||||
(Some(account), Some(password)) => (*account, *password),
|
||||
(Some(password), None) => (from.nick, *password),
|
||||
_ => {
|
||||
ctx.notice(me, from.uid, "Syntax: IDENTIFY [account] <password>");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Distinguish an unregistered account from a wrong password.
|
||||
if !db.exists(account_name) {
|
||||
ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered."));
|
||||
return;
|
||||
}
|
||||
match db.authenticate(account_name, password) {
|
||||
Some(account) => {
|
||||
// Already identified to this account: don't re-fire the login.
|
||||
if from.account == Some(account) {
|
||||
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
|
||||
return;
|
||||
}
|
||||
let account = account.to_string();
|
||||
ctx.login(from.uid, &account);
|
||||
ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account));
|
||||
}
|
||||
None => ctx.notice(me, from.uid, "Invalid password. Please try again."),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
use crate::engine::db::{human_time, Store};
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// INFO [account]: show an account's registration details. The email is shown
|
||||
// only to the account's own owner.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let name = args.get(1).copied().unwrap_or(from.nick);
|
||||
let Some(acct) = db.account(name) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
|
||||
return;
|
||||
};
|
||||
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name));
|
||||
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
|
||||
if from.account == Some(acct.name.as_str()) {
|
||||
match &acct.email {
|
||||
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")),
|
||||
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),
|
||||
None => ctx.notice(me, from.uid, " Email : (none set)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// LOGOUT: log out and rename to a guest nick (prefix + a per-logout sequence).
|
||||
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ctx: &mut ServiceCtx) {
|
||||
if from.account.is_none() {
|
||||
ctx.notice(me, from.uid, "You're not logged in.");
|
||||
return;
|
||||
}
|
||||
let guest = format!("{guest_nick}{guest_seq}");
|
||||
*guest_seq = guest_seq.wrapping_add(1);
|
||||
ctx.logout(from.uid);
|
||||
ctx.force_nick(from.uid, &guest);
|
||||
ctx.notice(me, from.uid, format!("You're now logged out. Your nick is now \x02{}\x02.", guest));
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, Service, ServiceCtx};
|
||||
use crate::engine::state::NetView;
|
||||
|
||||
#[path = "register.rs"]
|
||||
mod register;
|
||||
#[path = "identify.rs"]
|
||||
mod identify;
|
||||
#[path = "logout.rs"]
|
||||
mod logout;
|
||||
#[path = "cert.rs"]
|
||||
mod cert;
|
||||
#[path = "info.rs"]
|
||||
mod info;
|
||||
#[path = "alist.rs"]
|
||||
mod alist;
|
||||
#[path = "set.rs"]
|
||||
mod set;
|
||||
#[path = "drop.rs"]
|
||||
mod drop;
|
||||
#[path = "group.rs"]
|
||||
mod group;
|
||||
#[path = "glist.rs"]
|
||||
mod glist;
|
||||
#[path = "ungroup.rs"]
|
||||
mod ungroup;
|
||||
#[path = "ghost.rs"]
|
||||
mod ghost;
|
||||
#[path = "resetpass.rs"]
|
||||
mod resetpass;
|
||||
#[path = "confirm.rs"]
|
||||
mod confirm;
|
||||
|
||||
pub struct NickServ {
|
||||
pub uid: String,
|
||||
// Nick prefix assigned on LOGOUT (default "Guest"); a per-session sequence is
|
||||
// appended so successive guests don't collide within a run.
|
||||
pub guest_nick: String,
|
||||
pub guest_seq: u32,
|
||||
}
|
||||
|
||||
impl Service for NickServ {
|
||||
fn nick(&self) -> &str {
|
||||
"NickServ"
|
||||
}
|
||||
fn uid(&self) -> &str {
|
||||
&self.uid
|
||||
}
|
||||
fn gecos(&self) -> &str {
|
||||
"Nickname Services"
|
||||
}
|
||||
fn manages_accounts(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
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();
|
||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("REGISTER") => register::handle(me, from, args, ctx),
|
||||
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
|
||||
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
|
||||
Some("CERT") => cert::handle(me, from, args, ctx, db),
|
||||
Some("INFO") => info::handle(me, from, args, ctx, db),
|
||||
Some("ALIST") => alist::handle(me, from, ctx, db),
|
||||
Some("SET") => set::handle(me, from, args, ctx, db),
|
||||
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
|
||||
Some("GROUP") => group::handle(me, from, args, ctx, db),
|
||||
Some("GLIST") => glist::handle(me, from, ctx, db),
|
||||
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
|
||||
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
|
||||
Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
|
||||
Some("CONFIRM") => confirm::handle(me, from, args, ctx, db),
|
||||
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 <account>, \x02CONFIRM\x02 <code>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::proto::RegReply;
|
||||
|
||||
// REGISTER <password> [email]: register the sender's current nick. The engine
|
||||
// derives the password off-thread, commits, and answers.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
|
||||
let Some(password) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
|
||||
return;
|
||||
};
|
||||
let email = args.get(2).map(|s| s.to_string());
|
||||
ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
|
||||
agent: me.to_string(),
|
||||
uid: from.uid.to_string(),
|
||||
nick: from.nick.to_string(),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
use crate::engine::db::{CodeKind, Store};
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// RESETPASS <account>: email a reset code to the address on file.
|
||||
// RESETPASS <account> <code> <newpassword>: complete the reset with that code.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
if !db.email_enabled() {
|
||||
ctx.notice(me, from.uid, "Password reset by email isn't available here.");
|
||||
return;
|
||||
}
|
||||
match (args.get(1), args.get(2), args.get(3)) {
|
||||
(Some(&name), None, None) => {
|
||||
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
|
||||
return;
|
||||
};
|
||||
let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else {
|
||||
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
|
||||
return;
|
||||
};
|
||||
let code = db.issue_code(&canonical, CodeKind::Reset);
|
||||
let mail = crate::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code);
|
||||
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
|
||||
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
|
||||
}
|
||||
(Some(&name), Some(&code), Some(&newpass)) => {
|
||||
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
|
||||
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
|
||||
return;
|
||||
};
|
||||
if !db.take_code(&canonical, CodeKind::Reset, code) {
|
||||
ctx.notice(me, from.uid, "Invalid or expired reset code.");
|
||||
return;
|
||||
}
|
||||
ctx.defer_password(&canonical, newpass, me, from.uid);
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: RESETPASS <account> [<code> <newpassword>]"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("PASSWORD") | Some("PASS") => {
|
||||
let Some(&password) = args.get(2) else {
|
||||
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
|
||||
return;
|
||||
};
|
||||
// The link layer derives the new password off-thread, then commits it.
|
||||
ctx.defer_password(account, password, me, from.uid);
|
||||
}
|
||||
Some("EMAIL") => {
|
||||
let email = args.get(2).map(|s| s.to_string());
|
||||
let cleared = email.is_none();
|
||||
match db.set_email(account, email) {
|
||||
Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")),
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword> | SET EMAIL [address]"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
use crate::engine::db::Store;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick).
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(account) = from.account else {
|
||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||
return;
|
||||
};
|
||||
let nick = args.get(1).copied().unwrap_or(from.nick);
|
||||
if nick.eq_ignore_ascii_case(account) {
|
||||
ctx.notice(me, from.uid, "You can't ungroup your main account name.");
|
||||
return;
|
||||
}
|
||||
if db.resolve_account(nick).map_or(true, |a| !a.eq_ignore_ascii_case(account)) {
|
||||
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account."));
|
||||
return;
|
||||
}
|
||||
match db.ungroup_nick(nick) {
|
||||
Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")),
|
||||
Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
// InspIRCd spanning-tree link protocol. Handshake + UID + PING mirror the
|
||||
// sequence in Network-Links (protocols/inspircd.py); mode/burst details get
|
||||
// firmed up against a live insp4 uplink.
|
||||
use super::{NetAction, NetEvent, Protocol};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub struct InspIrcd {
|
||||
sid: String,
|
||||
name: String,
|
||||
password: String,
|
||||
protocol: u32,
|
||||
ts: u64,
|
||||
}
|
||||
|
||||
impl InspIrcd {
|
||||
pub fn new(name: String, sid: String, password: String, protocol: u32, ts: u64) -> Self {
|
||||
Self { sid, name, password, protocol, ts }
|
||||
}
|
||||
|
||||
fn from_us(&self, cmd: String) -> String {
|
||||
format!(":{} {}", self.sid, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
impl Protocol for InspIrcd {
|
||||
fn handshake(&mut self) -> Vec<String> {
|
||||
vec![
|
||||
format!("CAPAB START {}", self.protocol),
|
||||
format!("CAPAB CAPABILITIES :PROTOCOL={}", self.protocol),
|
||||
"CAPAB END".to_string(),
|
||||
format!("SERVER {} {} {} :{}", self.name, self.password, self.sid, "Federated Services"),
|
||||
]
|
||||
}
|
||||
|
||||
fn parse(&mut self, line: &str) -> Vec<NetEvent> {
|
||||
// Strip an optional IRCv3 message-tag prefix (@k=v;… ) — insp tags PRIVMSGs
|
||||
// with time/msgid, and it sits before the :source.
|
||||
let line = match line.strip_prefix('@') {
|
||||
Some(rest) => rest.splitn(2, ' ').nth(1).unwrap_or(""),
|
||||
None => line,
|
||||
};
|
||||
let (source, rest) = match line.strip_prefix(':') {
|
||||
Some(s) => {
|
||||
let mut it = s.splitn(2, ' ');
|
||||
(Some(it.next().unwrap_or("").to_string()), it.next().unwrap_or(""))
|
||||
}
|
||||
None => (None, line),
|
||||
};
|
||||
let mut tokens = rest.split(' ');
|
||||
let cmd = match tokens.next() {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
match cmd.to_ascii_uppercase().as_str() {
|
||||
"SERVER" => vec![NetEvent::Registered],
|
||||
"ENDBURST" => vec![NetEvent::EndBurst],
|
||||
"PING" => {
|
||||
let token = tokens
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches(':')
|
||||
.to_string();
|
||||
vec![NetEvent::Ping { token, from: source }]
|
||||
}
|
||||
"PRIVMSG" => {
|
||||
let to = tokens.next().unwrap_or("").to_string();
|
||||
vec![NetEvent::Privmsg {
|
||||
from: source.unwrap_or_default(),
|
||||
to,
|
||||
text: trailing(rest),
|
||||
}]
|
||||
}
|
||||
// UID <uuid> <nickts> <nick> … — track who's online so services can
|
||||
// resolve a sender's current nick.
|
||||
// UID <uuid> <nickts> <nick> <realhost> <disphost> … — take the
|
||||
// displayed host for ban masks.
|
||||
"UID" => {
|
||||
let a: Vec<&str> = tokens.collect();
|
||||
match (a.first(), a.get(2)) {
|
||||
(Some(uid), Some(nick)) => {
|
||||
let host = a.get(4).unwrap_or(&"").to_string();
|
||||
vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host }]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
// :<uid> NICK <newnick> <newts> — keep the sender's current nick
|
||||
// fresh, so nick-based commands (IDENTIFY/REGISTER) act on who they
|
||||
// are now, not their nick at burst time (e.g. after a guest rename).
|
||||
"NICK" => match (source, tokens.next()) {
|
||||
(Some(uid), Some(nick)) if !nick.is_empty() => {
|
||||
vec![NetEvent::NickChange { uid, nick: nick.to_string() }]
|
||||
}
|
||||
_ => vec![],
|
||||
},
|
||||
// FJOIN <chan> <ts> <modes> [params] :<members> — channel create/burst.
|
||||
// Each member is "<prefixes>,<uid>"; surface a Join for each.
|
||||
"FJOIN" => {
|
||||
// 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.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>]"; the prefixes are the
|
||||
// status mode letters, so 'o' means they join opped.
|
||||
for m in trailing(rest).split_whitespace() {
|
||||
if let Some((prefix, 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.to_string(), op: prefix.contains('o') });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
// :<uid> IJOIN <chan> — a single user joining an existing channel (not opped).
|
||||
"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(), op: false }]
|
||||
}
|
||||
_ => 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" => {
|
||||
let a: Vec<&str> = tokens.collect();
|
||||
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('#') => {
|
||||
let params = a.get(3..).unwrap_or(&[]);
|
||||
let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }];
|
||||
if let Some(key) = scan_key(modes, params) {
|
||||
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
|
||||
}
|
||||
for (op, uid) in scan_ops(modes, params) {
|
||||
out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op });
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
||||
// account-registration relay from an ircd:
|
||||
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
|
||||
"ACCTREGISTER" => {
|
||||
let a: Vec<&str> = tokens.by_ref().take(5).collect();
|
||||
if a.len() == 5 {
|
||||
vec![NetEvent::AccountRequest {
|
||||
reqid: a[0].to_string(),
|
||||
origin: a[1].to_string(),
|
||||
kind: a[2].to_string(),
|
||||
account: a[3].to_string(),
|
||||
p2: a[4].to_string(),
|
||||
p3: trailing(rest),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
// ENCAP <target> <subcmd> … — we only care about relayed SASL:
|
||||
// ENCAP <target> SASL <client> <agent> <mode> [data…]
|
||||
"ENCAP" => {
|
||||
let _target = tokens.next();
|
||||
match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("SASL") => {
|
||||
let p: Vec<&str> = tokens.collect();
|
||||
if p.len() >= 3 {
|
||||
vec![NetEvent::Sasl {
|
||||
client: p[0].to_string(),
|
||||
agent: p[1].to_string(),
|
||||
mode: p[2].to_string(),
|
||||
data: p[3..].iter().map(|s| s.to_string()).collect(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
||||
}
|
||||
}
|
||||
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(&mut self, action: &NetAction) -> Vec<String> {
|
||||
match action {
|
||||
NetAction::Burst => vec![self.from_us(format!("BURST {}", self.ts))],
|
||||
NetAction::EndBurst => vec![self.from_us("ENDBURST".to_string())],
|
||||
NetAction::Pong { token, from } => {
|
||||
let dest = from.clone().unwrap_or_else(|| self.sid.clone());
|
||||
vec![self.from_us(format!("PONG {} {}", dest, token))]
|
||||
}
|
||||
// insp4 UID: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes :gecos
|
||||
// (both a real AND a displayed user field — see m_spanningtree/uid.cpp Builder).
|
||||
NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.from_us(format!(
|
||||
"UID {uid} {ts} {nick} {host} {host} {ident} {ident} 0.0.0.0 {ts} +i :{gecos}",
|
||||
uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, gecos = gecos
|
||||
))],
|
||||
NetAction::Privmsg { from, to, text } => {
|
||||
vec![format!(":{} PRIVMSG {} :{}", from, to, text)]
|
||||
}
|
||||
NetAction::Notice { from, to, text } => {
|
||||
vec![format!(":{} NOTICE {} :{}", from, to, text)]
|
||||
}
|
||||
// ACCTREGRESULT <reqid> <kind> <account> <status> <code> :<message>
|
||||
NetAction::AccountResponse { reqid, kind, account, status, code, message } => {
|
||||
vec![self.from_us(format!(
|
||||
"ACCTREGRESULT {} {} {} {} {} :{}",
|
||||
reqid, kind, account, status, code, message
|
||||
))]
|
||||
}
|
||||
// ENCAP * SASL <agent> <client> <mode> [data…]
|
||||
NetAction::Sasl { agent, client, mode, data } => {
|
||||
let mut line = format!("ENCAP * SASL {} {} {}", agent, client, mode);
|
||||
for d in data {
|
||||
line.push(' ');
|
||||
line.push_str(d);
|
||||
}
|
||||
vec![self.from_us(line)]
|
||||
}
|
||||
// METADATA <target> <key> :<value> — "*" is server-global metadata.
|
||||
NetAction::Metadata { target, key, value } => {
|
||||
vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))]
|
||||
}
|
||||
// SVSNICK <uid> <newnick> <nickts> — the new nick takes the current
|
||||
// time as its TS so it wins any collision resolution.
|
||||
NetAction::ForceNick { uid, nick } => {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
|
||||
vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))]
|
||||
}
|
||||
// FMODE <chan> <ts> <modes>. The ircd drops an FMODE whose TS is newer
|
||||
// than the channel's, so we send TS 1 to guarantee it applies. Sourced
|
||||
// from the given pseudoclient (e.g. ChanServ) so users see who set it,
|
||||
// or from the services server when `from` is empty.
|
||||
NetAction::ChannelMode { from, channel, modes } => {
|
||||
let cmd = format!("FMODE {} 1 {}", channel, modes);
|
||||
if from.is_empty() {
|
||||
vec![self.from_us(cmd)]
|
||||
} else {
|
||||
vec![format!(":{} {}", from, cmd)]
|
||||
}
|
||||
}
|
||||
NetAction::Kick { from, channel, uid, reason } => {
|
||||
vec![format!(":{} KICK {} {} :{}", from, channel, uid, reason)]
|
||||
}
|
||||
// FTOPIC <chan> <chanTS> <topicTS> <setter> :<topic>. 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 <uid> <chan> <chanTS> <expiry>. 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 these before serialization.
|
||||
NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn sid(&self) -> &str {
|
||||
&self.sid
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Walk a mode change and its params for op grants/revokes (+o/-o). Returns
|
||||
// (is_op, uid) per `o` in the change, using the same param cursor as scan_key.
|
||||
fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> {
|
||||
let mut adding = true;
|
||||
let mut pi = 0;
|
||||
let mut out = Vec::new();
|
||||
for m in modes.chars() {
|
||||
match m {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
'o' => {
|
||||
if let Some(p) = params.get(pi) {
|
||||
out.push((adding, p.to_string()));
|
||||
}
|
||||
pi += 1;
|
||||
}
|
||||
c if takes_param(c, adding) => pi += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// The IRC "trailing" argument: everything after the first " :", else the last word.
|
||||
fn trailing(rest: &str) -> String {
|
||||
match rest.find(" :") {
|
||||
Some(i) => rest[i + 2..].to_string(),
|
||||
None => rest.rsplit(' ').next().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn proto() -> InspIrcd {
|
||||
InspIrcd::new("services.test".into(), "42S".into(), "pw".into(), 1206, 1)
|
||||
}
|
||||
|
||||
// A remote nick change must surface as a NickChange for the source uid, or
|
||||
// nick-based commands act on a stale nick.
|
||||
#[test]
|
||||
fn parses_nick_change() {
|
||||
let ev = proto().parse(":0IRAAAAAB NICK newnick 1783833000");
|
||||
assert!(
|
||||
matches!(ev.as_slice(), [NetEvent::NickChange { uid, nick }] if uid == "0IRAAAAAB" && nick == "newnick"),
|
||||
"{ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A UID burst introduces the user under their current nick.
|
||||
#[test]
|
||||
fn parses_uid_burst() {
|
||||
let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice host host user user 0.0.0.0 1783833000 +i :real");
|
||||
assert!(
|
||||
matches!(ev.as_slice(), [NetEvent::UserConnect { uid, nick, .. }] if uid == "0IRAAAAAB" && nick == "alice"),
|
||||
"{ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// 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.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.
|
||||
#[test]
|
||||
fn parses_fmode_and_filters_own() {
|
||||
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m");
|
||||
assert!(
|
||||
matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"),
|
||||
"{ev:?}"
|
||||
);
|
||||
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, op: false }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// Protocol layer. The normalized vocabulary (NetEvent / NetAction / RegReply)
|
||||
// and the Protocol trait live in the fedserv-api SDK crate; re-exported here so
|
||||
// the engine and the ircd modules keep referring to them as `crate::proto::*`.
|
||||
pub mod inspircd;
|
||||
|
||||
pub use fedserv_api::{NetAction, NetEvent, Protocol, RegReply};
|
||||
Loading…
Add table
Add a link
Reference in a new issue