use std::collections::{HashMap, VecDeque};
use std::fmt::Write as _;
use std::time::Instant;
use chrono::{DateTime, Utc};
use irc::proto::{Command, Message as IrcMessage, Prefix, Response};
use crate::config::IgnoreLevel;
use crate::irc::formatting::{
extract_nick, extract_nick_userhost, is_channel, is_server_prefix, modes_to_prefix,
strip_irc_formatting,
};
use crate::irc::ignore::should_ignore;
use crate::state::AppState;
use crate::state::buffer::{
ActivityLevel, Buffer, BufferType, Message, MessageType, NickEntry, make_buffer_id,
};
use crate::state::connection::ConnectionStatus;
const MAX_LIST_MODE_ENTRIES: usize = 500;
#[expect(
clippy::too_many_lines,
reason = "IRC command dispatcher — one arm per message type"
)]
pub fn handle_irc_message(state: &mut AppState, conn_id: &str, msg: &IrcMessage) {
let our_nick = state
.connections
.get(conn_id)
.map(|c| c.nick.clone())
.unwrap_or_default();
let tags = extract_tags(msg);
match &msg.command {
Command::PRIVMSG(target, text) => {
handle_privmsg(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
target,
text,
tags,
);
}
Command::NOTICE(target, text) => {
handle_notice(state, conn_id, msg.prefix.as_ref(), target, text, tags);
}
Command::JOIN(channel, account, realname) => {
handle_join(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
channel,
account.as_deref(),
realname.as_deref(),
tags,
);
}
Command::PART(channel, reason) => {
handle_part(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
channel,
reason.as_deref(),
tags,
);
}
Command::QUIT(reason) => {
handle_quit(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
reason.as_deref(),
tags,
);
}
Command::NICK(new_nick) => {
handle_nick_change(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
new_nick,
tags,
);
}
Command::KICK(channel, kicked_user, reason) => {
handle_kick(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
channel,
kicked_user,
reason.as_deref(),
tags,
);
}
Command::TOPIC(channel, topic) => {
handle_topic(
state,
conn_id,
msg.prefix.as_ref(),
channel,
topic.as_deref(),
tags,
);
}
Command::ChannelMODE(target, _) | Command::UserMODE(target, _) => {
handle_mode(state, conn_id, msg.prefix.as_ref(), target, msg, tags);
}
Command::INVITE(nick, channel) => {
handle_invite(
state,
conn_id,
&our_nick,
msg.prefix.as_ref(),
nick,
channel,
tags,
);
}
Command::Response(response, args) => {
handle_response(state, conn_id, *response, args);
}
Command::WALLOPS(text) => {
handle_wallops(state, conn_id, msg.prefix.as_ref(), text);
}
Command::ACCOUNT(account) => {
handle_account(state, conn_id, msg.prefix.as_ref(), account, tags);
}
Command::AWAY(reason) => {
handle_away(state, conn_id, msg.prefix.as_ref(), reason.as_deref());
}
Command::CHGHOST(new_user, new_host) => {
handle_chghost(
state,
conn_id,
msg.prefix.as_ref(),
new_user,
new_host,
tags,
);
}
Command::ERROR(message) => {
handle_error(state, conn_id, message);
}
Command::Raw(cmd, args) if cmd == "329" => {
if args.len() >= 3 {
let channel = &args[1];
let buffer_id = make_buffer_id(conn_id, channel);
if let Ok(ts) = args[2].parse::<i64>() {
let created = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now);
let formatted = created
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string();
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("Channel {channel} created {formatted}"),
highlight: false,
event_key: Some("channel_created".to_string()),
event_params: Some(vec![channel.clone(), formatted]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
}
}
Command::Raw(cmd, args) if cmd == "354" => {
handle_whox_reply(state, conn_id, args);
}
Command::Raw(cmd, args) if cmd.len() == 3 && cmd.chars().all(|c| c.is_ascii_digit()) => {
let buffer_id = active_or_server_buffer(state, conn_id);
let text = if args.len() > 1 {
args[1..].join(" ")
} else {
args.join(" ")
};
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
_ => {}
}
}
pub fn handle_connected(state: &mut AppState, conn_id: &str) {
state.update_connection_status(conn_id, ConnectionStatus::Connected);
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.reconnect_attempts = 0;
conn.next_reconnect = None;
conn.error = None;
conn.isupport_parsed = crate::irc::isupport::Isupport::default();
conn.silent_who_channels.clear();
}
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("Connected to {label}"),
highlight: false,
event_key: Some("connected".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
pub fn channels_to_rejoin(state: &AppState, conn_id: &str) -> Vec<String> {
let mut channels: Vec<String> = state
.buffers
.values()
.filter(|b| {
b.connection_id == conn_id && b.buffer_type == crate::state::buffer::BufferType::Channel
})
.map(|b| b.name.clone())
.collect();
if let Some(conn) = state.connections.get(conn_id) {
for ch in &conn.joined_channels {
if !channels.contains(ch) {
channels.push(ch.clone());
}
}
}
channels
}
pub fn handle_disconnected(state: &mut AppState, conn_id: &str, error: Option<&str>) {
let current_channels: Vec<String> = state
.buffers
.values()
.filter(|b| {
b.connection_id == conn_id && b.buffer_type == crate::state::buffer::BufferType::Channel
})
.map(|b| b.name.clone())
.collect();
if let Some(err) = error {
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.status = ConnectionStatus::Error;
conn.error = Some(err.to_string());
}
} else {
state.update_connection_status(conn_id, ConnectionStatus::Disconnected);
}
if let Some(conn) = state.connections.get_mut(conn_id) {
if !current_channels.is_empty() {
conn.joined_channels = current_channels;
}
if conn.should_reconnect {
let delay =
calculate_reconnect_delay(conn.reconnect_delay_secs, conn.reconnect_attempts);
conn.next_reconnect =
Some(std::time::Instant::now() + std::time::Duration::from_secs(delay));
}
}
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let mut msg_text = error.map_or_else(
|| format!("Disconnected from {label}"),
|e| format!("Disconnected from {label}: {e}"),
);
if let Some(conn) = state.connections.get(conn_id)
&& conn.should_reconnect
{
let delay = calculate_reconnect_delay(conn.reconnect_delay_secs, conn.reconnect_attempts);
let _ = write!(msg_text, " — reconnecting in {delay}s");
}
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: msg_text,
highlight: false,
event_key: Some("disconnected".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
fn calculate_reconnect_delay(base_delay: u64, attempts: u32) -> u64 {
if attempts >= 10 {
return 600;
}
let delay = base_delay.saturating_mul(2u64.saturating_pow(attempts));
delay.min(300)
}
fn extract_cap_string(field3: Option<&str>, field4: Option<&str>) -> String {
if field3 == Some("*") {
return field4.unwrap_or("").to_string();
}
if let Some(s) = field4
&& !s.is_empty()
{
return s.to_string();
}
field3.unwrap_or("").to_string()
}
pub fn handle_cap_new(
state: &mut AppState,
conn_id: &str,
field3: Option<&str>,
field4: Option<&str>,
) -> Vec<String> {
use crate::irc::cap::DESIRED_CAPS;
let caps_str = extract_cap_string(field3, field4);
let new_caps: Vec<String> = caps_str
.split_whitespace()
.map(|s| s.split_once('=').map_or(s, |(name, _)| name))
.map(str::to_ascii_lowercase)
.collect();
tracing::info!("CAP NEW from {conn_id}: {}", new_caps.join(" "));
let enabled = state.connections.get(conn_id).map(|c| &c.enabled_caps);
let to_request: Vec<String> = new_caps
.iter()
.filter(|cap| {
DESIRED_CAPS.iter().any(|d| d.eq_ignore_ascii_case(cap))
&& enabled.is_none_or(|set| !set.contains(cap.as_str()))
})
.cloned()
.collect();
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let text = if to_request.is_empty() {
format!(
"New capabilities available: {} (none requested)",
new_caps.join(", ")
)
} else {
format!(
"New capabilities available: {} — requesting: {}",
new_caps.join(", "),
to_request.join(", ")
)
};
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: Some("cap_new".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
to_request
}
pub fn handle_cap_del(
state: &mut AppState,
conn_id: &str,
field3: Option<&str>,
field4: Option<&str>,
) {
let caps_str = extract_cap_string(field3, field4);
let removed_caps: Vec<String> = caps_str
.split_whitespace()
.map(|s| s.split_once('=').map_or(s, |(name, _)| name))
.map(str::to_ascii_lowercase)
.collect();
tracing::info!("CAP DEL from {conn_id}: {}", removed_caps.join(" "));
let mut actually_removed = Vec::new();
if let Some(conn) = state.connections.get_mut(conn_id) {
for cap in &removed_caps {
if conn.enabled_caps.remove(cap) {
actually_removed.push(cap.clone());
}
}
}
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let text = if actually_removed.is_empty() {
format!(
"Capabilities removed: {} (none were enabled)",
removed_caps.join(", ")
)
} else {
format!("Capabilities removed: {}", actually_removed.join(", "))
};
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: Some("cap_del".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
pub fn handle_cap_ack(
state: &mut AppState,
conn_id: &str,
field3: Option<&str>,
field4: Option<&str>,
) {
let caps_str = extract_cap_string(field3, field4);
let acked_caps: Vec<String> = caps_str
.split_whitespace()
.map(str::to_ascii_lowercase)
.collect();
tracing::info!("CAP ACK from {conn_id}: {}", acked_caps.join(" "));
if let Some(conn) = state.connections.get_mut(conn_id) {
for cap in &acked_caps {
conn.enabled_caps.insert(cap.clone());
}
}
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let text = format!("Capabilities acknowledged: {}", acked_caps.join(", "));
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: Some("cap_ack".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
pub fn handle_cap_nak(
state: &mut AppState,
conn_id: &str,
field3: Option<&str>,
field4: Option<&str>,
) {
let caps_str = extract_cap_string(field3, field4);
let naked_caps: Vec<String> = caps_str
.split_whitespace()
.map(str::to_ascii_lowercase)
.collect();
tracing::warn!("CAP NAK from {conn_id}: {}", naked_caps.join(" "));
let label = state
.connections
.get(conn_id)
.map_or_else(|| conn_id.to_string(), |c| c.label.clone());
let buffer_id = make_buffer_id(conn_id, &label);
let text = format!("Capabilities rejected: {}", naked_caps.join(", "));
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: Some("cap_nak".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
fn nick_prefix(state: &AppState, buffer_id: &str, nick: &str) -> Option<char> {
state.nick_prefix(buffer_id, nick)
}
fn extract_tags(msg: &IrcMessage) -> Option<HashMap<String, String>> {
let tags = msg.tags.as_ref()?;
let map: HashMap<String, String> = tags
.iter()
.filter_map(|tag| Some((tag.0.clone(), tag.1.as_ref()?.clone())))
.collect();
if map.is_empty() { None } else { Some(map) }
}
fn message_timestamp(tags: Option<&HashMap<String, String>>) -> DateTime<Utc> {
tags.and_then(|t| t.get("time"))
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.map_or_else(Utc::now, |dt| dt.with_timezone(&Utc))
}
#[expect(clippy::too_many_lines, reason = "linear message handler")]
fn handle_privmsg(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
target: &str,
text: &str,
tags: Option<HashMap<String, String>>,
) {
let (nick, ident, host) = extract_nick_userhost(prefix);
let target_is_channel = is_channel(target);
let is_own = nick == our_nick;
let buffer_name = if target_is_channel || is_own {
target
} else {
&nick
};
let buffer_id = make_buffer_id(conn_id, buffer_name);
let sender_handle = format!("{ident}@{host}");
let decrypt_context = crate::e2e::context_key(target, &sender_handle);
let decrypted_owned = try_decrypt_e2e(
state,
conn_id,
&nick,
&sender_handle,
&decrypt_context,
text,
is_own,
);
if decrypted_owned.as_deref() == Some("") {
return;
}
let text: &str = decrypted_owned.as_deref().unwrap_or(text);
let is_ctcp = text.starts_with('\x01') && text.ends_with('\x01');
let is_action = is_ctcp && text.len() > 2 && text[1..text.len() - 1].starts_with("ACTION ");
{
let ignore_level = if is_action {
IgnoreLevel::Actions
} else if is_ctcp {
IgnoreLevel::Ctcps
} else if target_is_channel {
IgnoreLevel::Public
} else {
IgnoreLevel::Msgs
};
let channel = if target_is_channel {
Some(target)
} else {
None
};
if should_ignore(
&state.ignores,
&nick,
Some(&ident),
Some(&host),
&ignore_level,
channel,
) {
return;
}
}
if !target_is_channel && !state.buffers.contains_key(&buffer_id) {
state.add_buffer(Buffer {
id: buffer_id.clone(),
connection_id: conn_id.to_string(),
buffer_type: BufferType::Query,
name: nick.clone(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: std::collections::HashMap::new(),
modes: None,
mode_params: None,
list_modes: std::collections::HashMap::new(),
last_speakers: Vec::new(),
peer_handle: if is_own {
None
} else {
Some(format!("{ident}@{host}"))
},
});
}
if !target_is_channel
&& !is_own
&& let Some(buf) = state.buffers.get_mut(&buffer_id)
{
let new_handle = format!("{ident}@{host}");
if buf.peer_handle.as_deref() != Some(new_handle.as_str()) {
buf.peer_handle = Some(new_handle);
}
}
if let Some(tag_account) = tags.as_ref().and_then(|t| t.get("account")) {
let account = if tag_account == "*" {
None
} else {
Some(tag_account.clone())
};
if target_is_channel
&& let Some(buf) = state.buffers.get_mut(&buffer_id)
&& let Some(entry) = buf.users.get_mut(&nick.to_lowercase())
{
entry.account.clone_from(&account);
}
}
if is_ctcp {
let inner = &text[1..text.len() - 1];
if let Some(action_text) = inner.strip_prefix("ACTION ") {
let is_mention = !is_own
&& strip_irc_formatting(action_text)
.to_lowercase()
.contains(&our_nick.to_lowercase());
let activity = if is_own {
ActivityLevel::None
} else if !target_is_channel || is_mention {
ActivityLevel::Mention
} else {
ActivityLevel::Activity
};
let mode_prefix = nick_prefix(state, &buffer_id, &nick);
let id = state.next_message_id();
let ts = message_timestamp(tags.as_ref());
let nick_saved = if is_mention { Some(nick.clone()) } else { None };
state.add_message_with_activity(
&buffer_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Action,
nick: Some(nick),
nick_mode: mode_prefix.map(String::from),
text: action_text.to_string(),
highlight: is_mention,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags,
},
activity,
);
if is_mention && target_is_channel && state.buffers.contains_key("_mentions") {
let nick = nick_saved.unwrap_or_default();
let conn_label = state
.connections
.get(conn_id)
.map_or(conn_id, |c| c.label.as_str());
let datetime = ts
.with_timezone(&chrono::Local)
.format("%Y/%m/%d %H:%M:%S")
.to_string();
let action_body = format!("* {nick} {action_text}");
let mention_text = crate::ui::format_mention_line(
&datetime,
conn_label,
target,
&nick,
&action_body,
state.nick_color_sat,
state.nick_color_lit,
);
let mention_msg = Message {
id: state.next_message_id(),
timestamp: ts,
message_type: MessageType::MentionLog,
nick: None,
nick_mode: None,
text: mention_text,
highlight: true,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
};
state.add_mention_to_buffer(mention_msg);
}
return;
}
if state.flood_protection {
let now = Instant::now();
let result = state.flood_state.check_ctcp_flood(now);
if result.suppressed() {
if result == crate::irc::flood::FloodResult::Triggered {
emit(state, &buffer_id, "CTCP flood detected — suppressing");
}
return;
}
}
if try_dispatch_rpe2e_ctcp(state, conn_id, prefix, target, text)
== Some(RpEe2eOutcome::Handled)
{
return;
}
return;
}
if state.flood_protection && nick != our_nick {
let now = Instant::now();
if ident.starts_with('~') {
let result = state.flood_state.check_tilde_nick_flood(&nick, now);
if result.suppressed() {
if result == crate::irc::flood::FloodResult::Triggered {
emit(
state,
&buffer_id,
&format!("Flood from {nick} detected — suppressing"),
);
}
return;
}
if !target_is_channel {
let storm = state.flood_state.check_pm_tilde_storm(&nick, now);
if storm.suppressed() {
if storm == crate::irc::flood::FloodResult::Triggered {
emit(
state,
&buffer_id,
"PM flood storm detected — suppressing all ~ PMs",
);
}
return;
}
}
}
let dup_result = state
.flood_state
.check_duplicate_flood(text, target_is_channel, now);
if dup_result.suppressed() {
if dup_result == crate::irc::flood::FloodResult::Triggered {
emit(
state,
&buffer_id,
"Duplicate text flood detected — suppressing",
);
}
return;
}
}
let is_mention = !is_own
&& strip_irc_formatting(text)
.to_lowercase()
.contains(&our_nick.to_lowercase());
let activity = if is_own {
ActivityLevel::None
} else if !target_is_channel || is_mention {
ActivityLevel::Mention } else {
ActivityLevel::Activity
};
let mode_prefix = nick_prefix(state, &buffer_id, &nick);
let id = state.next_message_id();
let ts = message_timestamp(tags.as_ref());
let nick_saved = if is_mention { Some(nick.clone()) } else { None };
state.add_message_with_activity(
&buffer_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Message,
nick: Some(nick),
nick_mode: mode_prefix.map(String::from),
text: text.to_string(),
highlight: is_mention,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags,
},
activity,
);
if is_mention && target_is_channel && state.buffers.contains_key("_mentions") {
let nick = nick_saved.unwrap_or_default();
let conn_label = state
.connections
.get(conn_id)
.map_or(conn_id, |c| c.label.as_str());
let datetime = ts
.with_timezone(&chrono::Local)
.format("%Y/%m/%d %H:%M:%S")
.to_string();
let mention_text = crate::ui::format_mention_line(
&datetime,
conn_label,
target,
&nick,
text,
state.nick_color_sat,
state.nick_color_lit,
);
let mention_msg = Message {
id: state.next_message_id(),
timestamp: ts,
message_type: MessageType::MentionLog,
nick: None,
nick_mode: None,
text: mention_text,
highlight: true,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
};
state.add_mention_to_buffer(mention_msg);
}
}
fn handle_notice(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
target: &str,
text: &str,
tags: Option<HashMap<String, String>>,
) {
let nick = extract_nick(prefix);
let is_server_notice = nick.is_none() || is_server_prefix(prefix);
if !is_server_notice {
let (n, ident, host) = extract_nick_userhost(prefix);
let channel = if is_channel(target) {
Some(target)
} else {
None
};
if should_ignore(
&state.ignores,
&n,
Some(&ident),
Some(&host),
&IgnoreLevel::Notices,
channel,
) {
return;
}
}
if try_dispatch_rpe2e_ctcp(state, conn_id, prefix, target, text) == Some(RpEe2eOutcome::Handled)
{
return;
}
let our_nick = state
.connections
.get(conn_id)
.map(|c| c.nick.as_str())
.unwrap_or_default();
let is_own = nick.as_deref() == Some(our_nick);
let buffer_name = if is_server_notice {
state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str())
} else if is_channel(target) || is_own {
target
} else {
nick.as_deref().unwrap_or("Status")
};
let buffer_id = make_buffer_id(conn_id, buffer_name);
let buffer_id = if state.buffers.contains_key(&buffer_id) {
buffer_id
} else {
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
make_buffer_id(conn_id, label)
};
let mode_prefix = nick
.as_deref()
.and_then(|n| nick_prefix(state, &buffer_id, n));
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Notice,
nick,
nick_mode: mode_prefix.map(String::from),
text: text.to_string(),
highlight: false,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
#[expect(clippy::too_many_arguments, clippy::too_many_lines)]
fn handle_join(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
channel: &str,
extended_account: Option<&str>,
extended_realname: Option<&str>,
tags: Option<HashMap<String, String>>,
) {
let (nick, ident, host) = extract_nick_userhost(prefix);
let buffer_id = make_buffer_id(conn_id, channel);
let account = match extended_account {
Some("*") | None => None,
Some(a) => Some(a.to_string()),
};
let account = account.or_else(|| {
tags.as_ref()
.and_then(|t| t.get("account"))
.and_then(|a| if a == "*" { None } else { Some(a.clone()) })
});
let realname = extended_realname.unwrap_or("");
if nick != our_nick
&& should_ignore(
&state.ignores,
&nick,
Some(&ident),
Some(&host),
&IgnoreLevel::Joins,
Some(channel),
)
{
state.add_nick(
&buffer_id,
NickEntry {
nick: nick.clone(),
prefix: String::new(),
modes: String::new(),
away: false,
account,
ident: None,
host: None,
},
);
return;
}
if nick == our_nick {
if !state.buffers.contains_key(&buffer_id) {
state.add_buffer(Buffer {
id: buffer_id.clone(),
connection_id: conn_id.to_string(),
buffer_type: BufferType::Channel,
name: channel.to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: std::collections::HashMap::new(),
modes: None,
mode_params: None,
list_modes: std::collections::HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
}
state.set_active_buffer(&buffer_id);
} else {
state.add_nick(
&buffer_id,
NickEntry {
nick: nick.clone(),
prefix: String::new(),
modes: String::new(),
away: false,
account: account.clone(),
ident: None,
host: None,
},
);
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buffer_id.clone(),
kind: crate::web::protocol::NickEventKind::Join,
nick: nick.clone(),
new_nick: None,
prefix: Some(String::new()),
modes: Some(String::new()),
away: Some(false),
message: None,
});
if state.netsplit_state.handle_join(&nick, &buffer_id) {
return;
}
}
let account_display = account
.as_deref()
.map_or(String::new(), |a| format!("[{a}]"));
let realname_display = if realname.is_empty() {
String::new()
} else {
realname.to_string()
};
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!(
"{nick} ({ident}@{host}) has joined {channel} {account_display} {realname_display}"
),
highlight: false,
event_key: Some("join".to_string()),
event_params: Some(vec![
nick,
ident,
host,
channel.to_string(),
account_display,
realname_display,
]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
fn update_nick_account_in_buffers(
state: &mut AppState,
conn_id: &str,
nick: &str,
account: Option<&str>,
) {
let nick_lower = nick.to_lowercase();
for buf in state.buffers.values_mut() {
if buf.connection_id != conn_id {
continue;
}
if let Some(entry) = buf.users.get_mut(&nick_lower) {
entry.account = account.map(str::to_string);
}
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "tags follows the convention of all other event handlers"
)]
fn handle_account(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
account: &str,
tags: Option<HashMap<String, String>>,
) {
let Some(nick) = extract_nick(prefix) else {
return;
};
let resolved: Option<&str> = if account == "*" { None } else { Some(account) };
update_nick_account_in_buffers(state, conn_id, &nick, resolved);
let shared_buffers: Vec<String> = state
.buffers
.values()
.filter(|b| {
b.connection_id == conn_id
&& b.buffer_type == BufferType::Channel
&& b.users.contains_key(&nick.to_lowercase())
})
.map(|b| b.id.clone())
.collect();
let (text, description) = resolved.map_or_else(
|| {
(
format!("{nick} has logged out"),
"has logged out".to_string(),
)
},
|acct| {
(
format!("{nick} is now logged in as {acct}"),
format!("is now logged in as {acct}"),
)
},
);
for buf_id in shared_buffers {
let id = state.next_message_id();
state.add_message(
&buf_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("account".to_string()),
event_params: Some(vec![nick.clone(), description.clone()]),
log_msg_id: None,
log_ref_id: None,
tags: tags.clone(),
},
);
}
}
fn handle_away(state: &mut AppState, conn_id: &str, prefix: Option<&Prefix>, reason: Option<&str>) {
let Some(nick) = extract_nick(prefix) else {
return;
};
let is_away = reason.is_some();
let nick_lower = nick.to_lowercase();
let affected_bufs: Vec<String> = state
.buffers
.iter()
.filter(|(_, buf)| buf.connection_id == conn_id && buf.users.contains_key(&nick_lower))
.map(|(id, _)| id.clone())
.collect();
for buf in state.buffers.values_mut() {
if buf.connection_id != conn_id {
continue;
}
if let Some(entry) = buf.users.get_mut(&nick_lower) {
entry.away = is_away;
}
}
for buf_id in affected_bufs {
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buf_id,
kind: crate::web::protocol::NickEventKind::AwayChange,
nick: nick.clone(),
new_nick: None,
prefix: None,
modes: None,
away: Some(is_away),
message: reason.map(ToString::to_string),
});
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "tags follows the convention of all other event handlers"
)]
fn handle_chghost(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
new_user: &str,
new_host: &str,
tags: Option<HashMap<String, String>>,
) {
let Some(nick) = extract_nick(prefix) else {
return;
};
let nick_lower = nick.to_lowercase();
for buf in state.buffers.values_mut() {
if buf.connection_id != conn_id {
continue;
}
if let Some(entry) = buf.users.get_mut(&nick_lower) {
entry.ident = Some(new_user.to_string());
entry.host = Some(new_host.to_string());
}
}
let shared_buffers: Vec<String> = state
.buffers
.values()
.filter(|b| {
b.connection_id == conn_id
&& b.buffer_type == BufferType::Channel
&& b.users.contains_key(&nick_lower)
})
.map(|b| b.id.clone())
.collect();
let text = format!("{nick} changed host to {new_user}@{new_host}");
for buf_id in shared_buffers {
let id = state.next_message_id();
state.add_message(
&buf_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("chghost".to_string()),
event_params: Some(vec![
nick.clone(),
new_user.to_string(),
new_host.to_string(),
]),
log_msg_id: None,
log_ref_id: None,
tags: tags.clone(),
},
);
}
}
fn handle_part(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
channel: &str,
reason: Option<&str>,
tags: Option<HashMap<String, String>>,
) {
let (nick, ident, host) = extract_nick_userhost(prefix);
let buffer_id = make_buffer_id(conn_id, channel);
if nick == our_nick {
state.remove_buffer(&buffer_id);
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.silent_who_channels.remove(channel);
}
} else {
state.remove_nick(&buffer_id, &nick);
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buffer_id.clone(),
kind: crate::web::protocol::NickEventKind::Part,
nick: nick.clone(),
new_nick: None,
prefix: None,
modes: None,
away: None,
message: reason.map(ToString::to_string),
});
if should_ignore(
&state.ignores,
&nick,
Some(&ident),
Some(&host),
&IgnoreLevel::Parts,
Some(channel),
) {
return;
}
let reason_str = reason.unwrap_or("");
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{nick} ({ident}@{host}) has left {channel} ({reason_str})"),
highlight: false,
event_key: Some("part".to_string()),
event_params: Some(vec![
nick,
ident,
host,
channel.to_string(),
reason_str.to_string(),
]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "tags are dropped when ignored/netsplit, cloned into fan-out Messages otherwise"
)]
fn handle_quit(
state: &mut AppState,
conn_id: &str,
_our_nick: &str,
prefix: Option<&Prefix>,
reason: Option<&str>,
tags: Option<HashMap<String, String>>,
) {
let (nick, ident, host) = extract_nick_userhost(prefix);
let reason_str = reason.unwrap_or("");
let affected: Vec<String> = state
.buffers
.iter()
.filter(|(_, buf)| {
buf.connection_id == conn_id && buf.users.contains_key(&nick.to_lowercase())
})
.map(|(id, _)| id.clone())
.collect();
for buf_id in &affected {
state.remove_nick(buf_id, &nick);
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buf_id.clone(),
kind: crate::web::protocol::NickEventKind::Quit,
nick: nick.clone(),
new_nick: None,
prefix: None,
modes: None,
away: None,
message: reason.map(ToString::to_string),
});
}
if should_ignore(
&state.ignores,
&nick,
Some(&ident),
Some(&host),
&IgnoreLevel::Quits,
None,
) {
return;
}
if state
.netsplit_state
.handle_quit(&nick, reason_str, &affected)
{
return;
}
let primary_msg_id = uuid::Uuid::new_v4().to_string();
let text = format!("{nick} ({ident}@{host}) has quit ({reason_str})");
let ts = message_timestamp(tags.as_ref());
for (i, buf_id) in affected.iter().enumerate() {
let id = state.next_message_id();
state.add_message(
buf_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("quit".to_string()),
event_params: Some(vec![
nick.clone(),
ident.clone(),
host.clone(),
reason_str.to_string(),
]),
log_msg_id: if i == 0 {
Some(primary_msg_id.clone())
} else {
None
},
log_ref_id: if i == 0 {
None
} else {
Some(primary_msg_id.clone())
},
tags: tags.clone(),
},
);
}
}
fn rename_query_buffers(state: &mut AppState, conn_id: &str, new_nick: &str, affected: &[String]) {
for buf_id in affected {
let is_query = state
.buffers
.get(buf_id)
.is_some_and(|b| b.buffer_type == BufferType::Query);
if !is_query {
continue;
}
let new_buf_id = make_buffer_id(conn_id, new_nick);
if let Some(mut buf) = state.buffers.shift_remove(buf_id) {
buf.name = new_nick.to_string();
buf.id.clone_from(&new_buf_id);
state.buffers.insert(new_buf_id.clone(), buf);
if state.active_buffer_id.as_deref() == Some(buf_id.as_str()) {
state.active_buffer_id = Some(new_buf_id);
}
}
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "tags are cloned into each fan-out Message"
)]
#[expect(
clippy::too_many_lines,
reason = "nick change fan-out + web NickEvent broadcasting"
)]
fn handle_nick_change(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
new_nick: &str,
tags: Option<HashMap<String, String>>,
) {
let old_nick = extract_nick(prefix).unwrap_or_default();
if old_nick == our_nick
&& let Some(conn) = state.connections.get_mut(conn_id)
{
conn.nick = new_nick.to_string();
state
.pending_web_events
.push(crate::web::protocol::WebEvent::ConnectionStatus {
conn_id: conn_id.to_string(),
label: conn.label.clone(),
connected: conn.status == crate::state::connection::ConnectionStatus::Connected,
nick: new_nick.to_string(),
});
}
if old_nick != our_nick {
let (_, ident, host) = extract_nick_userhost(prefix);
if should_ignore(
&state.ignores,
&old_nick,
Some(&ident),
Some(&host),
&IgnoreLevel::Nicks,
None,
) {
let old_nick_lower = old_nick.to_lowercase();
let affected: Vec<String> = state
.buffers
.iter()
.filter(|(_, buf)| {
buf.connection_id == conn_id
&& (buf.users.contains_key(&old_nick_lower)
|| (buf.buffer_type == BufferType::Query
&& buf.name.to_lowercase() == old_nick_lower))
})
.map(|(id, _)| id.clone())
.collect();
for buf_id in &affected {
state.update_nick(buf_id, &old_nick, new_nick);
}
rename_query_buffers(state, conn_id, new_nick, &affected);
return;
}
}
let old_nick_lower = old_nick.to_lowercase();
let affected: Vec<String> = state
.buffers
.iter()
.filter(|(_, buf)| {
buf.connection_id == conn_id
&& (buf.users.contains_key(&old_nick_lower)
|| (buf.buffer_type == BufferType::Query
&& buf.name.to_lowercase() == old_nick_lower))
})
.map(|(id, _)| id.clone())
.collect();
let primary_msg_id = uuid::Uuid::new_v4().to_string();
let text = format!("{old_nick} is now known as {new_nick}");
let mut primary_assigned = false;
let ts = message_timestamp(tags.as_ref());
let now = Instant::now();
for buf_id in &affected {
state.update_nick(buf_id, &old_nick, new_nick);
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buf_id.clone(),
kind: crate::web::protocol::NickEventKind::NickChange,
nick: old_nick.clone(),
new_nick: Some(new_nick.to_string()),
prefix: None,
modes: None,
away: None,
message: None,
});
if state.flood_protection
&& old_nick != our_nick
&& state.flood_state.should_suppress_nick_flood(buf_id, now)
{
continue;
}
let is_primary = !primary_assigned;
if is_primary {
primary_assigned = true;
}
let id = state.next_message_id();
state.add_message(
buf_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("nick_change".to_string()),
event_params: Some(vec![old_nick.clone(), new_nick.to_string()]),
log_msg_id: if is_primary {
Some(primary_msg_id.clone())
} else {
None
},
log_ref_id: if is_primary {
None
} else {
Some(primary_msg_id.clone())
},
tags: tags.clone(),
},
);
}
rename_query_buffers(state, conn_id, new_nick, &affected);
}
#[expect(clippy::too_many_arguments, reason = "IRC KICK has many parameters")]
fn handle_kick(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
channel: &str,
kicked_user: &str,
reason: Option<&str>,
tags: Option<HashMap<String, String>>,
) {
let (kicker, kicker_ident, kicker_host) = extract_nick_userhost(prefix);
let buffer_id = make_buffer_id(conn_id, channel);
let reason_str = reason.unwrap_or("");
if kicked_user != our_nick
&& should_ignore(
&state.ignores,
&kicker,
Some(&kicker_ident),
Some(&kicker_host),
&IgnoreLevel::Kicks,
Some(channel),
)
{
state.remove_nick(&buffer_id, kicked_user);
return;
}
let ts = message_timestamp(tags.as_ref());
if kicked_user == our_nick {
let text = format!("You were kicked from {channel} by {kicker} ({reason_str})");
let server_buffer_id = state.connections.get(conn_id).map_or_else(
|| make_buffer_id(conn_id, conn_id),
|c| make_buffer_id(conn_id, &c.label),
);
let kick_params = Some(vec![
our_nick.to_string(),
kicker,
channel.to_string(),
reason_str.to_string(),
]);
let make_kick_msg = |state: &mut AppState,
t: String,
p: Option<Vec<String>>,
tg: Option<HashMap<String, String>>|
-> Message {
let id = state.next_message_id();
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: t,
highlight: true,
event_key: Some("kicked".to_string()),
event_params: p,
log_msg_id: None,
log_ref_id: None,
tags: tg,
}
};
let msg = make_kick_msg(state, text.clone(), kick_params.clone(), tags.clone());
state.add_message(&server_buffer_id, msg);
let msg = make_kick_msg(state, text.clone(), kick_params.clone(), tags.clone());
state.add_message(&buffer_id, msg);
state.remove_buffer(&buffer_id);
let landing_id = state
.active_buffer_id
.clone()
.unwrap_or_else(|| server_buffer_id.clone());
if landing_id != server_buffer_id {
let msg = make_kick_msg(state, text, kick_params, tags);
state.add_message(&landing_id, msg);
}
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.silent_who_channels.remove(channel);
}
} else {
state.remove_nick(&buffer_id, kicked_user);
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{kicked_user} was kicked by {kicker} ({reason_str})"),
highlight: false,
event_key: Some("kick".to_string()),
event_params: Some(vec![
kicked_user.to_string(),
kicker,
channel.to_string(),
reason_str.to_string(),
]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
}
fn handle_topic(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
channel: &str,
topic: Option<&str>,
tags: Option<HashMap<String, String>>,
) {
let nick = extract_nick(prefix);
let buffer_id = make_buffer_id(conn_id, channel);
if let Some(topic_text) = topic {
state.set_topic(&buffer_id, topic_text.to_string(), nick.clone());
state
.pending_web_events
.push(crate::web::protocol::WebEvent::TopicChanged {
buffer_id: buffer_id.clone(),
topic: Some(topic_text.to_string()),
set_by: nick.clone(),
});
let setter = nick.unwrap_or_default();
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{setter} changed the topic to: {topic_text}"),
highlight: false,
event_key: Some("topic_changed".to_string()),
event_params: Some(vec![setter, topic_text.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
}
fn handle_mode(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
target: &str,
raw_msg: &IrcMessage,
tags: Option<HashMap<String, String>>,
) {
let nick = extract_nick(prefix).unwrap_or_else(|| "server".to_string());
let mode_display = match &raw_msg.command {
Command::ChannelMODE(_, modes) => {
let buffer_id = make_buffer_id(conn_id, target);
for mode in modes {
apply_channel_mode(state, &buffer_id, mode);
}
build_channel_mode_string(modes)
}
Command::UserMODE(_, modes) => {
if let Some(conn) = state.connections.get_mut(conn_id) {
for mode in modes {
let (adding, m) = match mode {
irc::proto::Mode::Plus(m, _) | irc::proto::Mode::NoPrefix(m) => (true, m),
irc::proto::Mode::Minus(m, _) => (false, m),
};
let c = user_mode_letter(m);
if adding {
if !conn.user_modes.contains(c) {
conn.user_modes.push(c);
}
} else {
conn.user_modes = conn.user_modes.replace(c, "");
}
}
}
build_user_mode_string(modes)
}
_ => String::new(),
};
let ts = message_timestamp(tags.as_ref());
if is_channel(target) {
let buffer_id = make_buffer_id(conn_id, target);
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{nick} sets mode {mode_display} on {target}"),
highlight: false,
event_key: Some("mode".to_string()),
event_params: Some(vec![nick, mode_display, target.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
} else {
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
let server_buf = make_buffer_id(conn_id, label);
let id = state.next_message_id();
state.add_message(
&server_buf,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{nick} sets mode {mode_display} on {target}"),
highlight: false,
event_key: Some("mode".to_string()),
event_params: Some(vec![nick, mode_display, target.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
}
fn apply_channel_mode(
state: &mut AppState,
buffer_id: &str,
mode: &irc::proto::Mode<irc::proto::ChannelMode>,
) {
use irc::proto::ChannelMode;
let (adding, mode_enum, param) = match mode {
irc::proto::Mode::Plus(m, p) => (true, m, p.as_deref()),
irc::proto::Mode::Minus(m, p) => (false, m, p.as_deref()),
irc::proto::Mode::NoPrefix(_) => return,
};
let nick_mode_char = match mode_enum {
ChannelMode::Founder => Some('q'),
ChannelMode::Admin => Some('a'),
ChannelMode::Oper => Some('o'),
ChannelMode::Halfop => Some('h'),
ChannelMode::Voice => Some('v'),
_ => None,
};
if let Some(mc) = nick_mode_char
&& let Some(target_nick) = param
&& let Some(buf) = state.buffers.get_mut(buffer_id)
&& let Some(entry) = buf.users.get_mut(&target_nick.to_lowercase())
{
if adding && !entry.modes.contains(mc) {
entry.modes.push(mc);
} else if !adding {
entry.modes = entry.modes.replace(mc, "");
}
entry.prefix = modes_to_prefix(&entry.modes, "~&@%+");
let new_prefix = entry.prefix.clone();
let new_modes = entry.modes.clone();
state
.pending_web_events
.push(crate::web::protocol::WebEvent::NickEvent {
buffer_id: buffer_id.to_string(),
kind: crate::web::protocol::NickEventKind::ModeChange,
nick: target_nick.to_string(),
new_nick: None,
prefix: Some(new_prefix),
modes: Some(new_modes),
away: None,
message: None,
});
return;
}
let ch = channel_mode_letter(mode_enum);
let is_list_mode = matches!(
mode_enum,
ChannelMode::Ban | ChannelMode::Exception | ChannelMode::InviteException
);
if is_list_mode || nick_mode_char.is_some() {
return;
}
if let Some(buf) = state.buffers.get_mut(buffer_id) {
let modes = buf.modes.get_or_insert_with(String::new);
if adding {
if !modes.contains(ch) {
modes.push(ch);
}
if matches!(ch, 'k' | 'l')
&& let Some(val) = param
{
buf.mode_params
.get_or_insert_with(HashMap::new)
.insert(ch.to_string(), val.to_string());
}
} else {
*modes = modes.replace(ch, "");
if let Some(ref mut mp) = buf.mode_params {
mp.remove(&ch.to_string());
}
}
if modes.starts_with('+') {
*modes = modes[1..].to_string();
}
}
}
fn build_channel_mode_string(modes: &[irc::proto::Mode<irc::proto::ChannelMode>]) -> String {
let mut result = String::new();
let mut params = Vec::new();
let mut last_sign = ' ';
for mode in modes {
let (sign, m, param) = match mode {
irc::proto::Mode::Plus(m, p) => ('+', m, p.as_deref()),
irc::proto::Mode::Minus(m, p) => ('-', m, p.as_deref()),
irc::proto::Mode::NoPrefix(m) => (' ', m, None),
};
if sign != last_sign && sign != ' ' {
result.push(sign);
last_sign = sign;
}
result.push(channel_mode_letter(m));
if let Some(p) = param {
params.push(p);
}
}
if !params.is_empty() {
result.push(' ');
result.push_str(¶ms.join(" "));
}
result
}
fn build_user_mode_string(modes: &[irc::proto::Mode<irc::proto::UserMode>]) -> String {
let mut result = String::new();
let mut last_sign = ' ';
for mode in modes {
let (sign, m) = match mode {
irc::proto::Mode::Plus(m, _) => ('+', m),
irc::proto::Mode::Minus(m, _) => ('-', m),
irc::proto::Mode::NoPrefix(m) => (' ', m),
};
if sign != last_sign && sign != ' ' {
result.push(sign);
last_sign = sign;
}
result.push(user_mode_letter(m));
}
result
}
const fn channel_mode_letter(m: &irc::proto::ChannelMode) -> char {
use irc::proto::ChannelMode;
match m {
ChannelMode::Ban => 'b',
ChannelMode::Exception => 'e',
ChannelMode::Limit => 'l',
ChannelMode::InviteOnly => 'i',
ChannelMode::InviteException => 'I',
ChannelMode::Key => 'k',
ChannelMode::Moderated => 'm',
ChannelMode::RegisteredOnly => 'R',
ChannelMode::Secret => 's',
ChannelMode::ProtectedTopic => 't',
ChannelMode::NoExternalMessages => 'n',
ChannelMode::Founder => 'q',
ChannelMode::Admin => 'a',
ChannelMode::Oper => 'o',
ChannelMode::Halfop => 'h',
ChannelMode::Voice => 'v',
ChannelMode::Unknown(c) => *c,
}
}
const fn user_mode_letter(m: &irc::proto::UserMode) -> char {
use irc::proto::UserMode;
match m {
UserMode::Away => 'a',
UserMode::Invisible => 'i',
UserMode::Wallops => 'w',
UserMode::Restricted => 'r',
UserMode::Oper => 'o',
UserMode::LocalOper => 'O',
UserMode::ServerNotices => 's',
UserMode::MaskedHost => 'x',
UserMode::Unknown(c) => *c,
}
}
fn handle_invite(
state: &mut AppState,
conn_id: &str,
our_nick: &str,
prefix: Option<&Prefix>,
nick: &str,
channel: &str,
tags: Option<HashMap<String, String>>,
) {
let inviter = extract_nick(prefix).unwrap_or_default();
if nick.eq_ignore_ascii_case(our_nick) {
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
let buffer_id = state
.active_buffer_id
.clone()
.unwrap_or_else(|| make_buffer_id(conn_id, label));
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{inviter} invites you to {channel}"),
highlight: true,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags,
},
);
} else {
let buffer_id = make_buffer_id(conn_id, channel);
if state.buffers.contains_key(&buffer_id) {
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: message_timestamp(tags.as_ref()),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: format!("{inviter} invited {nick} to {channel}"),
highlight: false,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags,
},
);
}
}
}
fn handle_error(state: &mut AppState, conn_id: &str, message: &str) {
tracing::warn!("ERROR from {conn_id}: {message}");
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.status = ConnectionStatus::Error;
conn.error = Some(message.to_string());
}
let buf = server_buffer(state, conn_id);
emit(state, &buf, &format!("%Zff4444ERROR: {message}%N"));
}
fn handle_wallops(state: &mut AppState, conn_id: &str, prefix: Option<&Prefix>, text: &str) {
let from = extract_nick(prefix).unwrap_or_else(|| "server".to_string());
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
let buffer_id = make_buffer_id(conn_id, label);
emit(
state,
&buffer_id,
&format!("%Ze0af68[Wallops/{from}]%N {text}"),
);
}
#[expect(clippy::too_many_lines, reason = "dispatcher pattern")]
fn handle_response(state: &mut AppState, conn_id: &str, response: Response, args: &[String]) {
match response {
Response::RPL_ISUPPORT => {
if args.len() >= 2 {
let tokens = &args[1..args.len().saturating_sub(1)];
let token_strs: Vec<&str> = tokens.iter().map(String::as_str).collect();
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.isupport_parsed.parse_tokens(&token_strs);
}
if let Some(network) = state
.connections
.get(conn_id)
.and_then(|c| c.isupport_parsed.network().map(str::to_owned))
{
update_label_from_network(state, conn_id, &network);
}
}
}
Response::RPL_NAMREPLY => {
if args.len() >= 4 {
let channel = &args[2];
let buffer_id = make_buffer_id(conn_id, channel);
let nicks_str = &args[3];
let (prefix_map, has_userhost) = state
.connections
.get(conn_id)
.map_or_else(
|| (vec![('o', '@'), ('v', '+')], false),
|c| (c.isupport_parsed.prefix_map(), c.enabled_caps.contains("userhost-in-names")),
);
for nick_with_prefix in nicks_str.split_whitespace() {
let entry = parse_names_entry(nick_with_prefix, &prefix_map, has_userhost);
state.add_nick(&buffer_id, entry);
}
}
}
Response::RPL_TOPIC => {
if args.len() >= 3 {
let channel = &args[1];
let topic = &args[2];
let buffer_id = make_buffer_id(conn_id, channel);
state.set_topic(&buffer_id, topic.clone(), None);
}
}
Response::RPL_TOPICWHOTIME => {
if args.len() >= 3 {
let channel = &args[1];
let set_by = &args[2];
let buffer_id = make_buffer_id(conn_id, channel);
if let Some(buf) = state.buffers.get_mut(&buffer_id) {
buf.topic_set_by = Some(set_by.clone());
}
}
}
Response::RPL_CHANNELMODEIS => {
if args.len() >= 3 {
let channel = &args[1];
let mode_str = args[2].strip_prefix('+').unwrap_or(&args[2]);
let buffer_id = make_buffer_id(conn_id, channel);
if let Some(buf) = state.buffers.get_mut(&buffer_id) {
buf.modes = Some(mode_str.to_string());
let mut param_idx = 3;
let mut params = HashMap::new();
for ch in mode_str.chars() {
if matches!(ch, 'k' | 'l')
&& let Some(val) = args.get(param_idx)
{
params.insert(ch.to_string(), val.clone());
param_idx += 1;
}
}
if params.is_empty() {
buf.mode_params = None;
} else {
buf.mode_params = Some(params);
}
}
}
}
Response::RPL_WHOISUSER => {
if args.len() >= 6 {
let target_buf = whois_buffer(state, conn_id);
emit(state, &target_buf, &format!(
"%Z7aa2f7───── WHOIS {} ──────────────────────────%N", args[1]
));
emit(state, &target_buf, &format!(
"%Zc0caf5{}%Z565f89 ({}@{})%N", args[1], args[2], args[3]
));
if args.len() >= 6 && !args[5].is_empty() {
emit(state, &target_buf, &format!(" %Za9b1d6{}%N", args[5]));
}
}
}
Response::RPL_WHOISSERVER => {
if args.len() >= 4 {
let target_buf = whois_buffer(state, conn_id);
let info = if args[3].is_empty() {
String::new()
} else {
format!(" ({})", args[3])
};
emit(state, &target_buf, &format!(
"%Z565f89 server: %Za9b1d6{}{info}%N", args[2]
));
}
}
Response::RPL_WHOISOPERATOR => {
if args.len() >= 3 {
let target_buf = whois_buffer(state, conn_id);
emit(state, &target_buf, &format!(" %Zbb9af7{}%N", args[2]));
}
}
Response::RPL_WHOISIDLE => {
if args.len() >= 3 {
let target_buf = whois_buffer(state, conn_id);
let idle = args[2].parse::<u64>().unwrap_or(0);
let mut line = format!("%Z565f89 idle: %Za9b1d6{}", format_duration(idle));
if args.len() >= 4
&& let Ok(ts) = args[3].parse::<i64>()
{
let dt = chrono::DateTime::from_timestamp(ts, 0)
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default();
let _ = write!(line, "%Z565f89, signon: %Za9b1d6{dt}");
}
line.push_str("%N");
emit(state, &target_buf, &line);
}
}
Response::RPL_WHOISCHANNELS => {
if args.len() >= 3 {
let target_buf = whois_buffer(state, conn_id);
emit(state, &target_buf, &format!(
"%Z565f89 channels: %Za9b1d6{}%N", args[2]
));
}
}
Response::RPL_ENDOFWHOIS => {
let target_buf = whois_buffer(state, conn_id);
emit(state, &target_buf,
"%Z7aa2f7─────────────────────────────────────────────%N");
}
Response::RPL_AWAY => {
if args.len() >= 3 {
let target_buf = whois_buffer(state, conn_id);
emit(state, &target_buf, &format!(
"%Z565f89 away: %Ze0af68{}%N", args[2]
));
}
}
Response::RPL_BANLIST => {
if args.len() >= 3 {
let channel = &args[1];
let mask = &args[2];
let set_by = args.get(3).cloned().unwrap_or_default();
let set_at = args.get(4).and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
let buf_id = crate::state::buffer::make_buffer_id(conn_id, channel);
if let Some(buf) = state.buffers.get_mut(&buf_id) {
let entries = buf.list_modes.entry("b".to_string()).or_default();
entries.push(crate::state::buffer::ListEntry {
mask: mask.clone(),
set_by: set_by.clone(),
set_at,
});
if entries.len() > MAX_LIST_MODE_ENTRIES {
entries.drain(..entries.len() - MAX_LIST_MODE_ENTRIES);
}
}
let index = state.buffers.get(&buf_id)
.and_then(|b| b.list_modes.get("b"))
.map_or(0, Vec::len);
let target_buf = active_or_server_buffer(state, conn_id);
let set_info = if set_by.is_empty() {
String::new()
} else {
format!(" (set by {} {})", set_by, format_timestamp(args.get(4).map_or("0", |s| s.as_str())))
};
let extban_prefix = state.connections.get(conn_id)
.and_then(|c| c.isupport_parsed.extban())
.map(|(prefix, _)| prefix);
let mask_display = crate::irc::extban::format_ban_mask(mask, extban_prefix);
emit(state, &target_buf, &format!(
"%Z565f89 {index}. %Za9b1d6{mask_display}{set_info}%N"
));
}
}
Response::RPL_ENDOFBANLIST => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89 End of ban list%N");
}
Response::RPL_EXCEPTLIST => {
if args.len() >= 3 {
let target_buf = active_or_server_buffer(state, conn_id);
let set_info = if args.len() >= 5 {
format!(" (set by {} {})", args[3], format_timestamp(&args[4]))
} else {
String::new()
};
let extban_prefix = state.connections.get(conn_id)
.and_then(|c| c.isupport_parsed.extban())
.map(|(prefix, _)| prefix);
let mask_display = crate::irc::extban::format_ban_mask(&args[2], extban_prefix);
emit(state, &target_buf, &format!(
"%Z565f89 except: %Za9b1d6{mask_display}{set_info}%N"
));
}
}
Response::RPL_ENDOFEXCEPTLIST => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89 End of exception list%N");
}
Response::RPL_INVITELIST => {
if args.len() >= 3 {
let target_buf = active_or_server_buffer(state, conn_id);
let set_info = if args.len() >= 5 {
format!(" (set by {} {})", args[3], format_timestamp(&args[4]))
} else {
String::new()
};
let extban_prefix = state.connections.get(conn_id)
.and_then(|c| c.isupport_parsed.extban())
.map(|(prefix, _)| prefix);
let mask_display = crate::irc::extban::format_ban_mask(&args[2], extban_prefix);
emit(state, &target_buf, &format!(
"%Z565f89 invex: %Za9b1d6{mask_display}{set_info}%N"
));
}
}
Response::RPL_ENDOFINVITELIST => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89 End of invite exception list%N");
}
Response::RPL_MOTDSTART => {
let target_buf = server_buffer(state, conn_id);
emit(state, &target_buf, "%Z56b6c2── MOTD ──────────────────────────────────────%N");
}
Response::RPL_MOTD => {
if args.len() >= 2 {
let target_buf = server_buffer(state, conn_id);
let line = &args[args.len() - 1];
emit(state, &target_buf, &format!("%Z7aa2f7{line}%N"));
}
}
Response::RPL_ENDOFMOTD => {
let target_buf = server_buffer(state, conn_id);
emit(state, &target_buf, "%Z56b6c2── End of MOTD ─────────────────────────────%N");
}
Response::ERR_NICKNAMEINUSE => {
let attempted = if args.len() >= 2 { &args[1] } else { "unknown" };
let target_buf = server_buffer(state, conn_id);
emit(
state,
&target_buf,
&format!("%Ze0af68Nick {attempted} is already in use%N"),
);
}
Response::ERR_ERRONEOUSNICKNAME => {
let attempted = if args.len() >= 2 { &args[1] } else { "unknown" };
let reason = if args.len() >= 3 { &args[2] } else { "Erroneous nickname" };
let target_buf = server_buffer(state, conn_id);
emit(
state,
&target_buf,
&format!("%Zff6b6bErroneous nick {attempted}: {reason}%N"),
);
}
Response::ERR_CHANNELISFULL | Response::ERR_INVITEONLYCHAN | Response::ERR_BANNEDFROMCHAN | Response::ERR_BADCHANNELKEY | Response::ERR_TOOMANYCHANNELS => {
let channel = if args.len() >= 2 { &args[1] } else { "?" };
let reason = if args.len() >= 3 { &args[2] } else { "Cannot join channel" };
let buffer_id = make_buffer_id(conn_id, channel);
let target_buf = server_buffer(state, conn_id);
emit(
state,
&target_buf,
&format!("%Zff6b6bCannot join {channel}: {reason}%N"),
);
let should_remove = state
.buffers
.get(&buffer_id)
.is_some_and(|buf| buf.users.is_empty());
if should_remove {
state.remove_buffer(&buffer_id);
}
}
Response::RPL_NOWAWAY => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z56b6c2You are now marked as away%N");
}
Response::RPL_UNAWAY => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z56b6c2You are no longer marked as away%N");
}
Response::RPL_LIST => {
if args.len() >= 3 {
let channel = &args[1];
let user_count = &args[2];
let topic = if args.len() >= 4 { &args[3] } else { "" };
let target_buf = active_or_server_buffer(state, conn_id);
if topic.is_empty() {
emit(state, &target_buf, &format!(
"%Zc0caf5{channel}%Z565f89 [{user_count} users]%N"
));
} else {
emit(state, &target_buf, &format!(
"%Zc0caf5{channel}%Z565f89 [{user_count} users]%N: {topic}"
));
}
}
}
Response::RPL_LISTEND => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89End of channel list%N");
}
Response::RPL_WHOREPLY => {
if args.len() >= 8 {
let channel = &args[1];
let silent = state
.connections
.get(conn_id)
.is_some_and(|c| c.silent_who_channels.contains(channel.as_str()));
if !silent {
let user = &args[2];
let host = &args[3];
let nick = &args[5];
let flags = &args[6];
let realname = &args[7];
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, &format!(
"%Zc0caf5{nick}%Z565f89 ({user}@{host}) [{flags}] {channel}%Za9b1d6 {realname}%N"
));
}
}
}
Response::RPL_ENDOFWHO => {
let target = args.get(1).map_or("", String::as_str);
let was_silent = if let Some(conn) = state.connections.get_mut(conn_id) {
if target.contains(',') {
let mut any_silent = false;
for ch in target.split(',') {
any_silent |= conn.silent_who_channels.remove(ch);
}
any_silent
} else {
conn.silent_who_channels.remove(target)
}
} else {
false
};
if !was_silent {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89End of WHO list%N");
}
}
Response::RPL_USERHOST => {
handle_userhost_reply(state, conn_id, args);
}
Response::RPL_WHOWASUSER => {
if args.len() >= 6 {
let nick = &args[1];
let user = &args[2];
let host = &args[3];
let realname = &args[5];
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, &format!(
"%Zc0caf5{nick}%Z565f89 was ({user}@{host})%Za9b1d6 {realname}%N"
));
}
}
Response::RPL_ENDOFWHOWAS => {
let target_buf = active_or_server_buffer(state, conn_id);
emit(state, &target_buf, "%Z565f89End of WHOWAS%N");
}
Response::RPL_ENDOFNAMES => {}
_ => {
let buffer_id = if response.is_error() {
active_or_server_buffer(state, conn_id)
} else {
server_buffer(state, conn_id)
};
let text = if args.len() > 1 {
args[1..].join(" ")
} else {
args.join(" ")
};
let id = state.next_message_id();
state.add_message(
&buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: None,
event_params: None, log_msg_id: None, log_ref_id: None,
tags: None,
},
);
}
}
}
fn update_label_from_network(state: &mut AppState, conn_id: &str, network_name: &str) {
let current_label = match state.connections.get(conn_id) {
Some(conn) => conn.label.clone(),
None => return,
};
if !current_label.contains('.') {
return;
}
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.label = network_name.to_string();
}
let old_buf_id = make_buffer_id(conn_id, ¤t_label);
let new_buf_id = make_buffer_id(conn_id, network_name);
if let Some(mut buf) = state.buffers.shift_remove(&old_buf_id) {
buf.id.clone_from(&new_buf_id);
buf.name = network_name.to_string();
state.buffers.insert(new_buf_id.clone(), buf);
if state.active_buffer_id.as_deref() == Some(&old_buf_id) {
state.active_buffer_id = Some(new_buf_id);
}
}
}
pub fn emit(state: &mut AppState, buffer_id: &str, text: &str) {
let id = state.next_message_id();
state.add_message(
buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.to_string(),
highlight: false,
event_key: None,
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
fn server_buffer(state: &AppState, conn_id: &str) -> String {
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
make_buffer_id(conn_id, label)
}
fn active_or_server_buffer(state: &AppState, conn_id: &str) -> String {
state.active_buffer_id.as_deref().map_or_else(
|| {
let label = state
.connections
.get(conn_id)
.map_or("Status", |c| c.label.as_str());
make_buffer_id(conn_id, label)
},
str::to_owned,
)
}
fn whois_buffer(state: &AppState, conn_id: &str) -> String {
active_or_server_buffer(state, conn_id)
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else if secs < 86400 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
}
}
fn format_timestamp(ts_str: &str) -> String {
ts_str
.parse::<i64>()
.ok()
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default()
}
fn parse_names_entry(raw: &str, prefix_map: &[(char, char)], has_userhost: bool) -> NickEntry {
let mut prefix = String::new();
let mut modes = String::new();
let mut rest = raw;
while let Some(c) = rest.chars().next() {
if let Some(&(mode, _)) = prefix_map.iter().find(|&&(_, p)| p == c) {
prefix.push(c);
modes.push(mode);
rest = &rest[c.len_utf8()..];
} else {
break;
}
}
let (nick, ident, host) = if has_userhost {
parse_userhost(rest)
} else {
(rest.to_string(), None, None)
};
NickEntry {
nick,
prefix,
modes,
away: false,
account: None,
ident,
host,
}
}
fn parse_userhost(input: &str) -> (String, Option<String>, Option<String>) {
if let Some(bang_pos) = input.find('!') {
let nick = &input[..bang_pos];
let rest = &input[bang_pos + 1..];
if let Some(at_pos) = rest.find('@') {
let ident = &rest[..at_pos];
let host = &rest[at_pos + 1..];
return (
nick.to_string(),
Some(ident.to_string()),
Some(host.to_string()),
);
}
}
(input.to_string(), None, None)
}
pub fn next_who_token(state: &mut AppState, conn_id: &str) -> String {
if let Some(conn) = state.connections.get_mut(conn_id) {
conn.who_token_counter = conn.who_token_counter.wrapping_add(1);
conn.who_token_counter.to_string()
} else {
"0".to_string()
}
}
pub fn build_whox_who(
state: &mut AppState,
conn_id: &str,
channel: &str,
silent: bool,
) -> Option<(String, String)> {
let has_whox = state
.connections
.get(conn_id)
.is_some_and(|c| c.isupport_parsed.has_whox());
if has_whox {
let token = next_who_token(state, conn_id);
if silent && let Some(conn) = state.connections.get_mut(conn_id) {
conn.silent_who_channels.insert(channel.to_string());
}
let fields = format!("{},{token}", crate::constants::WHOX_FIELDS);
Some((channel.to_string(), fields))
} else {
None
}
}
fn handle_whox_reply(state: &mut AppState, conn_id: &str, args: &[String]) {
tracing::trace!(conn_id, args_len = args.len(), ?args, "handle_whox_reply");
if args.len() < 10 {
tracing::warn!(
conn_id,
args_len = args.len(),
"WHOX reply too short, skipping"
);
return;
}
let channel = &args[2];
let user = &args[3];
let host = &args[5];
let nick = &args[6];
let flags = &args[7];
let account_raw = &args[8];
let realname = &args[9];
let silent = state
.connections
.get(conn_id)
.is_some_and(|c| c.silent_who_channels.contains(channel.as_str()));
let away = flags.starts_with('G');
let account = (account_raw != "0").then(|| account_raw.clone());
let buffer_id = make_buffer_id(conn_id, channel);
if let Some(buf) = state.buffers.get_mut(&buffer_id)
&& let Some(entry) = buf.users.get_mut(&nick.to_lowercase())
{
tracing::trace!(%nick, %channel, %away, ?account, "WHOX: updating nick entry");
entry.ident = Some(user.clone());
entry.host = Some(host.clone());
entry.account.clone_from(&account);
entry.away = away;
} else {
tracing::warn!(%nick, %channel, %buffer_id, "WHOX: buffer or nick not found for update");
}
if !silent {
let target_buf = active_or_server_buffer(state, conn_id);
let account_str = account.as_deref().unwrap_or("");
emit(
state,
&target_buf,
&format!(
"%Zc0caf5{nick}%Z565f89 ({user}@{host}) [{flags}] {channel}%Za9b1d6 {realname}%Z565f89 [{account_str}]%N"
),
);
}
}
fn try_decrypt_e2e(
state: &mut AppState,
conn_id: &str,
sender_nick: &str,
sender_handle: &str,
channel: &str,
text: &str,
is_own: bool,
) -> Option<String> {
let mgr = state.e2e_manager.clone()?;
if !text.starts_with("+RPE2E01") {
return None;
}
if is_own {
return Some(String::new());
}
match mgr.decrypt_incoming(sender_handle, channel, text) {
Ok(crate::e2e::manager::DecryptOutcome::Plaintext(s)) => Some(s),
Ok(crate::e2e::manager::DecryptOutcome::MissingKey {
handle,
channel: ch,
}) => {
if mgr.allow_keyreq(&handle) {
match mgr.build_keyreq_for_peer(&ch, Some(&handle)) {
Ok(req) => {
let ctcp = mgr.encode_keyreq_ctcp(&req);
state.pending_e2e_sends.push(crate::state::PendingE2eSend {
connection_id: conn_id.to_string(),
target: sender_nick.to_string(),
notice_text: ctcp,
});
}
Err(e) => tracing::warn!("build_keyreq failed for {ch}: {e}"),
}
}
Some(format!("[E2E: awaiting session with {handle}]"))
}
Ok(crate::e2e::manager::DecryptOutcome::Rejected(reason)) => {
Some(format!("[E2E rejected: {reason}]"))
}
Err(e) => {
tracing::warn!("e2e decrypt error on {channel}: {e}");
None
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum RpEe2eOutcome {
Handled,
NotE2e,
}
#[expect(
clippy::too_many_lines,
reason = "RPE2E handshake dispatch keeps request/response flow together"
)]
fn try_dispatch_rpe2e_ctcp(
state: &mut AppState,
conn_id: &str,
prefix: Option<&Prefix>,
target: &str,
text: &str,
) -> Option<RpEe2eOutcome> {
use crate::e2e::handshake::HandshakeMsg;
let trimmed = text.strip_prefix('\x01').unwrap_or(text);
let inner = trimmed.strip_suffix('\x01').unwrap_or(trimmed);
if !inner.starts_with(crate::e2e::handshake::CTCP_TAG) {
return Some(RpEe2eOutcome::NotE2e);
}
let mgr = state.e2e_manager.clone()?;
let (nick, ident, host) = extract_nick_userhost(prefix);
let sender_handle = format!("{ident}@{host}");
let parsed = match crate::e2e::handshake::parse(inner) {
Ok(Some(msg)) => msg,
Ok(None) => return Some(RpEe2eOutcome::NotE2e),
Err(e) => {
tracing::warn!("rpe2e handshake parse error: {e}");
emit_e2e_debug(
state,
conn_id,
None,
format!("[E2E debug] RX handshake parse error from {sender_handle}: {e}"),
);
return Some(RpEe2eOutcome::Handled); }
};
let _ = target;
match parsed {
HandshakeMsg::Req(req) => {
emit_e2e_debug(
state,
conn_id,
Some(&req.channel),
format!(
"[E2E debug] RX KEYREQ from {nick} ({sender_handle}) for {}",
req.channel
),
);
let result = mgr.handle_keyreq_with_nick(&sender_handle, Some(&nick), &req);
surface_pending_trust_changes(state, conn_id, &mgr);
surface_pending_accept_requests(state, conn_id, &mgr);
match result {
Ok(Some(rsp)) => {
let body = mgr.encode_keyrsp_ctcp(&rsp);
state.pending_e2e_sends.push(crate::state::PendingE2eSend {
connection_id: conn_id.to_string(),
target: nick.clone(),
notice_text: body,
});
emit_e2e_debug(
state,
conn_id,
Some(&req.channel),
format!("[E2E debug] queued KEYRSP to {nick} for {}", req.channel),
);
for out in mgr.take_pending_outbound_keyreqs() {
let ctcp = mgr.encode_keyreq_ctcp(&out.req);
state.pending_e2e_sends.push(crate::state::PendingE2eSend {
connection_id: conn_id.to_string(),
target: nick.clone(),
notice_text: ctcp,
});
emit_e2e_debug(
state,
conn_id,
Some(&out.channel),
format!(
"[E2E debug] queued reciprocal KEYREQ to {nick} for {}",
out.channel
),
);
}
Some(RpEe2eOutcome::Handled)
}
Ok(None) => {
emit_e2e_debug(
state,
conn_id,
Some(&req.channel),
format!(
"[E2E debug] KEYREQ from {nick} ({sender_handle}) is pending on {}",
req.channel
),
);
Some(RpEe2eOutcome::Handled)
}
Err(e) => {
tracing::warn!("handle_keyreq error: {e}");
emit_e2e_debug(
state,
conn_id,
Some(&req.channel),
format!(
"[E2E debug] KEYREQ from {nick} ({sender_handle}) failed on {}: {e}",
req.channel
),
);
Some(RpEe2eOutcome::Handled)
}
}
}
HandshakeMsg::Rsp(rsp) => {
emit_e2e_debug(
state,
conn_id,
Some(&rsp.channel),
format!(
"[E2E debug] RX KEYRSP from {nick} ({sender_handle}) for {}",
rsp.channel
),
);
let result = mgr.handle_keyrsp(&sender_handle, &rsp);
surface_pending_trust_changes(state, conn_id, &mgr);
if let Err(e) = result {
tracing::warn!("handle_keyrsp error: {e}");
emit_e2e_debug(
state,
conn_id,
Some(&rsp.channel),
format!(
"[E2E debug] KEYRSP from {nick} ({sender_handle}) failed on {}: {e}",
rsp.channel
),
);
} else {
emit_e2e_debug(
state,
conn_id,
Some(&rsp.channel),
format!(
"[E2E debug] KEYRSP from {nick} ({sender_handle}) installed session on {}",
rsp.channel
),
);
}
Some(RpEe2eOutcome::Handled)
}
HandshakeMsg::Rekey(rekey) => {
emit_e2e_debug(
state,
conn_id,
Some(&rekey.channel),
format!(
"[E2E debug] RX REKEY from {nick} ({sender_handle}) for {}",
rekey.channel
),
);
let result = mgr.handle_rekey(&sender_handle, &rekey);
surface_pending_trust_changes(state, conn_id, &mgr);
if let Err(e) = result {
tracing::warn!("handle_rekey error: {e}");
emit_e2e_debug(
state,
conn_id,
Some(&rekey.channel),
format!(
"[E2E debug] REKEY from {nick} ({sender_handle}) failed on {}: {e}",
rekey.channel
),
);
} else {
emit_e2e_debug(
state,
conn_id,
Some(&rekey.channel),
format!(
"[E2E debug] REKEY from {nick} ({sender_handle}) applied on {}",
rekey.channel
),
);
}
Some(RpEe2eOutcome::Handled)
}
}
}
fn e2e_debug_enabled() -> bool {
std::env::var("REPARTEE_E2E_DEBUG_BUFFER").is_ok_and(|v| {
let v = v.trim();
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
})
}
fn emit_e2e_debug(
state: &mut AppState,
conn_id: &str,
channel: Option<&str>,
text: impl Into<String>,
) {
if !e2e_debug_enabled() {
return;
}
let text = text.into();
let target_buffer = channel
.map(|channel| make_buffer_id(conn_id, channel))
.filter(|id| state.buffers.contains_key(id))
.unwrap_or_else(|| active_or_server_buffer(state, conn_id));
let id = state.next_message_id();
let event_param = text.clone();
state.add_message(
&target_buffer,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: false,
event_key: Some("e2e_info".to_string()),
event_params: Some(vec![event_param]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
fn emit_e2e_message(
state: &mut AppState,
buffer_id: &str,
event_key: &str,
highlight: bool,
text: String,
) {
let id = state.next_message_id();
state.add_message(
buffer_id,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight,
event_key: Some(event_key.to_string()),
event_params: Some(vec![text]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
fn parse_userhost_reply(entry: &str) -> Option<(String, String)> {
let (nick_part, userhost_part) = entry.split_once('=')?;
let nick = nick_part.trim_end_matches('*');
let userhost = userhost_part
.strip_prefix('+')
.or_else(|| userhost_part.strip_prefix('-'))
.unwrap_or(userhost_part);
let (ident, host) = userhost.split_once('@')?;
Some((nick.to_string(), format!("{ident}@{host}")))
}
fn handle_userhost_reply(state: &mut AppState, conn_id: &str, args: &[String]) {
if state.pending_userhost_requests.is_empty() || args.len() < 2 {
return;
}
let replies = args[1..].join(" ");
for entry in replies.split_whitespace() {
let Some((nick, handle)) = parse_userhost_reply(entry) else {
continue;
};
let mut idx = 0usize;
while idx < state.pending_userhost_requests.len() {
let req = &state.pending_userhost_requests[idx];
if req.connection_id != conn_id || !req.nick.eq_ignore_ascii_case(&nick) {
idx += 1;
continue;
}
let req = state.pending_userhost_requests.remove(idx);
match req.action {
crate::state::PendingUserhostAction::E2eForget {
buffer_id,
target,
channel,
all,
} => {
let Some(mgr) = state.e2e_manager.clone() else {
emit_e2e_message(
state,
&buffer_id,
"e2e_error",
true,
"USERHOST resolved but E2E is disabled".to_string(),
);
continue;
};
let result = if all {
mgr.forget_peer_everywhere(&handle)
} else if let Some(channel) = channel.as_deref() {
mgr.forget_peer_on_channel(&handle, channel)
} else {
emit_e2e_message(
state,
&buffer_id,
"e2e_error",
true,
format!("/e2e forget: no channel context for {target}"),
);
continue;
};
match result {
Ok(deleted) if all => emit_e2e_message(
state,
&buffer_id,
"e2e_warning",
false,
format!(
"forgot {target} ({handle}) globally — removed {deleted} row(s)"
),
),
Ok(deleted) => emit_e2e_message(
state,
&buffer_id,
"e2e_warning",
false,
format!(
"forgot {target} ({handle}) on {} — removed {deleted} row(s)",
channel.unwrap_or_default()
),
),
Err(e) => emit_e2e_message(
state,
&buffer_id,
"e2e_error",
true,
format!("/e2e forget: {e}"),
),
}
}
}
}
}
}
fn surface_pending_trust_changes(
state: &mut AppState,
conn_id: &str,
mgr: &crate::e2e::E2eManager,
) {
use crate::e2e::manager::TrustChange;
let notices = mgr.take_pending_trust_changes();
if notices.is_empty() {
return;
}
for notice in notices {
let target_buffer = if notice.channel.is_empty() {
active_or_server_buffer(state, conn_id)
} else {
let cand = make_buffer_id(conn_id, ¬ice.channel);
if state.buffers.contains_key(&cand) {
cand
} else {
active_or_server_buffer(state, conn_id)
}
};
let (text, event_key) = match ¬ice.change {
TrustChange::FingerprintChanged {
handle,
old_fp,
new_fp,
} => {
let old_hex = hex::encode(old_fp);
let new_hex = hex::encode(new_fp);
let short_old = &old_hex[..old_hex.len().min(16)];
let short_new = &new_hex[..new_hex.len().min(16)];
(
format!(
"[E2E] WARNING: {handle} identity key has CHANGED\n \
old fp: {short_old}\n \
new fp: {short_new}\n \
run /e2e reverify {handle} to accept the new key"
),
"e2e_error",
)
}
TrustChange::HandleChanged {
old_handle,
new_handle,
fingerprint,
} => {
let fp_hex = hex::encode(fingerprint);
let short = &fp_hex[..fp_hex.len().min(16)];
(
format!(
"[E2E] notice: known key {short} appeared under new handle\n \
old handle: {old_handle}\n \
new handle: {new_handle}\n \
run /e2e reverify {new_handle} to accept"
),
"e2e_warning",
)
}
TrustChange::Revoked {
handle,
fingerprint,
} => {
let fp_hex = hex::encode(fingerprint);
let short = &fp_hex[..fp_hex.len().min(16)];
(
format!(
"[E2E] ERROR: peer {handle} (fp={short}) is REVOKED; \
handshake refused. run /e2e unrevoke {handle} to restore"
),
"e2e_error",
)
}
TrustChange::Known | TrustChange::New => continue,
};
let id = state.next_message_id();
state.add_message(
&target_buffer,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: true,
event_key: Some(event_key.to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
}
fn surface_pending_accept_requests(
state: &mut AppState,
conn_id: &str,
mgr: &crate::e2e::E2eManager,
) {
let requests = mgr.take_pending_accept_requests();
if requests.is_empty() {
return;
}
for req in requests {
let target_buffer = if req.channel.is_empty() {
active_or_server_buffer(state, conn_id)
} else {
let cand = make_buffer_id(conn_id, &req.channel);
if state.buffers.contains_key(&cand) {
cand
} else {
active_or_server_buffer(state, conn_id)
}
};
let text = format!(
"[E2E] Pending key exchange from {who} for {channel}.\n \
Run /e2e accept <nick> or /e2e decline <nick>.",
who = req.nick.as_ref().map_or_else(
|| req.handle.clone(),
|nick| format!("{nick} ({})", req.handle)
),
channel = req.channel,
);
let id = state.next_message_id();
state.add_message(
&target_buffer,
Message {
id,
timestamp: Utc::now(),
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text,
highlight: true,
event_key: Some("e2e_pending_accept".to_string()),
event_params: None,
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::connection::Connection;
use chrono::{Datelike, Timelike};
use irc::proto::Prefix;
use std::collections::HashMap;
fn make_test_state() -> AppState {
let mut state = AppState::new();
state.add_connection(Connection {
id: "test".to_string(),
label: "TestServer".to_string(),
status: ConnectionStatus::Connected,
nick: "me".to_string(),
user_modes: String::new(),
isupport: HashMap::new(),
isupport_parsed: crate::irc::isupport::Isupport::new(),
error: None,
lag: None,
lag_pending: false,
reconnect_attempts: 0,
reconnect_delay_secs: 30,
next_reconnect: None,
should_reconnect: true,
joined_channels: Vec::new(),
origin_config: crate::config::ServerConfig {
label: "TestServer".to_string(),
address: "irc.test.net".to_string(),
port: 6697,
tls: true,
tls_verify: true,
autoconnect: false,
channels: vec![],
nick: None,
username: None,
realname: None,
password: None,
sasl_user: None,
sasl_pass: None,
bind_ip: None,
encoding: None,
auto_reconnect: Some(true),
reconnect_delay: None,
reconnect_max_retries: None,
autosendcmd: None,
sasl_mechanism: None,
client_cert_path: None,
},
local_ip: None,
enabled_caps: std::collections::HashSet::new(),
who_token_counter: 0,
silent_who_channels: std::collections::HashSet::new(),
});
state.add_buffer(Buffer {
id: make_buffer_id("test", "TestServer"),
connection_id: "test".to_string(),
buffer_type: BufferType::Server,
name: "TestServer".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
let chan_id = make_buffer_id("test", "#test");
state.add_buffer(Buffer {
id: chan_id.clone(),
connection_id: "test".to_string(),
buffer_type: BufferType::Channel,
name: "#test".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
state.add_nick(
&chan_id,
NickEntry {
nick: "me".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
state
}
fn make_channel_buffer(conn_id: &str, name: &str) -> Buffer {
Buffer {
id: make_buffer_id(conn_id, name),
connection_id: conn_id.to_string(),
buffer_type: BufferType::Channel,
name: name.to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: std::collections::HashMap::new(),
modes: None,
mode_params: None,
list_modes: std::collections::HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
}
}
fn make_irc_msg(prefix: Option<&str>, command: Command) -> IrcMessage {
IrcMessage {
tags: None,
prefix: prefix.map(Prefix::new_from_str),
command,
}
}
#[test]
fn privmsg_to_channel() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hello".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].text, "hello");
assert_eq!(buf.messages[0].nick.as_deref(), Some("alice"));
assert_eq!(buf.messages[0].message_type, MessageType::Message);
}
#[test]
fn privmsg_pm_creates_query_buffer() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("bob!user@host"),
Command::PRIVMSG("me".into(), "hi there".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/bob").unwrap();
assert_eq!(buf.buffer_type, BufferType::Query);
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].text, "hi there");
}
#[test]
fn privmsg_mention_sets_highlight() {
let mut state = make_test_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hey me, how are you?".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.messages[0].highlight);
assert_eq!(buf.activity, ActivityLevel::Mention);
}
#[test]
fn privmsg_own_message_no_activity() {
let mut state = make_test_state();
state.set_active_buffer("test/testserver"); let msg = make_irc_msg(
Some("me!user@host"),
Command::PRIVMSG("#test".into(), "my own message".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.activity, ActivityLevel::None);
}
#[test]
fn privmsg_ctcp_action() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "\x01ACTION waves\x01".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages[0].message_type, MessageType::Action);
assert_eq!(buf.messages[0].text, "waves");
}
#[test]
fn privmsg_ctcp_action_mention() {
let mut state = make_test_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "\x01ACTION pokes me\x01".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages[0].message_type, MessageType::Action);
assert!(buf.messages[0].highlight);
assert_eq!(buf.activity, ActivityLevel::Mention);
}
#[test]
fn join_our_own_creates_buffer() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("me!user@host"),
Command::JOIN("#newchan".into(), None, None),
);
handle_irc_message(&mut state, "test", &msg);
assert!(state.buffers.contains_key("test/#newchan"));
let buf = state.buffers.get("test/#newchan").unwrap();
assert_eq!(buf.buffer_type, BufferType::Channel);
}
#[test]
fn join_other_user_adds_nick() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("carol!user@host"),
Command::JOIN("#test".into(), None, None),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.users.contains_key("carol"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("carol (user@host) has joined")
);
}
#[test]
fn part_our_own_removes_buffer() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("me!user@host"),
Command::PART("#test".into(), Some("bye".into())),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#test"));
}
#[test]
fn part_other_user_removes_nick() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "dave".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
Some("dave!user@host"),
Command::PART("#test".into(), Some("leaving".into())),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(!buf.users.contains_key("dave"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("dave (user@host) has left")
);
}
#[test]
fn quit_removes_from_all_buffers() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "eve".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(Some("eve!user@host"), Command::QUIT(Some("gone".into())));
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(!buf.users.contains_key("eve"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("eve (user@host) has quit")
);
}
#[test]
fn nick_change_updates_our_nick() {
let mut state = make_test_state();
let msg = make_irc_msg(Some("me!user@host"), Command::NICK("me_".into()));
handle_irc_message(&mut state, "test", &msg);
assert_eq!(state.connections.get("test").unwrap().nick, "me_");
}
#[test]
fn nick_change_other_user() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "frank".to_string(),
prefix: "@".to_string(),
modes: "o".to_string(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(Some("frank!user@host"), Command::NICK("frankie".into()));
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(!buf.users.contains_key("frank"));
assert!(buf.users.contains_key("frankie"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("frank is now known as frankie")
);
}
#[test]
fn kick_our_own_removes_buffer_and_notifies() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("op!user@host"),
Command::KICK("#test".into(), "me".into(), Some("behave".into())),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#test"));
let server_id = make_buffer_id("test", "TestServer");
let server_buf = state.buffers.get(&server_id).unwrap();
let server_msg = server_buf.messages.back().unwrap();
assert!(server_msg.text.contains("You were kicked from #test by op"));
assert!(server_msg.text.contains("behave"));
assert!(server_msg.highlight);
assert_eq!(server_msg.event_key.as_deref(), Some("kicked"));
}
#[test]
fn kick_other_user_removes_nick() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "troll".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
Some("op!user@host"),
Command::KICK("#test".into(), "troll".into(), Some("bye".into())),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(!buf.users.contains_key("troll"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("troll was kicked by op")
);
}
#[test]
fn topic_change_updates_buffer() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("alice!user@host"),
Command::TOPIC("#test".into(), Some("new topic".into())),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.topic.as_deref(), Some("new topic"));
assert_eq!(buf.topic_set_by.as_deref(), Some("alice"));
}
#[test]
fn rpl_namreply_adds_nicks() {
let mut state = make_test_state();
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_NAMREPLY,
vec![
"me".into(),
"=".into(),
"#test".into(),
"@op +voice regular".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.users.contains_key("op"));
assert_eq!(buf.users.get("op").unwrap().prefix, "@");
assert_eq!(buf.users.get("op").unwrap().modes, "o");
assert!(buf.users.contains_key("voice"));
assert_eq!(buf.users.get("voice").unwrap().prefix, "+");
assert_eq!(buf.users.get("voice").unwrap().modes, "v");
assert!(buf.users.contains_key("regular"));
assert_eq!(buf.users.get("regular").unwrap().prefix, "");
}
#[test]
fn parse_names_standard_single_prefix() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("@nick", &prefix_map, false);
assert_eq!(entry.nick, "nick");
assert_eq!(entry.prefix, "@");
assert_eq!(entry.modes, "o");
assert!(entry.ident.is_none());
assert!(entry.host.is_none());
}
#[test]
fn parse_names_no_prefix() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("regular", &prefix_map, false);
assert_eq!(entry.nick, "regular");
assert_eq!(entry.prefix, "");
assert_eq!(entry.modes, "");
}
#[test]
fn parse_names_multi_prefix_two_modes() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("@+nick", &prefix_map, false);
assert_eq!(entry.nick, "nick");
assert_eq!(entry.prefix, "@+");
assert_eq!(entry.modes, "ov");
assert!(entry.ident.is_none());
assert!(entry.host.is_none());
}
#[test]
fn parse_names_multi_prefix_five_modes() {
let prefix_map = vec![('q', '~'), ('a', '&'), ('o', '@'), ('h', '%'), ('v', '+')];
let entry = parse_names_entry("~&@%+nick", &prefix_map, false);
assert_eq!(entry.nick, "nick");
assert_eq!(entry.prefix, "~&@%+");
assert_eq!(entry.modes, "qaohv");
}
#[test]
fn parse_names_userhost_in_names() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("@+nick!user@host.com", &prefix_map, true);
assert_eq!(entry.nick, "nick");
assert_eq!(entry.prefix, "@+");
assert_eq!(entry.modes, "ov");
assert_eq!(entry.ident.as_deref(), Some("user"));
assert_eq!(entry.host.as_deref(), Some("host.com"));
}
#[test]
fn parse_names_userhost_no_prefix() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("nick!user@host.com", &prefix_map, true);
assert_eq!(entry.nick, "nick");
assert_eq!(entry.prefix, "");
assert_eq!(entry.modes, "");
assert_eq!(entry.ident.as_deref(), Some("user"));
assert_eq!(entry.host.as_deref(), Some("host.com"));
}
#[test]
fn parse_names_userhost_not_enabled_preserves_raw_nick() {
let prefix_map = vec![('o', '@'), ('v', '+')];
let entry = parse_names_entry("@nick!user@host.com", &prefix_map, false);
assert_eq!(entry.nick, "nick!user@host.com");
assert_eq!(entry.prefix, "@");
assert_eq!(entry.modes, "o");
assert!(entry.ident.is_none());
assert!(entry.host.is_none());
}
#[test]
fn rpl_namreply_multi_prefix() {
let mut state = make_test_state();
if let Some(conn) = state.connections.get_mut("test") {
conn.isupport_parsed.parse_tokens(&["PREFIX=(ov)@+"]);
}
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_NAMREPLY,
vec![
"me".into(),
"=".into(),
"#test".into(),
"@+alice @bob +carol regular".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let alice = buf.users.get("alice").unwrap();
assert_eq!(alice.prefix, "@+");
assert_eq!(alice.modes, "ov");
let bob = buf.users.get("bob").unwrap();
assert_eq!(bob.prefix, "@");
assert_eq!(bob.modes, "o");
let carol = buf.users.get("carol").unwrap();
assert_eq!(carol.prefix, "+");
assert_eq!(carol.modes, "v");
let regular = buf.users.get("regular").unwrap();
assert_eq!(regular.prefix, "");
assert_eq!(regular.modes, "");
}
#[test]
fn rpl_namreply_userhost_in_names() {
let mut state = make_test_state();
if let Some(conn) = state.connections.get_mut("test") {
conn.isupport_parsed.parse_tokens(&["PREFIX=(ov)@+"]);
conn.enabled_caps.insert("userhost-in-names".to_string());
}
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_NAMREPLY,
vec![
"me".into(),
"=".into(),
"#test".into(),
"@+alice!auser@ahost.net bob!buser@bhost.org".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let alice = buf.users.get("alice").unwrap();
assert_eq!(alice.prefix, "@+");
assert_eq!(alice.modes, "ov");
assert_eq!(alice.ident.as_deref(), Some("auser"));
assert_eq!(alice.host.as_deref(), Some("ahost.net"));
let bob = buf.users.get("bob").unwrap();
assert_eq!(bob.prefix, "");
assert_eq!(bob.modes, "");
assert_eq!(bob.ident.as_deref(), Some("buser"));
assert_eq!(bob.host.as_deref(), Some("bhost.org"));
}
#[test]
fn rpl_topic_sets_topic() {
let mut state = make_test_state();
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_TOPIC,
vec!["me".into(), "#test".into(), "Welcome!".into()],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.topic.as_deref(), Some("Welcome!"));
}
#[test]
fn connected_updates_status() {
let mut state = make_test_state();
state.update_connection_status("test", ConnectionStatus::Connecting);
handle_connected(&mut state, "test");
assert_eq!(
state.connections.get("test").unwrap().status,
ConnectionStatus::Connected
);
}
#[test]
fn disconnected_with_error() {
let mut state = make_test_state();
handle_disconnected(&mut state, "test", Some("timeout"));
let conn = state.connections.get("test").unwrap();
assert_eq!(conn.status, ConnectionStatus::Error);
assert_eq!(conn.error.as_deref(), Some("timeout"));
}
#[test]
fn disconnected_clean() {
let mut state = make_test_state();
handle_disconnected(&mut state, "test", None);
assert_eq!(
state.connections.get("test").unwrap().status,
ConnectionStatus::Disconnected
);
}
#[test]
fn notice_from_server_goes_to_status() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("irc.server.com"),
Command::NOTICE("*".into(), "*** Looking up your hostname".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/testserver").unwrap();
assert!(buf.messages.back().unwrap().text.contains("Looking up"));
assert_eq!(
buf.messages.back().unwrap().message_type,
MessageType::Notice
);
}
#[test]
fn extended_join_with_account() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("carol!user@host"),
Command::JOIN(
"#test".into(),
Some("patrick".into()),
Some("Real Name".into()),
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.users.contains_key("carol"));
let entry = buf.users.get("carol").unwrap();
assert_eq!(entry.account.as_deref(), Some("patrick"));
let join_msg = buf.messages.back().unwrap();
assert!(join_msg.text.contains("[patrick]"));
assert!(join_msg.text.contains("Real Name"));
let params = join_msg.event_params.as_ref().unwrap();
assert_eq!(params[4], "[patrick]"); assert_eq!(params[5], "Real Name"); }
#[test]
fn extended_join_without_account() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("carol!user@host"),
Command::JOIN("#test".into(), Some("*".into()), Some("Real Name".into())),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.users.contains_key("carol"));
let entry = buf.users.get("carol").unwrap();
assert_eq!(entry.account, None);
}
#[test]
fn standard_join_no_account() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("carol!user@host"),
Command::JOIN("#test".into(), None, None),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert!(buf.users.contains_key("carol"));
let entry = buf.users.get("carol").unwrap();
assert_eq!(entry.account, None);
}
#[test]
fn account_notify_login() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
Some("alice!user@host"),
Command::ACCOUNT("alice_account".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.account.as_deref(), Some("alice_account"));
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("alice is now logged in as alice_account")
);
}
#[test]
fn account_notify_logout() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: Some("alice_account".to_string()),
ident: None,
host: None,
},
);
let msg = make_irc_msg(Some("alice!user@host"), Command::ACCOUNT("*".into()));
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.account, None);
assert!(
buf.messages
.back()
.unwrap()
.text
.contains("alice has logged out")
);
}
#[test]
fn account_notify_updates_all_shared_buffers() {
let mut state = make_test_state();
let chan2_id = make_buffer_id("test", "#other");
state.add_buffer(Buffer {
id: chan2_id,
connection_id: "test".to_string(),
buffer_type: BufferType::Channel,
name: "#other".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
for buf_id in &["test/#test", "test/#other"] {
state.add_nick(
buf_id,
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
}
let msg = make_irc_msg(
Some("alice!user@host"),
Command::ACCOUNT("alice_acct".into()),
);
handle_irc_message(&mut state, "test", &msg);
let entry1 = state
.buffers
.get("test/#test")
.unwrap()
.users
.get("alice")
.unwrap();
assert_eq!(entry1.account.as_deref(), Some("alice_acct"));
let entry2 = state
.buffers
.get("test/#other")
.unwrap()
.users
.get("alice")
.unwrap();
assert_eq!(entry2.account.as_deref(), Some("alice_acct"));
}
#[test]
fn away_notify_sets_away() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
Some("alice!user@host"),
Command::AWAY(Some("Gone fishing".into())),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert!(
entry.away,
"NickEntry.away should be true after AWAY with reason"
);
assert!(
buf.messages.is_empty(),
"away-notify should not add event messages"
);
}
#[test]
fn away_notify_clears_away() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: true,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(Some("alice!user@host"), Command::AWAY(None));
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert!(
!entry.away,
"NickEntry.away should be false after AWAY without reason"
);
assert!(
buf.messages.is_empty(),
"away-notify should not add event messages"
);
}
#[test]
fn away_notify_updates_all_shared_buffers() {
let mut state = make_test_state();
let chan2_id = make_buffer_id("test", "#other");
state.add_buffer(Buffer {
id: chan2_id,
connection_id: "test".to_string(),
buffer_type: BufferType::Channel,
name: "#other".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
for buf_id in &["test/#test", "test/#other"] {
state.add_nick(
buf_id,
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
}
let msg = make_irc_msg(Some("alice!user@host"), Command::AWAY(Some("BRB".into())));
handle_irc_message(&mut state, "test", &msg);
let entry1 = state
.buffers
.get("test/#test")
.unwrap()
.users
.get("alice")
.unwrap();
assert!(entry1.away);
let entry2 = state
.buffers
.get("test/#other")
.unwrap()
.users
.get("alice")
.unwrap();
assert!(entry2.away);
}
#[test]
fn chghost_updates_ident_and_host() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: Some("olduser".to_string()),
host: Some("oldhost.example.com".to_string()),
},
);
let msg = make_irc_msg(
Some("alice!olduser@oldhost.example.com"),
Command::CHGHOST("newuser".into(), "newhost.example.com".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.ident.as_deref(), Some("newuser"));
assert_eq!(entry.host.as_deref(), Some("newhost.example.com"));
}
#[test]
fn chghost_adds_event_message() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
Some("alice!olduser@oldhost"),
Command::CHGHOST("newident".into(), "new.host.net".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1);
let event = &buf.messages[0];
assert_eq!(event.message_type, MessageType::Event);
assert!(
event
.text
.contains("alice changed host to newident@new.host.net")
);
assert_eq!(event.event_key.as_deref(), Some("chghost"));
}
#[test]
fn chghost_updates_all_shared_buffers() {
let mut state = make_test_state();
let chan2_id = make_buffer_id("test", "#other");
state.add_buffer(Buffer {
id: chan2_id,
connection_id: "test".to_string(),
buffer_type: BufferType::Channel,
name: "#other".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
for buf_id in &["test/#test", "test/#other"] {
state.add_nick(
buf_id,
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
}
let msg = make_irc_msg(
Some("alice!user@host"),
Command::CHGHOST("changed".into(), "vhost.net".into()),
);
handle_irc_message(&mut state, "test", &msg);
let entry1 = state
.buffers
.get("test/#test")
.unwrap()
.users
.get("alice")
.unwrap();
assert_eq!(entry1.ident.as_deref(), Some("changed"));
assert_eq!(entry1.host.as_deref(), Some("vhost.net"));
let entry2 = state
.buffers
.get("test/#other")
.unwrap()
.users
.get("alice")
.unwrap();
assert_eq!(entry2.ident.as_deref(), Some("changed"));
assert_eq!(entry2.host.as_deref(), Some("vhost.net"));
assert_eq!(state.buffers.get("test/#test").unwrap().messages.len(), 1);
assert_eq!(state.buffers.get("test/#other").unwrap().messages.len(), 1);
}
#[test]
fn account_tag_updates_nick_entry_on_privmsg() {
let mut state = make_test_state();
state.add_nick(
"test/#test",
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let mut msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hello".into()),
);
msg.tags = Some(vec![irc::proto::message::Tag(
"account".to_string(),
Some("alice_acct".to_string()),
)]);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.account.as_deref(), Some("alice_acct"));
}
#[test]
fn extended_join_account_on_own_join() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("me!user@host"),
Command::JOIN(
"#newchan".into(),
Some("my_account".into()),
Some("My Real Name".into()),
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(state.buffers.contains_key("test/#newchan"));
let buf = state.buffers.get("test/#newchan").unwrap();
assert_eq!(buf.buffer_type, BufferType::Channel);
}
#[test]
fn server_time_tag_used_as_timestamp() {
let mut state = make_test_state();
let mut msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hello from the past".into()),
);
msg.tags = Some(vec![irc::proto::message::Tag(
"time".to_string(),
Some("2020-06-15T10:30:00.000Z".to_string()),
)]);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let ts = buf.messages[0].timestamp;
assert_eq!(ts.year(), 2020);
assert_eq!(ts.month(), 6);
assert_eq!(ts.day(), 15);
assert_eq!(ts.hour(), 10);
assert_eq!(ts.minute(), 30);
}
#[test]
fn missing_time_tag_falls_back_to_now() {
let mut state = make_test_state();
let before = Utc::now();
let msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hello".into()),
);
handle_irc_message(&mut state, "test", &msg);
let after = Utc::now();
let buf = state.buffers.get("test/#test").unwrap();
let ts = buf.messages[0].timestamp;
assert!(
ts >= before && ts <= after,
"timestamp should be approximately now"
);
}
#[test]
fn malformed_time_tag_falls_back_to_now() {
let mut state = make_test_state();
let before = Utc::now();
let mut msg = make_irc_msg(
Some("alice!user@host"),
Command::PRIVMSG("#test".into(), "hello".into()),
);
msg.tags = Some(vec![irc::proto::message::Tag(
"time".to_string(),
Some("not-a-timestamp".to_string()),
)]);
handle_irc_message(&mut state, "test", &msg);
let after = Utc::now();
let buf = state.buffers.get("test/#test").unwrap();
let ts = buf.messages[0].timestamp;
assert!(
ts >= before && ts <= after,
"malformed tag should fall back to now"
);
}
#[test]
fn server_time_helper_unit() {
let mut tags = HashMap::new();
tags.insert("time".to_string(), "2023-01-15T08:45:30.123Z".to_string());
let ts = message_timestamp(Some(&tags));
assert_eq!(ts.year(), 2023);
assert_eq!(ts.month(), 1);
assert_eq!(ts.day(), 15);
assert_eq!(ts.hour(), 8);
assert_eq!(ts.minute(), 45);
assert_eq!(ts.second(), 30);
let before = Utc::now();
let ts = message_timestamp(None);
let after = Utc::now();
assert!(ts >= before && ts <= after);
let mut bad = HashMap::new();
bad.insert("time".to_string(), "garbage".to_string());
let before = Utc::now();
let ts = message_timestamp(Some(&bad));
let after = Utc::now();
assert!(ts >= before && ts <= after);
}
#[test]
fn cap_new_desired_caps_returns_request_list() {
let mut state = make_test_state();
if let Some(conn) = state.connections.get_mut("test") {
conn.enabled_caps.insert("multi-prefix".to_string());
}
let to_request = handle_cap_new(
&mut state,
"test",
Some("multi-prefix echo-message unknown-cap"),
None,
);
assert_eq!(to_request, vec!["echo-message"]);
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert!(
last.text.contains("echo-message"),
"should mention requested cap"
);
assert_eq!(last.event_key.as_deref(), Some("cap_new"));
}
#[test]
fn cap_new_non_desired_caps_ignored() {
let mut state = make_test_state();
let to_request =
handle_cap_new(&mut state, "test", Some("unknown-cap fancy-feature"), None);
assert!(to_request.is_empty(), "no desired caps should be requested");
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert!(
last.text.contains("none requested"),
"should note nothing was requested"
);
}
#[test]
fn cap_new_with_values_strips_value_part() {
let mut state = make_test_state();
let to_request = handle_cap_new(
&mut state,
"test",
Some("sasl=PLAIN,EXTERNAL server-time"),
None,
);
assert!(to_request.contains(&"sasl".to_string()));
assert!(to_request.contains(&"server-time".to_string()));
}
#[test]
fn cap_del_removes_from_enabled() {
let mut state = make_test_state();
if let Some(conn) = state.connections.get_mut("test") {
conn.enabled_caps.insert("multi-prefix".to_string());
conn.enabled_caps.insert("server-time".to_string());
conn.enabled_caps.insert("away-notify".to_string());
}
handle_cap_del(&mut state, "test", Some("multi-prefix server-time"), None);
let conn = state.connections.get("test").unwrap();
assert!(!conn.enabled_caps.contains("multi-prefix"));
assert!(!conn.enabled_caps.contains("server-time"));
assert!(
conn.enabled_caps.contains("away-notify"),
"untouched cap should remain"
);
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert_eq!(last.event_key.as_deref(), Some("cap_del"));
assert!(last.text.contains("multi-prefix"));
}
#[test]
fn cap_del_for_non_enabled_caps_is_noop() {
let mut state = make_test_state();
handle_cap_del(&mut state, "test", Some("fancy-feature unknown-cap"), None);
let conn = state.connections.get("test").unwrap();
assert!(conn.enabled_caps.is_empty());
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert!(last.text.contains("none were enabled"));
}
#[test]
fn cap_ack_adds_to_enabled() {
let mut state = make_test_state();
handle_cap_ack(&mut state, "test", Some("echo-message invite-notify"), None);
let conn = state.connections.get("test").unwrap();
assert!(conn.enabled_caps.contains("echo-message"));
assert!(conn.enabled_caps.contains("invite-notify"));
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert_eq!(last.event_key.as_deref(), Some("cap_ack"));
assert!(last.text.contains("echo-message"));
}
#[test]
fn cap_nak_logs_rejection() {
let mut state = make_test_state();
handle_cap_nak(&mut state, "test", Some("echo-message"), None);
let conn = state.connections.get("test").unwrap();
assert!(!conn.enabled_caps.contains("echo-message"));
let buf = state
.buffers
.get(&make_buffer_id("test", "TestServer"))
.unwrap();
let last = buf.messages.back().unwrap();
assert_eq!(last.event_key.as_deref(), Some("cap_nak"));
assert!(last.text.contains("echo-message"));
}
#[test]
fn extract_cap_string_field3_primary() {
assert_eq!(
extract_cap_string(Some("multi-prefix server-time"), None),
"multi-prefix server-time"
);
}
#[test]
fn extract_cap_string_continuation() {
assert_eq!(
extract_cap_string(Some("*"), Some("batch echo-message")),
"batch echo-message"
);
}
#[test]
fn extract_cap_string_field4_preferred_when_present() {
assert_eq!(
extract_cap_string(Some("some-prefix"), Some("actual-caps here")),
"actual-caps here"
);
}
#[test]
fn cap_new_full_roundtrip_with_ack() {
let mut state = make_test_state();
let to_request = handle_cap_new(&mut state, "test", Some("echo-message batch"), None);
assert_eq!(to_request.len(), 2);
assert!(to_request.contains(&"echo-message".to_string()));
assert!(to_request.contains(&"batch".to_string()));
handle_cap_ack(&mut state, "test", Some("echo-message batch"), None);
let conn = state.connections.get("test").unwrap();
assert!(conn.enabled_caps.contains("echo-message"));
assert!(conn.enabled_caps.contains("batch"));
handle_cap_del(&mut state, "test", Some("batch"), None);
let conn = state.connections.get("test").unwrap();
assert!(
conn.enabled_caps.contains("echo-message"),
"echo-message should remain"
);
assert!(
!conn.enabled_caps.contains("batch"),
"batch should be removed"
);
}
#[test]
fn echo_message_own_privmsg_displayed_when_cap_enabled() {
let mut state = make_test_state();
state
.connections
.get_mut("test")
.unwrap()
.enabled_caps
.insert("echo-message".to_string());
let msg = make_irc_msg(
Some("me!user@host"),
Command::PRIVMSG("#test".into(), "hello from echo".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1, "echoed message should be displayed");
assert_eq!(buf.messages[0].text, "hello from echo");
assert_eq!(buf.messages[0].nick.as_deref(), Some("me"));
assert_eq!(buf.messages[0].message_type, MessageType::Message);
}
#[test]
fn echo_message_own_privmsg_no_cap_unchanged() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("me!user@host"),
Command::PRIVMSG("#test".into(), "my own message".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1, "message should still be displayed");
assert_eq!(buf.messages[0].text, "my own message");
assert_eq!(buf.activity, ActivityLevel::None);
}
#[test]
fn echo_message_own_pm_routes_to_recipient_buffer() {
let mut state = make_test_state();
state
.connections
.get_mut("test")
.unwrap()
.enabled_caps
.insert("echo-message".to_string());
let msg = make_irc_msg(
Some("me!user@host"),
Command::PRIVMSG("bob".into(), "hey bob".into()),
);
handle_irc_message(&mut state, "test", &msg);
assert!(
state.buffers.contains_key("test/bob"),
"query buffer should be created for recipient"
);
assert!(
!state.buffers.contains_key("test/me"),
"should NOT create a buffer named after ourselves"
);
let buf = state.buffers.get("test/bob").unwrap();
assert_eq!(buf.buffer_type, BufferType::Query);
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].text, "hey bob");
assert_eq!(buf.messages[0].nick.as_deref(), Some("me"));
}
#[test]
fn echo_message_own_action_displayed() {
let mut state = make_test_state();
state
.connections
.get_mut("test")
.unwrap()
.enabled_caps
.insert("echo-message".to_string());
let msg = make_irc_msg(
Some("me!user@host"),
Command::PRIVMSG("#test".into(), "\x01ACTION dances\x01".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].message_type, MessageType::Action);
assert_eq!(buf.messages[0].text, "dances");
assert_eq!(buf.messages[0].nick.as_deref(), Some("me"));
}
#[test]
fn echo_message_own_notice_routes_to_recipient() {
let mut state = make_test_state();
state
.connections
.get_mut("test")
.unwrap()
.enabled_caps
.insert("echo-message".to_string());
state.add_buffer(Buffer {
id: make_buffer_id("test", "bob"),
connection_id: "test".to_string(),
buffer_type: BufferType::Query,
name: "bob".to_string(),
messages: VecDeque::new(),
activity: ActivityLevel::None,
unread_count: 0,
last_read: Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
let msg = make_irc_msg(
Some("me!user@host"),
Command::NOTICE("bob".into(), "notice to bob".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/bob").unwrap();
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].message_type, MessageType::Notice);
assert_eq!(buf.messages[0].text, "notice to bob");
}
#[test]
fn invite_target_is_us_shows_in_active_buffer() {
let mut state = make_test_state();
state.set_active_buffer("test/#test");
let msg = make_irc_msg(
Some("op!user@host"),
Command::INVITE("me".into(), "#secret".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].message_type, MessageType::Event);
assert_eq!(buf.messages[0].text, "op invites you to #secret");
assert!(buf.messages[0].highlight);
}
#[test]
fn invite_notify_other_user_shows_in_channel() {
let mut state = make_test_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
Some("op!user@host"),
Command::INVITE("alice".into(), "#test".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
assert_eq!(buf.messages.len(), 1);
assert_eq!(buf.messages[0].message_type, MessageType::Event);
assert_eq!(buf.messages[0].text, "op invited alice to #test");
assert!(!buf.messages[0].highlight);
let server_buf = state.buffers.get("test/testserver").unwrap();
assert_eq!(server_buf.messages.len(), 0);
}
fn make_whox_state() -> AppState {
let mut state = make_test_state();
if let Some(conn) = state.connections.get_mut("test") {
conn.isupport_parsed.parse_tokens(&["WHOX"]);
}
let chan_id = make_buffer_id("test", "#test");
state.add_nick(
&chan_id,
NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
state.add_nick(
&chan_id,
NickEntry {
nick: "bob".to_string(),
prefix: "@".to_string(),
modes: "o".to_string(),
away: false,
account: None,
ident: None,
host: None,
},
);
state
}
#[test]
fn whox_reply_updates_nick_entry() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Raw(
"354".to_string(),
vec![
"me".to_string(), "1".to_string(), "#test".to_string(), "~alice".to_string(), "1.2.3.4".to_string(), "host.example.com".to_string(), "alice".to_string(), "H".to_string(), "patrick".to_string(), "Alice Smith".to_string(), ],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.ident.as_deref(), Some("~alice"));
assert_eq!(entry.host.as_deref(), Some("host.example.com"));
assert_eq!(entry.account.as_deref(), Some("patrick"));
assert!(!entry.away);
}
#[test]
fn whox_account_zero_means_not_logged_in() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Raw(
"354".to_string(),
vec![
"me".to_string(),
"1".to_string(),
"#test".to_string(),
"~bob".to_string(),
"5.6.7.8".to_string(),
"bob.host.net".to_string(),
"bob".to_string(),
"H@".to_string(),
"0".to_string(), "Bob Jones".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("bob").unwrap();
assert!(entry.account.is_none());
}
#[test]
fn whox_gone_flag_sets_away() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Raw(
"354".to_string(),
vec![
"me".to_string(),
"1".to_string(),
"#test".to_string(),
"~alice".to_string(),
"1.2.3.4".to_string(),
"host.example.com".to_string(),
"alice".to_string(),
"G".to_string(), "alice_acct".to_string(),
"Alice".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert!(entry.away);
}
#[test]
fn whox_here_flag_clears_away() {
let mut state = make_whox_state();
let chan_id = make_buffer_id("test", "#test");
if let Some(buf) = state.buffers.get_mut(&chan_id)
&& let Some(entry) = buf.users.get_mut("alice")
{
entry.away = true;
}
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Raw(
"354".to_string(),
vec![
"me".to_string(),
"1".to_string(),
"#test".to_string(),
"~alice".to_string(),
"1.2.3.4".to_string(),
"host.example.com".to_string(),
"alice".to_string(),
"H".to_string(), "0".to_string(),
"Alice".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert!(!entry.away);
}
#[test]
fn standard_who_reply_still_works() {
let mut state = make_test_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_WHOREPLY,
vec![
"me".to_string(),
"#test".to_string(),
"~user".to_string(),
"host.com".to_string(),
"irc.net".to_string(),
"alice".to_string(),
"H@".to_string(),
"0 Real Name".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/testserver").unwrap();
assert_eq!(buf.messages.len(), 1);
assert!(buf.messages[0].text.contains("alice"));
}
#[test]
fn next_who_token_increments() {
let mut state = make_test_state();
let t1 = next_who_token(&mut state, "test");
let t2 = next_who_token(&mut state, "test");
let t3 = next_who_token(&mut state, "test");
assert_eq!(t1, "1");
assert_eq!(t2, "2");
assert_eq!(t3, "3");
}
#[test]
fn build_whox_who_returns_none_without_whox() {
let mut state = make_test_state();
assert!(build_whox_who(&mut state, "test", "#test", false).is_none());
}
#[test]
fn build_whox_who_returns_fields_with_whox() {
let mut state = make_whox_state();
let result = build_whox_who(&mut state, "test", "#test", false);
assert!(result.is_some());
let (target, fields) = result.unwrap();
assert_eq!(target, "#test");
assert!(fields.starts_with("%tcuihnfar,"));
assert!(fields.ends_with(",1"));
}
#[test]
fn build_whox_who_silent_registers_channel() {
let mut state = make_whox_state();
let result = build_whox_who(&mut state, "test", "#silent", true);
assert!(result.is_some());
let conn = state.connections.get("test").unwrap();
assert!(conn.silent_who_channels.contains("#silent"));
}
#[test]
fn build_whox_who_non_silent_does_not_register() {
let mut state = make_whox_state();
let _result = build_whox_who(&mut state, "test", "#loud", false);
let conn = state.connections.get("test").unwrap();
assert!(!conn.silent_who_channels.contains("#loud"));
}
#[test]
fn silent_whox_reply_updates_state_without_display() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
if let Some(conn) = state.connections.get_mut("test") {
conn.silent_who_channels.insert("#test".to_string());
}
let msg = make_irc_msg(
None,
Command::Raw(
"354".to_string(),
vec![
"me".to_string(),
"1".to_string(),
"#test".to_string(),
"~alice".to_string(),
"1.2.3.4".to_string(),
"host.example.com".to_string(),
"alice".to_string(),
"H".to_string(),
"alice_acct".to_string(),
"Alice Smith".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/#test").unwrap();
let entry = buf.users.get("alice").unwrap();
assert_eq!(entry.ident.as_deref(), Some("~alice"));
assert_eq!(entry.account.as_deref(), Some("alice_acct"));
let server_buf = state.buffers.get("test/testserver").unwrap();
assert!(server_buf.messages.is_empty());
}
#[test]
fn silent_who_end_cleans_up_and_suppresses_display() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
if let Some(conn) = state.connections.get_mut("test") {
conn.silent_who_channels.insert("#test".to_string());
}
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_ENDOFWHO,
vec![
"me".to_string(),
"#test".to_string(),
"End of WHO list".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let conn = state.connections.get("test").unwrap();
assert!(!conn.silent_who_channels.contains("#test"));
let server_buf = state.buffers.get("test/testserver").unwrap();
assert!(server_buf.messages.is_empty());
}
#[test]
fn manual_who_end_displays_message() {
let mut state = make_whox_state();
state.set_active_buffer("test/testserver");
let msg = make_irc_msg(
None,
Command::Response(
Response::RPL_ENDOFWHO,
vec![
"me".to_string(),
"#test".to_string(),
"End of WHO list".to_string(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
let server_buf = state.buffers.get("test/testserver").unwrap();
assert_eq!(server_buf.messages.len(), 1);
assert!(server_buf.messages[0].text.contains("End of WHO list"));
}
#[test]
fn error_command_creates_event_in_status_buffer() {
let mut state = make_test_state();
let msg = make_irc_msg(
Some("irc.server.com"),
Command::ERROR("Closing Link: timeout".into()),
);
handle_irc_message(&mut state, "test", &msg);
let buf = state.buffers.get("test/testserver").unwrap();
assert_eq!(buf.messages.len(), 1);
assert!(buf.messages[0].text.contains("ERROR"));
assert!(buf.messages[0].text.contains("Closing Link: timeout"));
assert_eq!(buf.messages[0].message_type, MessageType::Event);
}
#[test]
fn error_command_marks_connection_as_errored() {
let mut state = make_test_state();
let msg = make_irc_msg(Some("irc.server.com"), Command::ERROR("Banned".into()));
handle_irc_message(&mut state, "test", &msg);
let conn = state.connections.get("test").unwrap();
assert_eq!(conn.status, ConnectionStatus::Error);
assert_eq!(conn.error.as_deref(), Some("Banned"));
}
#[test]
fn join_failure_removes_empty_buffer() {
let mut state = make_test_state();
state.add_buffer(make_channel_buffer("test", "#locked"));
assert!(state.buffers.contains_key("test/#locked"));
let msg = make_irc_msg(
None,
Command::Response(
Response::ERR_BANNEDFROMCHAN,
vec![
"me".into(),
"#locked".into(),
"Cannot join channel (+b)".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#locked"));
}
#[test]
fn join_failure_keeps_active_buffer() {
let mut state = make_test_state();
state.add_buffer(make_channel_buffer("test", "#active"));
state.add_nick(
"test/#active",
NickEntry {
nick: "me".into(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let msg = make_irc_msg(
None,
Command::Response(
Response::ERR_BANNEDFROMCHAN,
vec![
"me".into(),
"#active".into(),
"Cannot join channel (+b)".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(state.buffers.contains_key("test/#active"));
}
#[test]
fn join_failure_invite_only() {
let mut state = make_test_state();
state.add_buffer(make_channel_buffer("test", "#secret"));
let msg = make_irc_msg(
None,
Command::Response(
Response::ERR_INVITEONLYCHAN,
vec![
"me".into(),
"#secret".into(),
"Cannot join channel (+i)".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#secret"));
}
#[test]
fn join_failure_channel_full() {
let mut state = make_test_state();
state.add_buffer(make_channel_buffer("test", "#crowded"));
let msg = make_irc_msg(
None,
Command::Response(
Response::ERR_CHANNELISFULL,
vec![
"me".into(),
"#crowded".into(),
"Cannot join channel (+l)".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#crowded"));
}
#[test]
fn join_failure_bad_key() {
let mut state = make_test_state();
state.add_buffer(make_channel_buffer("test", "#keyed"));
let msg = make_irc_msg(
None,
Command::Response(
Response::ERR_BADCHANNELKEY,
vec![
"me".into(),
"#keyed".into(),
"Cannot join channel (+k)".into(),
],
),
);
handle_irc_message(&mut state, "test", &msg);
assert!(!state.buffers.contains_key("test/#keyed"));
}
}