IRC: the channel's own modes, its bans, and the network's list

Closes #78.

A mode with no nick attached used to be dropped on the floor - the arm
that read MODE only looked for ones that re-rank somebody, so a channel
going moderated was invisible until a message bounced off it. Those modes
belong to the channel, so the channel now carries them: tracked letter by
letter as they change, replaced wholesale when the server answers a bare
MODE, and reported on the buffer so a client can say why a message will
not send before it is written.

Modes that take an argument are deliberately not part of that string. `+k`
without the key says nothing a reader can act on, and bans have a list of
their own.

`/mode` is passed through raw. Channel modes are a small language with
per-network extensions, and a client that only forwards the ones it
recognises cannot set the one this network has - the server is the
authority and says so itself. With no argument it asks instead, which is
also how `/mode #chan b` reads the ban list; those entries arrive as
numerics and are shown in the channel they govern, which is where a ban
list is read.

`/invite` and `/unban` join them, and an INVITE arriving is announced
instead of being silently discarded.

`/list` is gathered rather than printed. A network answers with tens of
thousands of channels, which is a directory to search and not a wall of
chat, so it is collected, sorted busiest-first and handed over in one
event.

`/ban someone` still becomes `someone!*@*`, but a mask with a `!` or `@`
already in it is now left alone - wrapping `*!*@example.com` again banned
nobody while appearing to work.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 00:26:03 -04:00
co-authored by Claude Opus 5
parent 2f37c476eb
commit 043df9bc58
3 changed files with 345 additions and 5 deletions
+239 -5
View File
@@ -1035,11 +1035,32 @@ async fn handle_message(
let (mode, target, granting) = match m {
Mode::Plus(mode, Some(target)) => (mode, target, true),
Mode::Minus(mode, Some(target)) => (mode, target, false),
// Channel modes proper (+m, +t, bans) carry no nick
// to re-rank, and belong to the channel rather than
// to anybody in it.
// A mode with no nick belongs to the channel rather
// than to anybody in it - +m, +t, +i, +k, +l. Those
// used to be dropped on the floor, so a channel going
// moderated was invisible until a message bounced.
Mode::Plus(mode, None) | Mode::Minus(mode, None) => {
let adding = matches!(m, Mode::Plus(_, None));
let letter = mode_letter(mode);
state.runtime.set_irc_channel_mode(state, account_id, &channel, letter, adding);
let body = format!("{from} sets {}{letter} on {channel}", if adding { "+" } else { "-" });
state.runtime.record_message(state, account_id, &channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
continue;
}
_ => continue,
};
// A ban or an exception carries a mask, not a nick, so it
// re-ranks nobody - but it is the channel changing and is
// worth saying out loud.
if matches!(mode, ChannelMode::Ban | ChannelMode::Exception | ChannelMode::InviteException) {
let body = format!(
"{from} {} {} {target}",
if granting { "adds" } else { "removes" },
mode_word(mode),
);
state.runtime.record_message(state, account_id, &channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
continue;
}
let Some(rank) = rank_from_mode(mode) else { continue };
let Some(slot) = members.get_mut(target.as_str()) else { continue };
*slot = if granting { rank } else { MemberRank::None };
@@ -1190,6 +1211,72 @@ async fn handle_message(
// when there's no channel buffer to show them in (the join never
// succeeded), so the always-present server buffer is the only
// reliable place - same as HexChat's server tab.
// What modes a channel currently has, in answer to a bare /mode.
Command::Response(Response::RPL_CHANNELMODEIS, args) => {
if let Some(channel) = args.get(1) {
let modes = args.get(2).map(String::as_str).unwrap_or("");
state.runtime.set_irc_channel_modes(state, account_id, channel, modes);
let body = if modes.trim_matches('+').is_empty() {
format!("{channel} has no modes set")
} else {
format!("{channel} is {modes}")
};
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
}
// The ban list, an entry at a time, in answer to "/mode #chan b".
// Rendered in the channel rather than gathered into a panel: a ban
// list is short, it is read once, and it belongs beside the channel
// it governs.
Command::Response(code @ (Response::RPL_BANLIST | Response::RPL_EXCEPTLIST | Response::RPL_INVITELIST), args) => {
if let (Some(channel), Some(mask)) = (args.get(1), args.get(2)) {
let kind = match code {
Response::RPL_BANLIST => "banned",
Response::RPL_EXCEPTLIST => "ban exception",
_ => "invite exception",
};
// Who set it and when, where the server says.
let by = args.get(3).map(|who| format!(" by {who}")).unwrap_or_default();
let body = format!("{kind}: {mask}{by}");
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
}
Command::Response(Response::RPL_ENDOFBANLIST, args) => {
if let Some(channel) = args.get(1) {
state.runtime.record_message(state, account_id, channel, "channel", "*", "end of ban list", false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
}
// Somebody asked us to join something.
Command::INVITE(_, channel) => {
let host = account_id.split_once('@').map(|(_, h)| h).unwrap_or(account_id);
let body = format!("{from} invites you to {channel}");
state.runtime.record_message(state, account_id, host, "server", "*", &body, false, "system", None, None, true, None, Vec::new(), Vec::new(), None);
}
// Confirmation that ours went out.
Command::Response(Response::RPL_INVITING, args) => {
if let (Some(nick), Some(channel)) = (args.get(1), args.get(2)) {
// The server answers with the pair either way round depending
// on how old it is, and both readings say the same thing.
let body = format!("invited {nick} to {channel}");
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
}
// The channel directory, gathered rather than printed: a network's
// list runs to tens of thousands of entries, which is something to
// search rather than a wall of chat.
Command::Response(Response::RPL_LISTSTART, _) => state.runtime.begin_irc_channel_list(account_id),
Command::Response(Response::RPL_LIST, args) => {
if let Some(channel) = args.get(1) {
let users = args.get(2).and_then(|u| u.parse::<u32>().ok()).unwrap_or(0);
let topic = args.get(3).cloned().unwrap_or_default();
state.runtime.push_irc_channel_list(account_id, channel, users, &topic);
}
}
Command::Response(Response::RPL_LISTEND, _) => state.runtime.finish_irc_channel_list(state, account_id),
Command::Response(code, args) if is_channel_error(code) => {
if let Some(text) = channel_error_text(&args) {
let host = account_id.split_once('@').map(|(_, h)| h).unwrap_or(account_id);
@@ -1393,8 +1480,53 @@ pub fn send_message(state: &AppState, account_id: &str, sender: &Sender, target_
if arg.is_empty() {
bail!("/ban requires a nick");
}
let mask = format!("{arg}!*@*");
sender.send(Command::ChannelMODE(target_buffer.to_string(), vec![Mode::Plus(ChannelMode::Ban, Some(mask))])).map_err(|e| anyhow!(e))
sender
.send(Command::ChannelMODE(target_buffer.to_string(), vec![Mode::Plus(ChannelMode::Ban, Some(ban_mask(arg)))]))
.map_err(|e| anyhow!(e))
}
// Raw, deliberately. Channel modes are a small language of their
// own with per-network extensions, and a client that only passes
// through the ones it recognises is a client that cannot set the
// one this network has. The server is the authority on what is
// valid and says so itself when it is not.
//
// With no argument this asks rather than sets, which is how every
// other client spells "what modes does this channel have" - and
// "/mode #chan b" is how you read the ban list.
"mode" => {
let (target, rest) = split_mode_target(target_buffer, arg);
if target.is_empty() {
bail!("/mode needs a channel or nick");
}
let raw = if rest.is_empty() { format!("MODE {target}") } else { format!("MODE {target} {rest}") };
sender.send(raw.parse::<Message>().map_err(|e| anyhow!("{e}"))?).map_err(|e| anyhow!(e))
}
"unban" => {
if arg.is_empty() {
bail!("/unban requires a nick or mask");
}
sender
.send(Command::ChannelMODE(target_buffer.to_string(), vec![Mode::Minus(ChannelMode::Ban, Some(ban_mask(arg)))]))
.map_err(|e| anyhow!(e))
}
"invite" => {
let mut invite_parts = arg.split_whitespace();
let nick = invite_parts.next().unwrap_or("");
if nick.is_empty() {
bail!("/invite requires a nick");
}
// The channel may be named, or taken from where the command
// was typed - which is what somebody means nine times in ten.
let channel = invite_parts.next().unwrap_or(target_buffer);
sender.send(Command::INVITE(nick.to_string(), channel.to_string())).map_err(|e| anyhow!(e))
}
// Asks the server for its channel list. The answer arrives as
// numerics and is gathered up rather than printed line by line -
// a network's list runs to tens of thousands of channels, which
// is a directory to search rather than a wall of chat.
"list" => {
let raw = if arg.is_empty() { "LIST".to_string() } else { format!("LIST {arg}") };
sender.send(raw.parse::<Message>().map_err(|e| anyhow!("{e}"))?).map_err(|e| anyhow!(e))
}
"op" => set_channel_mode(sender, target_buffer, ChannelMode::Oper, true, arg),
"deop" => set_channel_mode(sender, target_buffer, ChannelMode::Oper, false, arg),
@@ -1415,6 +1547,75 @@ pub fn send_message(state: &AppState, account_id: &str, sender: &Sender, target_
send_plain(state, account_id, sender, target_buffer, body)
}
/// A ban mask from whatever was typed.
///
/// A bare nick becomes `nick!*@*`, which is what somebody means by "ban them".
/// Anything already carrying a `!` or `@` is a mask and is left exactly as
/// written - turning `*!*@example.com` into `*!*@example.com!*@*` would ban
/// nobody while appearing to work.
fn ban_mask(target: &str) -> String {
let target = target.trim();
if target.contains('!') || target.contains('@') {
target.to_string()
} else {
format!("{target}!*@*")
}
}
/// Splits `/mode` into what it acts on and what it does.
///
/// `/mode +o someone` in a channel means that channel; `/mode #other +o me`
/// names one. The difference is whether the first word looks like a target
/// rather than a mode string, and a mode string is the thing starting with
/// `+` or `-`.
fn split_mode_target<'a>(current: &'a str, arg: &'a str) -> (&'a str, &'a str) {
let arg = arg.trim();
let first = arg.split_whitespace().next().unwrap_or("");
if !first.is_empty() && !first.starts_with('+') && !first.starts_with('-') {
let rest = arg[first.len()..].trim_start();
return (first, rest);
}
(current, arg)
}
/// The letter a channel mode is written with.
///
/// `ChannelMode` carries the ones the crate knows and `Unknown(c)` the rest,
/// which is most of them on a real network - so the letter is what gets stored
/// rather than the enum.
fn mode_letter(mode: &ChannelMode) -> char {
match mode {
ChannelMode::Ban => 'b',
ChannelMode::Exception => 'e',
ChannelMode::InviteException => 'I',
ChannelMode::InviteOnly => 'i',
ChannelMode::Key => 'k',
ChannelMode::Limit => 'l',
ChannelMode::Moderated => 'm',
ChannelMode::NoExternalMessages => 'n',
ChannelMode::RegisteredOnly => 'r',
ChannelMode::Secret => 's',
ChannelMode::ProtectedTopic => 't',
ChannelMode::Oper => 'o',
ChannelMode::Voice => 'v',
ChannelMode::Founder => 'q',
ChannelMode::Admin => 'a',
ChannelMode::Halfop => 'h',
ChannelMode::Unknown(c) => *c,
// The crate may grow variants; a letter nobody here knows is still
// better shown than swallowed.
_ => '?',
}
}
fn mode_word(mode: &ChannelMode) -> &'static str {
match mode {
ChannelMode::Ban => "a ban on",
ChannelMode::Exception => "a ban exception for",
_ => "an invite exception for",
}
}
fn set_channel_mode(sender: &Sender, channel: &str, mode: ChannelMode, add: bool, nick: &str) -> Result<()> {
if nick.is_empty() {
bail!("this command requires a nick");
@@ -1457,6 +1658,39 @@ mod quit_tests {
IrcAccountConfig { quit_message: message.map(String::from), ..Default::default() }
}
#[test]
fn a_bare_nick_becomes_a_mask_and_a_mask_is_left_alone() {
assert_eq!(ban_mask("someone"), "someone!*@*");
assert_eq!(ban_mask(" someone "), "someone!*@*");
// Wrapping a mask again would ban nobody while appearing to work.
assert_eq!(ban_mask("*!*@example.com"), "*!*@example.com");
assert_eq!(ban_mask("someone!user@host"), "someone!user@host");
assert_eq!(ban_mask("*@example.com"), "*@example.com");
}
#[test]
fn mode_acts_on_the_channel_it_was_typed_in_unless_told_otherwise() {
// The common case: no target named, so it is this channel.
assert_eq!(split_mode_target("#here", "+o someone"), ("#here", "+o someone"));
assert_eq!(split_mode_target("#here", "-m"), ("#here", "-m"));
// Asking rather than setting.
assert_eq!(split_mode_target("#here", ""), ("#here", ""));
assert_eq!(split_mode_target("#here", "b"), ("b", ""));
// A target named explicitly.
assert_eq!(split_mode_target("#here", "#other +o someone"), ("#other", "+o someone"));
assert_eq!(split_mode_target("#here", "somenick +i"), ("somenick", "+i"));
}
#[test]
fn every_mode_has_a_letter() {
assert_eq!(mode_letter(&ChannelMode::Moderated), 'm');
assert_eq!(mode_letter(&ChannelMode::InviteOnly), 'i');
assert_eq!(mode_letter(&ChannelMode::Key), 'k');
assert_eq!(mode_letter(&ChannelMode::ProtectedTopic), 't');
// The ones a network invented, which is most of them in practice.
assert_eq!(mode_letter(&ChannelMode::Unknown('C')), 'C');
}
#[test]
fn reads_every_spelling_of_a_mechanism() {
assert_eq!(SaslMechanism::parse("external"), Some(SaslMechanism::External));
+10
View File
@@ -124,6 +124,15 @@ pub struct Buffer {
/// mark_matrix_room_encrypted.
#[serde(rename = "encrypted", skip_serializing_if = "Option::is_none")]
pub encrypted: Option<bool>,
/// The channel's own modes, as the letters the server writes them with -
/// "+mnt". IRC only, and absent until the server has said, which it does
/// on join and whenever they change.
///
/// Carried so a client can say why a message will not send. A moderated
/// channel refuses one with an error that names a numeric, and "you cannot
/// speak here" is a better thing to have known beforehand.
#[serde(rename = "channelModes", skip_serializing_if = "Option::is_none")]
pub channel_modes: Option<String>,
/// Which BufferGroup this buffer belongs to - a Discord guild, a Matrix
/// space, or the account itself for protocols with no such concept.
/// Absent only while a backend has not yet placed it.
@@ -448,6 +457,7 @@ mod tests {
category: None,
position: 0,
encrypted: None,
channel_modes: None,
group_id: None,
remote_id: None,
}
+96
View File
@@ -130,6 +130,14 @@ pub const DCC_KEEP: usize = 100;
/// real prompt among fifty fake ones at worst.
const DCC_PENDING_PER_NICK: usize = 3;
/// A channel the server has told us about, for the directory `/list` builds.
#[derive(Clone, Debug, serde::Serialize)]
pub struct IrcChannelListing {
pub name: String,
pub users: u32,
pub topic: String,
}
/// One Kick channel this daemon is watching.
#[derive(Clone, Debug)]
pub struct KickChannel {
@@ -359,6 +367,9 @@ pub struct Runtime {
kick_senders: Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>>>,
/// What is known about each watched Kick channel, by buffer id.
kick_channels: Mutex<HashMap<String, KickChannel>>,
/// A `/list` in progress, by account. Gathered rather than announced a
/// line at a time - a network answers with tens of thousands of channels.
irc_channel_lists: Mutex<HashMap<String, Vec<IrcChannelListing>>>,
/// Matrix-specific: buffer id -> room id. Matrix buffer *names* are
/// human-friendly (see backend/matrix/rooms.rs's naming fallback
/// chain), but sending/reacting/etc. needs the real `!opaque:server`
@@ -538,6 +549,7 @@ impl Runtime {
sockchat_senders: Mutex::new(HashMap::new()),
kick_senders: Mutex::new(HashMap::new()),
kick_channels: Mutex::new(HashMap::new()),
irc_channel_lists: Mutex::new(HashMap::new()),
matrix_rooms: Mutex::new(HashMap::new()),
matrix_room_names: Mutex::new(HashMap::new()),
matrix_space_parents: Mutex::new(HashMap::new()),
@@ -806,6 +818,7 @@ impl Runtime {
category: None,
position: 0,
encrypted: None,
channel_modes: None,
group_id: Some(model::account_group_id(account_id)),
remote_id: None,
};
@@ -1914,6 +1927,89 @@ impl Runtime {
self.kick_senders.lock().unwrap().get(account_id).cloned()
}
/// Records one of a channel's own modes, as the server announced it.
///
/// Letter by letter, because that is how a MODE change arrives: a channel
/// going moderated says `+m` and nothing about the modes it already had.
pub fn set_irc_channel_mode(&self, state: &AppState, account_id: &str, channel: &str, letter: char, adding: bool) {
// A mode that takes an argument - a key, a limit, a ban - is not part
// of the channel's mode string in any useful sense here: `+k` without
// the key says nothing a reader can act on, and bans have their own
// list.
if matches!(letter, 'b' | 'e' | 'I' | 'o' | 'v' | 'q' | 'a' | 'h') {
return;
}
let buffer_id = crate::model::buffer_id(account_id, channel);
let mut letters: Vec<char> = self
.buffers
.lock()
.unwrap()
.get(&buffer_id)
.and_then(|b| b.channel_modes.clone())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_alphabetic())
.collect();
letters.retain(|c| *c != letter);
if adding {
letters.push(letter);
}
letters.sort_unstable();
self.publish_channel_modes(state, &buffer_id, &letters);
}
/// Replaces the whole set, for the server's answer to a bare `MODE`.
pub fn set_irc_channel_modes(&self, state: &AppState, account_id: &str, channel: &str, modes: &str) {
let mut letters: Vec<char> = modes.chars().filter(|c| c.is_ascii_alphabetic()).collect();
letters.sort_unstable();
letters.dedup();
self.publish_channel_modes(state, &crate::model::buffer_id(account_id, channel), &letters);
}
fn publish_channel_modes(&self, state: &AppState, buffer_id: &str, letters: &[char]) {
let modes = if letters.is_empty() { String::new() } else { format!("+{}", letters.iter().collect::<String>()) };
let updated = {
let mut buffers = self.buffers.lock().unwrap();
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
let next = (!modes.is_empty()).then_some(modes);
if buffer.channel_modes == next {
return;
}
buffer.channel_modes = next;
buffer.clone()
};
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
pub fn begin_irc_channel_list(&self, account_id: &str) {
self.irc_channel_lists.lock().unwrap().insert(account_id.to_string(), Vec::new());
}
pub fn push_irc_channel_list(&self, account_id: &str, name: &str, users: u32, topic: &str) {
// Started implicitly if the server sent no 321, which many do not.
self.irc_channel_lists
.lock()
.unwrap()
.entry(account_id.to_string())
.or_default()
.push(IrcChannelListing { name: name.to_string(), users, topic: topic.to_string() });
}
/// Announces the finished directory, busiest first.
///
/// Sorted here rather than in the client because every client would want
/// the same order, and because "which of these has anybody in it" is the
/// only question somebody scrolling a list of forty thousand channels is
/// actually asking.
pub fn finish_irc_channel_list(&self, state: &AppState, account_id: &str) {
let mut channels = self.irc_channel_lists.lock().unwrap().remove(account_id).unwrap_or_default();
channels.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.cmp(&b.name)));
state.events.emit(
"ircChannelList",
json!({ "accountId": account_id, "channels": channels }),
);
}
pub fn clear_kick_sender(&self, account_id: &str) {
self.kick_senders.lock().unwrap().remove(account_id);
}