channel-rename: implement IRCv3 draft/channel-rename — RENAME command + cap, in-place rename (RENAME for cap clients, PART/JOIN fallback for the rest), and S2S propagation
This commit is contained in:
parent
755e835baf
commit
c9bd8e7492
6 changed files with 400 additions and 1 deletions
104
src/channels.rs
104
src/channels.rs
|
|
@ -970,6 +970,110 @@ impl Server {
|
|||
self.events.push_back(Hook::Join(uid, key));
|
||||
}
|
||||
|
||||
/// Rename channel `oldkey` (an existing lowercase key) to display name
|
||||
/// `newname`, preserving all state — membership, modes, topic, bans, TS. The
|
||||
/// channel object is rekeyed in the table and every local member's channel set
|
||||
/// is moved with it. Local members are then notified: a `RENAME` line for
|
||||
/// draft/channel-rename clients, a PART+JOIN(+topic+names) emulation for the
|
||||
/// rest (skipped when only the casing changed, per the spec). `src_prefix` is
|
||||
/// the nick!user@host — or server name — shown as the RENAME source. Returns
|
||||
/// the new lowercase key, or `None` if the move couldn't be made. Callers
|
||||
/// validate policy first (op rank, name validity, collision).
|
||||
pub fn rename_channel(
|
||||
&mut self,
|
||||
oldkey: &str,
|
||||
newname: &str,
|
||||
src_prefix: &str,
|
||||
reason: &str,
|
||||
) -> Option<String> {
|
||||
let newkey = newname.to_ascii_lowercase();
|
||||
let case_only = newkey.as_str() == oldkey;
|
||||
if !self.channels.contains_key(oldkey) {
|
||||
return None;
|
||||
}
|
||||
if !case_only && self.channels.contains_key(&newkey) {
|
||||
return None;
|
||||
}
|
||||
let oldname = self.channels[oldkey].name.clone();
|
||||
let members: Vec<Uid> = self.channels[oldkey].members.keys().copied().collect();
|
||||
if case_only {
|
||||
if let Some(ch) = self.channels.get_mut(oldkey) {
|
||||
ch.name = newname.to_string();
|
||||
}
|
||||
} else {
|
||||
let mut ch = self.channels.remove(oldkey).unwrap();
|
||||
ch.name = newname.to_string();
|
||||
self.channels.insert(newkey.clone(), ch);
|
||||
for &m in &members {
|
||||
if let Some(u) = self.users.get_mut(&m) {
|
||||
u.channels.remove(oldkey);
|
||||
u.channels.insert(newkey.clone());
|
||||
}
|
||||
}
|
||||
self.rekey_channel_state(oldkey, &newkey);
|
||||
}
|
||||
let renline = format!(":{src_prefix} RENAME {oldname} {newname} :{reason}");
|
||||
for &m in &members {
|
||||
let has_cap = self
|
||||
.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.channel_rename)
|
||||
.unwrap_or(false);
|
||||
if has_cap {
|
||||
self.send(m, renline.clone());
|
||||
} else if !case_only {
|
||||
self.emulate_rename_join(m, &oldname, newname, &newkey, reason);
|
||||
}
|
||||
// case-only + no cap: the spec says the PART/JOIN fallback SHOULD NOT
|
||||
// be used, so those clients simply keep the channel under its old case.
|
||||
}
|
||||
Some(newkey)
|
||||
}
|
||||
|
||||
/// The PART-old + JOIN-new(+topic+names) fallback shown to one member that
|
||||
/// lacks draft/channel-rename, so their client follows the channel across a
|
||||
/// rename. Mirrors the JOIN broadcast (extended-join aware).
|
||||
fn emulate_rename_join(&self, m: Uid, oldname: &str, newname: &str, newkey: &str, reason: &str) {
|
||||
let Some(u) = self.users.get(&m) else {
|
||||
return;
|
||||
};
|
||||
let prefix = u.prefix();
|
||||
let joinline = if u.caps.extended_join {
|
||||
let acct = u.account.clone().unwrap_or_else(|| "*".to_string());
|
||||
format!(":{prefix} JOIN {newname} {acct} :{}", u.realname)
|
||||
} else {
|
||||
format!(":{prefix} JOIN {newname}")
|
||||
};
|
||||
let partline = if reason.is_empty() {
|
||||
format!(":{prefix} PART {oldname}")
|
||||
} else {
|
||||
format!(":{prefix} PART {oldname} :{reason}")
|
||||
};
|
||||
self.send(m, partline);
|
||||
self.send(m, joinline);
|
||||
if let Some(t) = self.channels.get(newkey).and_then(|c| c.topic.as_ref()) {
|
||||
let text = t.text.clone();
|
||||
self.numeric(m, RPL_TOPIC, &format!("{newname} :{text}"));
|
||||
}
|
||||
self.send_names(m, newkey);
|
||||
}
|
||||
|
||||
/// Move a channel's auxiliary, name-keyed module state (recorded history,
|
||||
/// channel metadata) from `oldkey` to `newkey` on a rename, so a CHATHISTORY
|
||||
/// replay or a metadata read still finds it under the new name.
|
||||
fn rekey_channel_state(&mut self, oldkey: &str, newkey: &str) {
|
||||
if let Some(h) = self.ext.get_mut::<crate::modules::chathistory::History>() {
|
||||
if let Some(v) = h.0.remove(oldkey) {
|
||||
h.0.insert(newkey.to_string(), v);
|
||||
}
|
||||
}
|
||||
if let Some(store) = self.ext.get_mut::<crate::modules::metadata::MetaStore>() {
|
||||
if let Some(v) = store.0.remove(oldkey) {
|
||||
store.0.insert(newkey.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// +H chanhistory: replay a channel's recent messages to a user who just
|
||||
/// joined — the last `<lines>` (within `<secs>`, 0 = no limit) from the store,
|
||||
/// wrapped in a `chathistory` batch for batch-capable clients.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
||||
|
||||
use crate::channels::{normalize_ban_mask, Ban, Topic, RANK_HALFOP};
|
||||
use crate::channels::{normalize_ban_mask, valid_chan, Ban, Topic, RANK_HALFOP, RANK_OP};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
|
|
@ -12,6 +12,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
|||
vec![
|
||||
Box::new(Join),
|
||||
Box::new(Part),
|
||||
Box::new(Rename),
|
||||
Box::new(Kick),
|
||||
Box::new(TopicCmd),
|
||||
Box::new(Names),
|
||||
|
|
@ -479,6 +480,112 @@ impl Command for Part {
|
|||
}
|
||||
}
|
||||
|
||||
/// RENAME `<old-channel> <new-channel> [<reason>]` — IRCv3 `draft/channel-rename`.
|
||||
/// A channel operator renames a channel in place, keeping its membership, modes
|
||||
/// and topic. Members that negotiated the cap see a `RENAME`; the rest are moved
|
||||
/// with a PART/JOIN. Registered (+r) channels are managed by services, so a
|
||||
/// client can't rename them — ChanServ does that over S2S.
|
||||
struct Rename;
|
||||
impl Command for Rename {
|
||||
fn name(&self) -> &'static str {
|
||||
"RENAME"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (old, new) = (¶ms[0], ¶ms[1]);
|
||||
let reason = params.get(2).cloned().unwrap_or_default();
|
||||
let oldkey = old.to_ascii_lowercase();
|
||||
let newkey = new.to_ascii_lowercase();
|
||||
let case_only = oldkey == newkey;
|
||||
if !s.channels.contains_key(&oldkey) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{old} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !s.is_member(uid, &oldkey) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{old} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let is_oper = s.is_oper(uid);
|
||||
if s.rank(uid, &oldkey) < RANK_OP && !is_oper {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{old} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// A registered channel's name is owned by services; renaming it moves the
|
||||
// registration, which only ChanServ (founder-authorised) may do.
|
||||
if s.channels[&oldkey].modes.registered && !is_oper {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
"This channel is registered — ask ChanServ to rename it.",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_chan(new, s.conf_num("maxchannel", 50usize)) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
&format!("{new} is not a valid channel name."),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// A pure prefix-type change (e.g. # -> &) isn't a rename we support.
|
||||
if new.chars().next() != old.chars().next() {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
"The channel prefix can't be changed.",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !case_only && s.channels.contains_key(&newkey) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CHANNEL_NAME_IN_USE",
|
||||
&format!("{new} already exists."),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !is_oper {
|
||||
if let Some(reason) = s.matched_cban(&newkey) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
&format!("{new} is CBAN'd: {reason}"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
// Capture the identity/TS before the move, then rename + propagate.
|
||||
let (prefix, uuid) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.uuid.clone())
|
||||
};
|
||||
let oldname = s.channels[&oldkey].name.clone();
|
||||
if s.rename_channel(&oldkey, new, &prefix, &reason).is_none() {
|
||||
s.fail(uid, "RENAME", "CANNOT_RENAME", "The channel cannot be renamed.");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.snotice_c('a', &format!("{oldname} renamed to {new} by {prefix}"));
|
||||
s.propagate_rename(&uuid, &oldname, new, &reason, None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Kick;
|
||||
impl Command for Kick {
|
||||
fn name(&self) -> &'static str {
|
||||
|
|
|
|||
59
src/link.rs
59
src/link.rs
|
|
@ -178,6 +178,7 @@ impl Server {
|
|||
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
||||
"FTOPIC" if registered => self.link_ftopic_recv(uid, msg),
|
||||
"KICK" if registered => self.link_kick_recv(uid, msg),
|
||||
"RENAME" if registered => self.link_rename_recv(uid, msg),
|
||||
"MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg),
|
||||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||
"IJOIN" if registered => self.link_ijoin_recv(uid, msg),
|
||||
|
|
@ -1525,6 +1526,27 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// Tell linked servers a channel was renamed. `source` is the initiator's
|
||||
/// uuid (a client or a services pseudoclient) or a server SID; each receiver
|
||||
/// moves the channel and notifies its own members. `except` skips the link a
|
||||
/// forwarded rename arrived on.
|
||||
pub fn propagate_rename(
|
||||
&self,
|
||||
source: &str,
|
||||
oldname: &str,
|
||||
newname: &str,
|
||||
reason: &str,
|
||||
except: Option<Uid>,
|
||||
) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.propagate(
|
||||
&format!(":{source} RENAME {oldname} {newname} :{reason}"),
|
||||
except,
|
||||
);
|
||||
}
|
||||
|
||||
/// The distinct links a channel's remote members sit behind (minus `except`).
|
||||
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
||||
let mut set: HashSet<Uid> = HashSet::new();
|
||||
|
|
@ -1673,6 +1695,43 @@ impl Server {
|
|||
self.propagate(&fwd, Some(via));
|
||||
}
|
||||
|
||||
fn link_rename_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<source> RENAME <old> <new> [:reason] — a channel renamed elsewhere
|
||||
// (by a client on another server, or by ChanServ). Apply it locally,
|
||||
// notify our members, then forward to the rest of the mesh. A
|
||||
// services-sourced rename is honoured unconditionally: services owns the
|
||||
// registered name and validated the op/founder before sending this.
|
||||
let Some(source) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let (Some(old), Some(new)) = (msg.params.first().cloned(), msg.params.get(1).cloned())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let reason = msg.params.get(2).cloned().unwrap_or_default();
|
||||
let oldkey = old.to_ascii_lowercase();
|
||||
if !self.channels.contains_key(&oldkey) {
|
||||
return;
|
||||
}
|
||||
// The nick!user@host (or server name) shown to local members as the source.
|
||||
let prefix = self
|
||||
.remote_users
|
||||
.get(&source)
|
||||
.map(|r| r.prefix())
|
||||
.or_else(|| self.servers.get(&source).map(|s| s.name.clone()))
|
||||
.or_else(|| {
|
||||
self.servers
|
||||
.get(source.get(..3).unwrap_or(source.as_str()))
|
||||
.map(|s| s.name.clone())
|
||||
});
|
||||
let Some(prefix) = prefix else {
|
||||
return; // unknown source — don't act on a rename we can't attribute
|
||||
};
|
||||
if self.rename_channel(&oldkey, &new, &prefix, &reason).is_some() {
|
||||
self.propagate_rename(&source, &old, &new, &reason, Some(via));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a remote user everywhere (channels + registries) and QUIT them to
|
||||
/// any local users who shared a channel.
|
||||
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
||||
|
|
|
|||
|
|
@ -1352,6 +1352,49 @@ mod tests {
|
|||
assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_moves_channel_and_notifies_by_cap() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann"); // op, cap-aware
|
||||
let brx = add_user(&mut s, 2, "bob"); // no cap
|
||||
s.users.get_mut(&1).unwrap().caps.channel_rename = true;
|
||||
s.join(1, "#old", None); // ann creates -> op
|
||||
s.join(2, "#old", None);
|
||||
let _ = arx.try_iter().count(); // drain the join chatter
|
||||
let _ = brx.try_iter().count();
|
||||
|
||||
let key = s.rename_channel("#old", "#new", "ann!u@localhost", "moving");
|
||||
assert_eq!(key.as_deref(), Some("#new"));
|
||||
assert!(!s.channels.contains_key("#old"), "old key gone");
|
||||
assert!(s.channels.contains_key("#new"), "new key present");
|
||||
assert_eq!(s.channels["#new"].members.len(), 2, "membership preserved");
|
||||
assert!(s.users[&1].channels.contains("#new") && !s.users[&1].channels.contains("#old"));
|
||||
assert!(s.users[&2].channels.contains("#new") && !s.users[&2].channels.contains("#old"));
|
||||
|
||||
// The cap holder sees a RENAME; the plain client is walked PART -> JOIN.
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(ann.iter().any(|l| l.contains("RENAME #old #new")), "cap client got RENAME: {ann:?}");
|
||||
assert!(!ann.iter().any(|l| l.contains("PART #old")), "cap client not PARTed");
|
||||
let bob: Vec<String> = brx.try_iter().collect();
|
||||
assert!(bob.iter().any(|l| l.contains("PART #old")), "plain client PARTed: {bob:?}");
|
||||
assert!(bob.iter().any(|l| l.contains("JOIN #new")), "plain client re-JOINed");
|
||||
assert!(!bob.iter().any(|l| l.contains("RENAME")), "plain client got no RENAME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_case_only_keeps_key_and_skips_fallback() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann");
|
||||
s.join(1, "#chan", None);
|
||||
let _ = arx.try_iter().count();
|
||||
let key = s.rename_channel("#chan", "#Chan", "ann!u@localhost", "");
|
||||
assert_eq!(key.as_deref(), Some("#chan"), "key unchanged on a case-only rename");
|
||||
assert_eq!(s.channels["#chan"].name, "#Chan", "display casing updated");
|
||||
// Non-cap member: the spec says no PART/JOIN fallback for a case change.
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nick_change_reindexes_and_notifies_channel() {
|
||||
let mut s = srv();
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
|||
"draft/extended-isupport",
|
||||
"reverse.im/filehost",
|
||||
"draft/relaymsg",
|
||||
"draft/channel-rename",
|
||||
"cap-notify",
|
||||
];
|
||||
|
||||
|
|
@ -166,6 +167,7 @@ pub struct Caps {
|
|||
pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005
|
||||
pub filehost: bool, // reverse.im/filehost — knows the file-host extension
|
||||
pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying)
|
||||
pub channel_rename: bool, // draft/channel-rename — receives RENAME (else PART+JOIN)
|
||||
pub cap_notify: bool,
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +236,7 @@ impl Caps {
|
|||
"draft/extended-isupport" => self.ext_isupport,
|
||||
"reverse.im/filehost" => self.filehost,
|
||||
"draft/relaymsg" => self.relaymsg,
|
||||
"draft/channel-rename" => self.channel_rename,
|
||||
"cap-notify" => self.cap_notify,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -269,6 +272,7 @@ impl Caps {
|
|||
"draft/extended-isupport" => &mut self.ext_isupport,
|
||||
"reverse.im/filehost" => &mut self.filehost,
|
||||
"draft/relaymsg" => &mut self.relaymsg,
|
||||
"draft/channel-rename" => &mut self.channel_rename,
|
||||
"cap-notify" => &mut self.cap_notify,
|
||||
_ => return false,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue