commit f0b12f673dc0cc29eea87d46a56565d1906e9f0e Author: IceDragon Date: Mon Jan 5 17:23:49 2026 +0200 Initial: simple IRC bot implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b0534f7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rs-irc" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7cbe69f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "rs-irc" +version = "0.1.0" +edition = "2024" +authors = ["icedragon"] + +[dependencies] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..eef0255 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,286 @@ +use std::net::TcpStream; +use std::io::{self, Read, Write}; +use std::ops::ControlFlow; + +#[derive(Debug)] +#[allow(dead_code)] +enum IrcClientMessage<'msg> { + Raw(&'msg [u8]), + RawLine(&'msg [u8]), + Nick(&'msg str), + User(&'msg str, &'msg str), + Pong(&'msg str), + Notice(&'msg str, &'msg str), +} + +impl<'msg> From> for Vec { + fn from(value: IrcClientMessage) -> Self { + match value { + IrcClientMessage::Nick(nick) => + format!("NICK {nick}").into_bytes(), + IrcClientMessage::User(ident, gecos) => + format!("USER {ident} 0 * :{gecos}").into_bytes(), + IrcClientMessage::Pong(s) => + format!("PONG :{s}").into_bytes(), + IrcClientMessage::Notice(to, msg) => + format!("NOTICE {to} :{msg}").into_bytes(), + IrcClientMessage::RawLine(bytes) => + bytes.into(), + IrcClientMessage::Raw(bytes) => + bytes.into(), + } + } +} + +#[derive(Debug)] +#[allow(dead_code)] +enum IrcServerMessage<'msg> { + RawLine(&'msg [u8]), + Ping(&'msg str), + Notice{ from: &'msg str, to: &'msg str, msg: &'msg str }, + Connected { from: &'msg str, nick: &'msg str, msg: &'msg str }, +} + +struct IrcProtocol(TcpStream); + +impl<'msg> IrcProtocol { + fn get_stream(&mut self) -> &mut TcpStream { + &mut self.0 + } + + fn send_message(&mut self, msg: IrcClientMessage) -> io::Result<()> { + let is_raw = matches!(msg, IrcClientMessage::Raw(_)); + + // translate message into bytes + let mut msg_bytes: Vec = msg.into(); + + // leave Raw messages as they are - without extra newlines + if !is_raw { + msg_bytes.push(b'\r'); + msg_bytes.push(b'\n'); + } + + // print what we're sending + let msg_str: String = msg_bytes.iter().map(|&c| c as char).collect(); + print!("[<<] {msg_str}"); + + // send the bytes + self.get_stream().write_all(&msg_bytes) + } + + fn resolve_message(&self, msg_bytes: &'msg [u8]) -> io::Result> { + assert!(msg_bytes.iter().all(|&c| c != b'\r' && c != b'\n'), "msg_bytes ({:?}) has newline characters", msg_bytes); + + if msg_bytes.starts_with(b"PING :") { + return Ok(IrcServerMessage::Ping(str::from_utf8(&msg_bytes[6..]).unwrap())); + } + + if msg_bytes.len() > 0 && msg_bytes[0] == b':' { + // the message if likely of the ": ..." or ":nick!user@host ..." format; + // those tend to have 3+ space-spearated parts to them + let msg_parts: Vec<&[u8]> = msg_bytes + .splitn(4, |&c| c == b' ') + .collect(); + + if msg_parts.len() >= 3 { + // convert msg_parts into an str vector + let mut parts: Vec<&str> = Vec::new(); + let parse_result = msg_parts + .iter() + .try_for_each(|&part| + match str::from_utf8(part) { + Ok(s) => ControlFlow::Continue(parts.push(s)), + Err(e) => ControlFlow::Break((part, e)), + } + ); + + if let ControlFlow::Break((part, e)) = parse_result { + let strpart: String = part.iter().map(|&c| c as char).collect(); + eprintln!("[!!] from_utf8({strpart:?}) failed -> {e:?}"); + } else { + // extract the various str components of the message + let from = parts[0]; + let cmd = parts[1]; + let to = parts[2]; + let rest = if parts.len() >= 4 { + parts[3] + } else { + "" + }; + + // if we found a suitable message, return it + if let Some(msg) = self._resolve_triple(from, cmd, to, rest) { + return Ok(msg); + } + } + } + } + + // couldn't find a suitable message - resort to just returning a + // raw one + Ok(IrcServerMessage::RawLine(msg_bytes)) + } + + fn _resolve_triple( + &self, + from: &'msg str, + cmd: &'msg str, + to: &'msg str, + rest: &'msg str, + ) -> Option> { + match cmd { + "NOTICE" => Some(IrcServerMessage::Notice { from, to, msg: rest }), + "001" => Some(IrcServerMessage::Connected { from, nick: to, msg: rest }), + _ => None, + } + } +} + +struct Client { + nick: String, + ident: String, + gecos: String, + _proto: Option, + _mynick: Option, + _myserver: Option, +} + +impl Client { + fn new(nick: &str, ident: &str, gecos: &str) -> Self { + Self { + nick: nick.to_string(), + ident: ident.to_string(), + gecos: gecos.to_string(), + _proto: None, + _mynick: None, + _myserver: None, + } + } + + fn protocol(&self) -> &IrcProtocol { + self._proto.as_ref().unwrap() + } + + fn protocol_mut(&mut self) -> &mut IrcProtocol { + self._proto.as_mut().unwrap() + } + + fn connect(&mut self, addr: &str) -> io::Result<()> { + // make sure we're not already connected + if self._proto.is_some() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, "already connected")); + } + + // try to connect + match TcpStream::connect(addr) { + Ok(stream) => { + self._proto = Some(IrcProtocol(stream)); + self.handle_connected() + }, + Err(e) => Err(e), + } + } + + fn serve(&mut self) -> io::Result<()> { + let mut rbuf = [0u8; 4096]; + let mut filled = 0; + + loop { + let sz = self.protocol_mut().get_stream().read(&mut rbuf[filled..])?; + let available = filled + sz; + if sz == 0 { + println!("[--] CONNECTION CLOSED ({filled} bytes lost)"); + if let Err(e) = self.handle_disconnected() { + eprintln!("[EE] handle_disconnected() error: {e:?}"); + } + break; + } + + // go over the buffer so far and extract any message that are ready + // we know for sure that up to there are no newline chars + let mut start = 0; + for i in filled..available { + if rbuf[i] == b'\n' { + let mut end = i; + while end > 0 && rbuf[end-1] == b'\r' { + end -= 1; + } + + // translate into a message + let msg_bytes = &rbuf[start..end]; + let msg_str: String = msg_bytes.iter().map(|&c| c as char).collect(); + println!("[>>] {msg_str}"); + + let msg = self.protocol().resolve_message(msg_bytes); + if msg.is_err() { + eprintln!("[EE] Error: {:?} for message {:?}", msg.unwrap_err(), msg_bytes); + continue; + } + + // handle the message before moving on to the next one + if let Err(e) = self.handle_message(msg.unwrap()) { + eprintln!("[EE] Error: handle_message() returned {e:?} for {msg_bytes:?}"); + } + start = i+1; + } + } + + // copy what's left to the beginning of the buffer + // TODO: use ring buffer + for (dst, i) in (start..available).enumerate() { + rbuf[dst] = rbuf[i]; + } + + filled = available - start; + } + + Ok(()) + } + + fn send_msg(&mut self, msg: IrcClientMessage) -> io::Result<()> { + self.protocol_mut().send_message(msg) + } + + fn handle_connected(&mut self) -> io::Result<()> { + self.send_msg(IrcClientMessage::Nick(&self.nick.clone()))?; + self.send_msg(IrcClientMessage::User(&self.ident.clone(), &self.gecos.clone()))?; + Ok(()) + } + + fn handle_disconnected(&mut self) -> io::Result<()> { + println!("[!!] DISCONNECTED"); + self._myserver = None; + self._mynick = None; + self._proto = None; + Ok(()) + } + + fn handle_message(&mut self, msg: IrcServerMessage) -> io::Result<()> { + match msg { + IrcServerMessage::Connected { from, nick, msg: _ } => { + self._mynick = Some(nick.to_string()); + self._myserver = Some(from.to_string()); + self.send_msg(IrcClientMessage::Notice("IceDragon", "Rawr!"))?; + }, + IrcServerMessage::Notice { from, to, msg: _ } => { + if to == "AUTH" && self._myserver.is_none() { + self._myserver = Some(from.to_string()); + } + }, + IrcServerMessage::Ping(data) => { + self.send_msg(IrcClientMessage::Pong(data))?; + }, + IrcServerMessage::RawLine(_) => { + // nothing + }, + } + + Ok(()) + } +} + +fn main() -> io::Result<()> { + let mut client = Client::new("RustBot", "icedragon", "IceDragon's Rust client thingy"); + client.connect("irc.quickfox.net:6667")?; + client.serve() +}