diff options
| author | 2026-03-06 18:09:52 -0500 | |
|---|---|---|
| committer | 2026-03-06 18:09:52 -0500 | |
| commit | ffb9c05de1c755dbddd8b67cca1d6023b213115f (patch) | |
| tree | fa8c375fc6489871b3539e15f39310dad99b3618 /src/client | |
initial commit
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/event.rs | 50 | ||||
| -rw-r--r-- | src/client/handler.rs | 250 | ||||
| -rw-r--r-- | src/client/mod.rs | 176 | ||||
| -rw-r--r-- | src/client/state.rs | 62 |
4 files changed, 538 insertions, 0 deletions
diff --git a/src/client/event.rs b/src/client/event.rs new file mode 100644 index 0000000..d34908d --- /dev/null +++ b/src/client/event.rs @@ -0,0 +1,50 @@ +/// Events produced by the IRC client and surfaced to your application. +/// Match on these in your main loop to drive UI, bot logic, etc. +#[derive(Debug, Clone)] +pub enum Event { + /// Successfully registered with the server (001 received) + Connected { server: String, nick: String }, + + /// A PRIVMSG or NOTICE in a channel or as a PM + Message { + from: String, + target: String, + text: String, + is_notice: bool, + }, + + /// We joined a channel + Joined { channel: String }, + + /// We or someone else left a channel + Parted { + channel: String, + nick: String, + reason: Option<String>, + }, + + /// Someone quit the server + Quit { + nick: String, + reason: Option<String>, + }, + + /// A nick change (could be ours) + NickChanged { old_nick: String, new_nick: String }, + + /// Channel topic was set or changed + Topic { channel: String, topic: String }, + + /// NAMES list entry (members of a channel) + Names { + channel: String, + members: Vec<String>, + }, + + /// A raw message we didn't handle specifically + /// Useful for debugging or handling custom commands + Raw(crate::proto::message::IrcMessage), + + /// The connection was closed + Disconnected, +} diff --git a/src/client/handler.rs b/src/client/handler.rs new file mode 100644 index 0000000..91aa5ea --- /dev/null +++ b/src/client/handler.rs @@ -0,0 +1,250 @@ +use tracing::{debug, warn}; + +use crate::client::event::Event; +use crate::client::state::{ClientState, RegistrationState}; +use crate::connection::Sender; +use crate::proto::message::{Command, IrcMessage, Prefix}; +use crate::proto::serializer::serialize; + +/// Dispatch a single incoming `IrcMessage`, updating `state` and returning +/// zero or more `Event`s for the application to handle. +pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<Event> { + let mut events = Vec::new(); + + match &msg.command { + // --- PING: must reply immediately or the server drops us --- + Command::Ping => { + let token = msg.params.first().cloned().unwrap_or_default(); + sender.send(IrcMessage::new(Command::Pong, vec![token])); + } + + // --- CAP: capability negotiation --- + Command::Cap => { + handle_cap(&msg, state, sender); + } + + // --- 001: welcome — registration complete --- + Command::Numeric(1) => { + let server = msg + .prefix + .as_ref() + .map(|p| match p { + Prefix::Server(s) => s.clone(), + Prefix::User { nick, .. } => nick.clone(), + }) + .unwrap_or_default(); + + // Server may have assigned us a different nick + if let Some(nick) = msg.params.first() { + state.nick = nick.clone(); + } + + state.reg = RegistrationState::Registered; + state.server_name = Some(server.clone()); + + events.push(Event::Connected { + server, + nick: state.nick.clone(), + }); + } + + // --- 353: NAMES reply (list of members in a channel) --- + Command::Numeric(353) => { + // params: [our_nick, ("=" / "*" / "@"), channel, ":member1 member2 ..."] + if let (Some(channel), Some(members_str)) = (msg.params.get(2), msg.params.get(3)) { + let members: Vec<String> = members_str + .split_whitespace() + // Strip membership prefixes (@, +, etc.) + .map(|m| { + m.trim_start_matches(&['@', '+', '%', '~', '&'][..]) + .to_string() + }) + .collect(); + + let ch = state.channel_mut(channel); + ch.members.extend(members.clone()); + + events.push(Event::Names { + channel: channel.clone(), + members, + }); + } + } + + // --- 332: topic on join --- + Command::Numeric(332) => { + if let (Some(channel), Some(topic)) = (msg.params.get(1), msg.params.get(2)) { + state.channel_mut(channel).topic = Some(topic.clone()); + events.push(Event::Topic { + channel: channel.clone(), + topic: topic.clone(), + }); + } + } + + // --- JOIN --- + Command::Join => { + let nick = nick_from_prefix(&msg.prefix); + if let Some(channel) = msg.params.first() { + if nick == state.nick { + // We joined + state.channel_mut(channel); + events.push(Event::Joined { + channel: channel.clone(), + }); + } else { + // Someone else joined + state.channel_mut(channel).members.insert(nick); + } + } + } + + // --- PART --- + Command::Part => { + let nick = nick_from_prefix(&msg.prefix); + let channel = msg.params.first().cloned().unwrap_or_default(); + let reason = msg.params.get(1).cloned(); + + if nick == state.nick { + state.remove_channel(&channel); + } else { + state.channel_mut(&channel).members.remove(&nick); + } + + events.push(Event::Parted { + channel, + nick, + reason, + }); + } + + // --- QUIT --- + Command::Quit => { + let nick = nick_from_prefix(&msg.prefix); + let reason = msg.params.first().cloned(); + + // Remove them from all channels + for ch in state.channels.values_mut() { + ch.members.remove(&nick); + } + + events.push(Event::Quit { nick, reason }); + } + + // --- NICK --- + Command::Nick => { + let old_nick = nick_from_prefix(&msg.prefix); + let new_nick = msg.params.first().cloned().unwrap_or_default(); + + if old_nick == state.nick { + state.nick = new_nick.clone(); + } + + // Update in all channels + for ch in state.channels.values_mut() { + if ch.members.remove(&old_nick) { + ch.members.insert(new_nick.clone()); + } + } + + events.push(Event::NickChanged { old_nick, new_nick }); + } + + // --- PRIVMSG / NOTICE --- + Command::Privmsg | Command::Notice => { + let from = nick_from_prefix(&msg.prefix); + let target = msg.params.first().cloned().unwrap_or_default(); + let text = msg.params.get(1).cloned().unwrap_or_default(); + let is_notice = msg.command == Command::Notice; + + events.push(Event::Message { + from, + target, + text, + is_notice, + }); + } + + // --- TOPIC (live change) --- + Command::Topic => { + if let (Some(channel), Some(topic)) = (msg.params.first(), msg.params.get(1)) { + state.channel_mut(channel).topic = Some(topic.clone()); + events.push(Event::Topic { + channel: channel.clone(), + topic: topic.clone(), + }); + } + } + + // --- Everything else: surface as Raw --- + _ => { + debug!("Unhandled: {}", serialize(&msg)); + events.push(Event::Raw(msg)); + } + } + + events +} + +/// Handle CAP sub-commands during capability negotiation. +fn handle_cap(msg: &IrcMessage, state: &mut ClientState, sender: &Sender) { + // params: [target, subcommand, (optional "*",) params] + let subcommand = msg.params.get(1).map(|s| s.as_str()).unwrap_or(""); + + match subcommand { + "LS" => { + // Server listed its capabilities. + // For now, request a small set of common useful caps. + let want = ["multi-prefix", "away-notify", "server-time", "message-tags"]; + let server_caps = msg.params.last().map(|s| s.as_str()).unwrap_or(""); + + let to_request: Vec<&str> = want + .iter() + .copied() + .filter(|cap| server_caps.split_whitespace().any(|s| s == *cap)) + .collect(); + + if to_request.is_empty() { + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } else { + sender.send(IrcMessage::new( + Command::Cap, + vec!["REQ".into(), to_request.join(" ")], + )); + state.reg = RegistrationState::CapPending; + } + } + + "ACK" => { + // Server acknowledged our capability requests + if let Some(caps) = msg.params.last() { + for cap in caps.split_whitespace() { + state.caps.insert(cap.to_string()); + } + } + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } + + "NAK" => { + // Server rejected our request — just end negotiation + warn!("CAP NAK: {:?}", msg.params.last()); + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } + + other => { + debug!("Unhandled CAP subcommand: {}", other); + } + } +} + +/// Extract the nick from a message prefix, returning empty string if absent. +fn nick_from_prefix(prefix: &Option<Prefix>) -> String { + match prefix { + Some(Prefix::User { nick, .. }) => nick.clone(), + Some(Prefix::Server(s)) => s.clone(), + None => String::new(), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..6d32017 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,176 @@ +use tokio::sync::mpsc; +use tracing::info; + +use crate::client::event::Event; +use crate::client::handler::handle; +use crate::client::state::ClientState; +use crate::connection::{self, Sender}; +use crate::proto::message::{Command, IrcMessage}; + +pub mod event; +pub mod handler; +pub mod state; + +/// Configuration for the IRC client. +pub struct Config { + /// Server address, e.g. "irc.libera.chat:6667" + pub server: String, + /// Desired nick + pub nick: String, + /// IRC username (shown in /whois) + pub user: String, + /// Real name (shown in /whois) + pub realname: String, + /// Optional server password + pub password: Option<String>, + /// Channels to auto-join after registration + pub autojoin: Vec<String>, +} + +/// The main IRC client. +/// +/// Call `Client::connect` to establish a connection, then drive the event +/// loop with `client.next_event().await` in your application loop. +pub struct Client { + state: ClientState, + sender: Sender, + inbox: mpsc::UnboundedReceiver<IrcMessage>, + config: Config, +} + +impl Client { + /// Connect to the server and begin the registration handshake. + pub async fn connect(config: Config) -> Result<Self, std::io::Error> { + let (sender, inbox) = connection::connect(&config.server).await?; + let state = ClientState::new(&config.nick); + + let client = Self { + state, + sender, + inbox, + config, + }; + client.register(); + Ok(client) + } + + /// Send a raw `IrcMessage` to the server. + pub fn send(&self, msg: IrcMessage) { + self.sender.send(msg); + } + + /// Send a PRIVMSG to a channel or user. + pub fn privmsg(&self, target: &str, text: &str) { + self.sender.send(IrcMessage::new( + Command::Privmsg, + vec![target.to_string(), text.to_string()], + )); + } + + /// Join a channel. + pub fn join(&self, channel: &str) { + self.sender + .send(IrcMessage::new(Command::Join, vec![channel.to_string()])); + } + + /// Part a channel. + pub fn part(&self, channel: &str, reason: Option<&str>) { + let mut params = vec![channel.to_string()]; + if let Some(r) = reason { + params.push(r.to_string()); + } + self.sender.send(IrcMessage::new(Command::Part, params)); + } + + /// Change nick. + pub fn nick(&self, new_nick: &str) { + self.sender + .send(IrcMessage::new(Command::Nick, vec![new_nick.to_string()])); + } + + /// Read-only view of current client state. + pub fn state(&self) -> &ClientState { + &self.state + } + + /// Wait for the next event from the server. + /// Returns `None` if the connection has closed. + pub async fn next_event(&mut self) -> Option<Event> { + loop { + let msg = self.inbox.recv().await?; + let events = handle(msg, &mut self.state, &self.sender); + + // Handle auto-join after registration + for event in &events { + if let Event::Connected { .. } = event { + for channel in &self.config.autojoin.clone() { + info!("Auto-joining {}", channel); + self.join(channel); + } + } + } + + // Return the first event; re-queue the rest + // (simple approach: process one at a time via recursive buffering) + if let Some(first) = events.into_iter().next() { + return Some(first); + } + // If no events were produced (e.g. a PING), loop and wait for next message + } + } + + /// Send the registration sequence to the server. + fn register(&self) { + // Optional server password + if let Some(pass) = &self.config.password { + self.sender + .send(IrcMessage::new(Command::Pass, vec![pass.clone()])); + } + + // Begin CAP negotiation first — lets us request IRCv3 caps + // before NICK/USER so the server doesn't rush past registration + self.sender.send(IrcMessage::new( + Command::Cap, + vec!["LS".into(), "302".into()], + )); + + self.sender.send(IrcMessage::new( + Command::Nick, + vec![self.config.nick.clone()], + )); + + self.sender.send(IrcMessage::new( + Command::User, + vec![ + self.config.user.clone(), + "0".into(), + "*".into(), + self.config.realname.clone(), + ], + )); + } +} + +impl Client { + /// Non-blocking version of `next_event`. + /// Returns `Some(event)` if one is immediately available, `None` otherwise. + /// Used by the TUI loop to drain events without blocking the render tick. + pub fn next_event_nowait(&mut self) -> Option<Event> { + loop { + let msg = self.inbox.try_recv().ok()?; + let mut events = handle(msg, &mut self.state, &self.sender); + + for event in &events { + if let Event::Connected { .. } = event { + for channel in &self.config.autojoin.clone() { + self.join(channel); + } + } + } + + if !events.is_empty() { + return Some(events.remove(0)); + } + } + } +} diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 0000000..b987509 --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,62 @@ +use std::collections::{HashMap, HashSet}; + +/// The full state of a connected IRC client. +#[derive(Debug, Default)] +pub struct ClientState { + pub nick: String, + pub channels: HashMap<String, Channel>, + pub caps: HashSet<String>, + pub server_name: Option<String>, + pub reg: RegistrationState, +} + +impl ClientState { + pub fn new(nick: impl Into<String>) -> Self { + Self { + nick: nick.into(), + ..Default::default() + } + } + + pub fn channel(&self, name: &str) -> Option<&Channel> { + self.channels.get(&name.to_lowercase()) + } + + pub fn channel_mut(&mut self, name: &str) -> &mut Channel { + self.channels + .entry(name.to_lowercase()) + .or_insert_with(|| Channel::new(name)) + } + + pub fn remove_channel(&mut self, name: &str) { + self.channels.remove(&name.to_lowercase()); + } +} + +/// State of the registration handshake. +#[derive(Debug, Default, PartialEq, Eq)] +pub enum RegistrationState { + #[default] + CapNegotiation, + CapPending, + WaitingForWelcome, + Registered, +} + +/// A joined channel and its current state. +#[derive(Debug)] +pub struct Channel { + pub name: String, + pub members: HashSet<String>, + pub topic: Option<String>, +} + +impl Channel { + pub fn new(name: impl Into<String>) -> Self { + Self { + name: name.into(), + members: HashSet::new(), + topic: None, + } + } +} |
