echoIRCd/src/coremods/core_channel.rs

628 lines
20 KiB
Rust

//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
use crate::channels::{normalize_ban_mask, Ban, Topic, RANK_HALFOP};
use crate::command::{CmdResult, Command};
use crate::module::Hook;
use crate::numeric::*;
use crate::server::{now, Server};
use crate::xline::parse_duration;
use crate::Uid;
pub fn commands() -> Vec<Box<dyn Command>> {
vec![
Box::new(Join),
Box::new(Part),
Box::new(Kick),
Box::new(TopicCmd),
Box::new(Names),
Box::new(Invite),
Box::new(Uninvite),
Box::new(Knock),
Box::new(Cycle),
Box::new(Remove),
Box::new(Tban),
]
}
/// TBAN — set a +b ban that lifts itself after a duration (InspIRCd `m_timedbans`).
/// `TBAN <#chan> <duration> <mask>`; needs half-op or above. The background tick
/// removes it and announces `MODE -b` when it expires.
struct Tban;
impl Command for Tban {
fn name(&self) -> &'static str {
"TBAN"
}
fn min_params(&self) -> usize {
3
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let chan = &params[0];
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
if s.rank(uid, &key) < RANK_HALFOP {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let Some(dur) = parse_duration(&params[1]).filter(|&d| d > 0) else {
s.fail(
uid,
"TBAN",
"INVALID_DURATION",
"TBAN needs a positive duration",
);
return CmdResult::Fail;
};
let mask = normalize_ban_mask(&params[2]);
let (nick, prefix) = {
let u = &s.users[&uid];
(u.nick.clone(), u.prefix())
};
if s.channels[&key].bans.iter().any(|b| b.mask == mask) {
s.send(
uid,
format!(
":{} NOTICE {nick} :{mask} is already banned on {chan}",
s.name
),
);
return CmdResult::Fail;
}
if let Some(c) = s.channels.get_mut(&key) {
c.bans.push(Ban {
mask: mask.clone(),
setter: nick,
ts: now(),
expires: Some(now() + dur),
});
}
s.to_channel(&key, &format!(":{prefix} MODE {chan} +b {mask}"), None);
s.propagate_from_user(uid, &format!("MODE {chan} +b {mask}"));
CmdResult::Ok
}
}
/// KNOCK — ask for an invite to an invite-only channel.
struct Knock;
impl Command for Knock {
fn name(&self) -> &'static str {
"KNOCK"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let chan = &params[0];
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key);
if !can {
s.fail(
uid,
"KNOCK",
"CANNOT_KNOCK",
&format!("Can't KNOCK on {chan} (not invite-only, or you're on it)"),
);
return CmdResult::Fail;
}
let reason = params
.get(1)
.cloned()
.unwrap_or_else(|| "requesting an invite".to_string());
let who = s.users[&uid].prefix();
s.to_channel(
&key,
&format!(
":{} NOTICE {chan} :[Knock] {who} is knocking: {reason}",
s.name
),
None,
);
s.numeric(
uid,
RPL_KNOCKDLVR,
&format!("{chan} :Your KNOCK has been delivered"),
);
CmdResult::Ok
}
}
/// CYCLE — part and immediately rejoin a channel.
struct Cycle;
impl Command for Cycle {
fn name(&self) -> &'static str {
"CYCLE"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let chan = &params[0];
let key = chan.to_ascii_lowercase();
if !s.is_member(uid, &key) {
s.numeric(
uid,
ERR_NOTONCHANNEL,
&format!("{chan} :You're not on that channel"),
);
return CmdResult::Fail;
}
let prefix = s.users[&uid].prefix();
s.to_channel(&key, &format!(":{prefix} PART {chan} :cycling"), None);
s.propagate_part(uid, chan, "cycling");
if let Some(ch) = s.channels.get_mut(&key) {
ch.members.remove(&uid);
}
if let Some(u) = s.users.get_mut(&uid) {
u.channels.remove(&key);
}
s.channels.retain(|_, c| c.keep_alive());
s.join(uid, chan, None);
CmdResult::Ok
}
}
/// REMOVE — like KICK, but the target sees a PART (a softer removal).
struct Remove;
impl Command for Remove {
fn name(&self) -> &'static str {
"REMOVE"
}
fn min_params(&self) -> usize {
2
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (chan, victim) = (&params[0], &params[1]);
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
if s.rank(uid, &key) < RANK_HALFOP {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let Some(tuid) = s.find_nick(victim) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{victim} :No such nick/channel"),
);
return CmdResult::Fail;
};
if !s.channels[&key].members.contains_key(&tuid) {
s.numeric(
uid,
ERR_USERNOTINCHANNEL,
&format!("{victim} {chan} :They aren't on that channel"),
);
return CmdResult::Fail;
}
if s.rank(uid, &key) < s.rank(tuid, &key) {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You cannot remove a user of higher rank"),
);
return CmdResult::Fail;
}
let by = s.users[&uid].nick.clone();
let reason = match params.get(2) {
Some(r) => format!("Removed by {by}: {r}"),
None => format!("Removed by {by}"),
};
let prefix = s.users[&tuid].prefix();
s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None);
s.propagate_part(tuid, chan, &reason);
if let Some(ch) = s.channels.get_mut(&key) {
ch.members.remove(&tuid);
}
if let Some(u) = s.users.get_mut(&tuid) {
u.channels.remove(&key);
}
s.channels.retain(|_, c| c.keep_alive());
CmdResult::Ok
}
}
struct Invite;
impl Command for Invite {
fn name(&self) -> &'static str {
"INVITE"
}
fn min_params(&self) -> usize {
2
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (tnick, chan) = (&params[0], &params[1]);
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
if !s.is_member(uid, &key) {
s.numeric(
uid,
ERR_NOTONCHANNEL,
&format!("{chan} :You're not on that channel"),
);
return CmdResult::Fail;
}
// only ops may invite into an +i channel — unless +A (allow anyone to invite)
if s.channels[&key].modes.invite_only
&& !s.channels[&key].modes.allowinvite
&& !s.is_op(uid, &key)
{
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let Some(tuid) = s.find_nick(tnick) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{tnick} :No such nick/channel"),
);
return CmdResult::Fail;
};
if s.channels[&key].members.contains_key(&tuid) {
s.numeric(
uid,
ERR_USERONCHANNEL,
&format!("{tnick} {chan} :is already on channel"),
);
return CmdResult::Fail;
}
if let Some(ch) = s.channels.get_mut(&key) {
ch.invites.insert(tuid);
}
let who = s.users[&tuid].nick.clone();
s.numeric(uid, RPL_INVITING, &format!("{who} {chan}"));
let prefix = s.users[&uid].prefix();
s.send(tuid, format!(":{prefix} INVITE {who} :{chan}"));
// invite-notify: tell capable channel members about the invite
let notify = format!(":{prefix} INVITE {who} {chan}");
let members: Vec<Uid> = s.channels[&key].members.keys().copied().collect();
for m in members {
if m != uid
&& s.users
.get(&m)
.map(|u| u.caps.invite_notify)
.unwrap_or(false)
{
s.send(m, notify.clone());
}
}
CmdResult::Ok
}
}
/// UNINVITE — revoke a pending invite (InspIRCd `m_uninvite`). `UNINVITE <nick>
/// <#chan>`; a channel op cancels an invite they (or another op) issued.
struct Uninvite;
impl Command for Uninvite {
fn name(&self) -> &'static str {
"UNINVITE"
}
fn min_params(&self) -> usize {
2
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (tnick, chan) = (&params[0], &params[1]);
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
if !s.is_member(uid, &key) {
s.numeric(
uid,
ERR_NOTONCHANNEL,
&format!("{chan} :You're not on that channel"),
);
return CmdResult::Fail;
}
if !s.is_op(uid, &key) {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let Some(tuid) = s.find_nick(tnick) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{tnick} :No such nick/channel"),
);
return CmdResult::Fail;
};
let removed = s
.channels
.get_mut(&key)
.map(|ch| ch.invites.remove(&tuid))
.unwrap_or(false);
let who = s.users[&tuid].nick.clone();
let word = if removed {
"is no longer invited to"
} else {
"was not invited to"
};
let nick = s.users[&uid].nick.clone();
s.send(
uid,
format!(":{} NOTICE {nick} :{who} {word} {chan}", s.name),
);
if removed {
s.send(
tuid,
format!(
":{} NOTICE {who} :Your invite to {chan} was revoked",
s.name
),
);
}
CmdResult::Ok
}
}
struct Join;
impl Command for Join {
fn name(&self) -> &'static str {
"JOIN"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let keys: Vec<&str> = params
.get(1)
.map(|k| k.split(',').collect())
.unwrap_or_default();
for (i, name) in params[0].split(',').filter(|x| !x.is_empty()).enumerate() {
s.join(uid, name, keys.get(i).copied());
}
CmdResult::Ok
}
}
struct Part;
impl Command for Part {
fn name(&self) -> &'static str {
"PART"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let reason = params.get(1).cloned().unwrap_or_default();
for target in params[0].split(',').filter(|x| !x.is_empty()) {
let key = target.to_ascii_lowercase();
let on = s
.users
.get(&uid)
.map(|u| u.channels.contains(&key))
.unwrap_or(false);
if !on {
s.numeric(
uid,
ERR_NOTONCHANNEL,
&format!("{target} :You're not on that channel"),
);
continue;
}
let prefix = s.users[&uid].prefix();
let line = if reason.is_empty() {
format!(":{prefix} PART {target}")
} else {
format!(":{prefix} PART {target} :{reason}")
};
// +D delayjoin: a still-hidden member's PART is shown only to themselves
let hidden = s
.channels
.get(&key)
.and_then(|c| c.members.get(&uid))
.map(|m| m.hidden)
.unwrap_or(false);
if hidden {
s.send(uid, line.clone());
} else {
s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part
}
s.propagate_part(uid, target, &reason); // tell linked servers
if let Some(ch) = s.channels.get_mut(&key) {
ch.members.remove(&uid);
}
if let Some(u) = s.users.get_mut(&uid) {
u.channels.remove(&key);
}
s.channels.retain(|_, c| c.keep_alive());
s.events.push_back(Hook::Part(uid, key, reason.clone()));
}
CmdResult::Ok
}
}
struct Kick;
impl Command for Kick {
fn name(&self) -> &'static str {
"KICK"
}
fn min_params(&self) -> usize {
2
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (chan, victim) = (&params[0], &params[1]);
let key = chan.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
return CmdResult::Fail;
}
if s.rank(uid, &key) < RANK_HALFOP {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let Some(tuid) = s.find_nick(victim) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{victim} :No such nick/channel"),
);
return CmdResult::Fail;
};
if !s.channels[&key].members.contains_key(&tuid) {
s.numeric(
uid,
ERR_USERNOTINCHANNEL,
&format!("{victim} {chan} :They aren't on that channel"),
);
return CmdResult::Fail;
}
// can't kick someone who out-ranks you
if s.rank(uid, &key) < s.rank(tuid, &key) {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :You cannot kick a user of higher rank"),
);
return CmdResult::Fail;
}
// +Q — kicks disabled (IRC operators bypass; SAKICK is a separate path)
if s.channels[&key].modes.nokicks && !s.is_oper(uid) {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{chan} :Kicks are disabled here (+Q)"),
);
return CmdResult::Fail;
}
let kicker = s.users[&uid].nick.clone();
let reason = params.get(2).cloned().unwrap_or(kicker);
let prefix = s.users[&uid].prefix();
s.to_channel(
&key,
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
None,
);
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); // tell links
if let Some(ch) = s.channels.get_mut(&key) {
ch.members.remove(&tuid);
if ch.modes.kicknorejoin.is_some() {
ch.recent_kicks.insert(tuid, now()); // +J rejoin-delay clock
}
}
if let Some(u) = s.users.get_mut(&tuid) {
u.channels.remove(&key);
}
s.channels.retain(|_, c| c.keep_alive());
s.events
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
CmdResult::Ok
}
}
struct TopicCmd;
impl Command for TopicCmd {
fn name(&self) -> &'static str {
"TOPIC"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let target = &params[0];
let key = target.to_ascii_lowercase();
if !s.channels.contains_key(&key) {
s.numeric(
uid,
ERR_NOSUCHCHANNEL,
&format!("{target} :No such channel"),
);
return CmdResult::Fail;
}
if params.len() < 2 {
match s.channels[&key].topic.as_ref() {
Some(t) => {
let text = t.text.clone();
s.numeric(uid, RPL_TOPIC, &format!("{target} :{text}"));
}
None => s.numeric(uid, RPL_NOTOPIC, &format!("{target} :No topic is set")),
}
return CmdResult::Ok;
}
let on = s
.users
.get(&uid)
.map(|u| u.channels.contains(&key))
.unwrap_or(false);
if !on {
s.numeric(
uid,
ERR_NOTONCHANNEL,
&format!("{target} :You're not on that channel"),
);
return CmdResult::Fail;
}
// +t: only ops may set the topic
if s.channels[&key].modes.topic_ops && !s.is_op(uid, &key) {
s.numeric(
uid,
ERR_CHANOPRIVSNEEDED,
&format!("{target} :You're not a channel operator"),
);
return CmdResult::Fail;
}
let text = params[1].clone();
let (prefix, setter) = {
let u = &s.users[&uid];
(u.prefix(), u.nick.clone())
};
if let Some(ch) = s.channels.get_mut(&key) {
ch.topic = Some(Topic {
text: text.clone(),
setter,
ts: now(),
});
}
s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None);
s.propagate_from_user(uid, &format!("TOPIC {target} :{text}")); // tell links
CmdResult::Ok
}
}
struct Names;
impl Command for Names {
fn name(&self) -> &'static str {
"NAMES"
}
fn min_params(&self) -> usize {
1
}
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
for target in params[0].split(',').filter(|x| !x.is_empty()) {
s.send_names(uid, &target.to_ascii_lowercase());
}
CmdResult::Ok
}
}