aboutsummaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
authorGravatar lancebord 2026-03-06 18:09:52 -0500
committerGravatar lancebord 2026-03-06 18:09:52 -0500
commitffb9c05de1c755dbddd8b67cca1d6023b213115f (patch)
treefa8c375fc6489871b3539e15f39310dad99b3618 /src/client
initial commit
Diffstat (limited to '')
-rw-r--r--src/client/event.rs50
-rw-r--r--src/client/handler.rs250
-rw-r--r--src/client/mod.rs176
-rw-r--r--src/client/state.rs62
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,
+ }
+ }
+}