From cb940dc272a5fe7ad6863ee48d4338d531fdd936 Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Wed, 3 Sep 2025 23:39:49 -0300 Subject: [PATCH 1/8] feat(message): make api less stupid somewhat --- twixel_core/Cargo.toml | 7 +- twixel_core/benches/benches.rs | 75 ++-- twixel_core/src/auth.rs | 79 ++-- twixel_core/src/connection/mod.rs | 95 +++-- twixel_core/src/connection/pool.rs | 35 +- twixel_core/src/irc_message/builder.rs | 65 ++-- twixel_core/src/irc_message/command.rs | 44 ++- twixel_core/src/irc_message/iter.rs | 11 +- twixel_core/src/irc_message/message.rs | 301 ++++++++++------ twixel_core/src/irc_message/mod.rs | 47 ++- twixel_core/src/irc_message/prefix.rs | 43 ++- .../src/irc_message/semantic/clearchat.rs | 8 +- .../src/irc_message/semantic/clearmsg.rs | 10 +- twixel_core/src/irc_message/semantic/mod.rs | 109 +++--- .../src/irc_message/semantic/notice.rs | 4 +- twixel_core/src/irc_message/semantic/ping.rs | 4 +- .../src/irc_message/semantic/privmsg.rs | 10 +- .../src/irc_message/semantic/userstate.rs | 2 +- twixel_core/src/irc_message/tags.rs | 336 +++++++++++++++++- twixel_core/src/lib.rs | 4 +- twixel_core/src/user.rs | 7 +- 21 files changed, 877 insertions(+), 419 deletions(-) diff --git a/twixel_core/Cargo.toml b/twixel_core/Cargo.toml index 527c6af..5f9b0fb 100644 --- a/twixel_core/Cargo.toml +++ b/twixel_core/Cargo.toml @@ -9,7 +9,7 @@ hashbrown = { version = "0.15" } log = "0.4" serde = { version = "1.0", features = ["derive"], optional = true } tokio = { version = "1.35", features = ["sync", "time"] } -tokio-tungstenite = { version = "0.26" } +tokio-tungstenite = { version = "0.26", optional = true} rand = "0.9" thiserror = "2.0" chrono = { version = "0.4", optional = true } @@ -20,7 +20,8 @@ bitflags = "2.4" either = "1.13" [dev-dependencies] -criterion = "0.5" +divan = "0.1" +mimalloc = "0.1" serde_json = "1.0" [[bench]] @@ -28,6 +29,8 @@ name = "benches" harness = false [features] +default = ["connection"] +connection = ["dep:tokio-tungstenite"] native-tls = ["tokio-tungstenite/native-tls"] rustls = [ "tokio-tungstenite/rustls", diff --git a/twixel_core/benches/benches.rs b/twixel_core/benches/benches.rs index 6ea5535..088b838 100644 --- a/twixel_core/benches/benches.rs +++ b/twixel_core/benches/benches.rs @@ -1,49 +1,54 @@ use std::hint::black_box; -use criterion::{Criterion, criterion_group, criterion_main}; +use divan::{counter::BytesCount, Bencher}; +use mimalloc::MiMalloc; use twixel_core::irc_message::{ - builder::MessageBuilder, message::IrcMessage, prefix::OwnedPrefix, tags::OwnedTag, + builder::MessageBuilder, message::IrcMessage, prefix::OwnedPrefix, tags::OwnedTag }; #[cfg(test)] const SHIT_TON: &str = include_str!("../../logs/logs.txt"); +const COUNT: usize = 20000; -fn deserialize_shit_ton(c: &mut Criterion) { - let messages: Vec<&str> = SHIT_TON.lines().take(1000).collect(); +// #[global_allocator] +// static ALLOC: AllocProfiler = AllocProfiler::new(MiMalloc{}); - c.bench_function("Parse a Bunch of xQc's Chat's Logs", |b| { - b.iter_custom(|iterations| { - let start = std::time::Instant::now(); - for _ in 0..iterations { - for message in messages.iter() { - IrcMessage::new(black_box(*message).into()).unwrap(); - } - } - start.elapsed() +#[global_allocator] +static ALLOC: MiMalloc = MiMalloc{}; + +#[divan::bench(threads = [0, 1], min_time = 1)] +fn deserialize_shit_ton(bencher: Bencher) { + bencher + .with_inputs(|| { + SHIT_TON.lines().take(COUNT).collect::>() + }) + .input_counter(|i| { + i.len() + }) + .input_counter(|i| { + BytesCount::new(i.iter().fold(0, |r, i| r + i.len())) }) - }); + .bench_local_values(move |messages| { + for i in messages.into_iter() { + IrcMessage::try_from(black_box(i)).unwrap(); + } + }); } -fn build_and_format_owned_messages(c: &mut Criterion) { - c.bench_function("Build and format MessageBuilder", |b| { - b.iter(|| { - let owned = black_box( - MessageBuilder::privmsg("juliapixel", "hi im julia!") - .add_tag(OwnedTag::Color, "#ffffff") - .add_tag(OwnedTag::DisplayName, "Juliapixel") - .add_tag(OwnedTag::Id, "12345678") - .prefix(OwnedPrefix::OnlyHostname { - host: "juliapixel.com".into(), - }), - ); - black_box(owned.build()); - }) - }); +#[divan::bench] +fn build_and_format_owned_messages() { + let owned = black_box( + MessageBuilder::privmsg("juliapixel", "hi im julia!") + .add_tag(OwnedTag::Color, "#ffffff") + .add_tag(OwnedTag::DisplayName, "Juliapixel") + .add_tag(OwnedTag::Id, "12345678") + .prefix(OwnedPrefix::OnlyHostname { + host: "juliapixel.com".into(), + }), + ); + black_box(owned.build()); } -criterion_group!( - benches, - deserialize_shit_ton, - build_and_format_owned_messages -); -criterion_main!(benches); +fn main() { + divan::main(); +} diff --git a/twixel_core/src/auth.rs b/twixel_core/src/auth.rs index 39731b3..1272a41 100644 --- a/twixel_core/src/auth.rs +++ b/twixel_core/src/auth.rs @@ -1,47 +1,60 @@ +//! Trait and implementations for auth methods for IRC + use std::fmt::Debug; use rand::Rng; use crate::irc_message::{builder::MessageBuilder, command::IrcCommand}; -#[derive(Default, Clone)] -pub enum Auth { - OAuth { - username: String, - token: String, - }, - #[default] - Anonymous, +/// Trait for IRC auth providers +pub trait AuthProvider { + /// Returns a tuple where the first item is the first param to a + /// [`PASS`](crate::irc_message::Pass) message and the second item is the + /// first param to a [`NICK`](crate::irc_message::Nick) message + fn pass_nick(&mut self) -> (String, String); + + /// Provided method that returns a tuple of a [`PASS`](crate::irc_message::Pass) + /// and a [`NICK`](crate::irc_message::Nick) message, to be sent to the IRC + /// server + fn get_commands(&mut self) -> (MessageBuilder<'_>, MessageBuilder<'_>) { + let (pass, nick) = self.pass_nick(); + ( + MessageBuilder::new(IrcCommand::Pass).add_param(pass), + MessageBuilder::new(IrcCommand::Nick).add_param(nick), + ) + } +} + +/// Anonymous login auth implementation +#[derive(Debug, Clone, Copy)] +pub struct Anonymous; + +impl AuthProvider for Anonymous { + fn pass_nick(&mut self) -> (String, String) { + ("POGGERS".into(), format!("justinfan{}", rand::rng().random_range(1..99999))) + } +} + +/// Basic OAuth static auth +#[derive(Clone)] +pub struct OAuth { + /// The OAuth token + pub oauth: String, + /// The associated account's login + pub nick: String, } -impl Auth { - pub fn into_commands(&self) -> (MessageBuilder<'_>, MessageBuilder<'_>) { - match self { - Self::OAuth { username, token } => ( - MessageBuilder::new(IrcCommand::Pass).add_param(format!("oauth:{token}")), - MessageBuilder::new(IrcCommand::Nick).add_param(username.as_str()), - ), - Self::Anonymous => { - let mut rng = rand::rng(); - ( - MessageBuilder::new(IrcCommand::Pass).add_param("POGGERS"), - MessageBuilder::new(IrcCommand::Nick) - .add_param(format!("justinfan{}", rng.random_range(1..99999))), - ) - } - } +impl AuthProvider for OAuth { + fn pass_nick(&mut self) -> (String, String) { + (format!("oauth:{}", self.oauth), self.nick.clone()) } } -impl Debug for Auth { +impl Debug for OAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::OAuth { username, token: _ } => f - .debug_struct("OAuth") - .field("username", username) - .field("token", &"*redacted for privacy*") - .finish(), - Self::Anonymous => write!(f, "Anonymous"), - } + f.debug_struct("Standard") + .field("oauth", &"[REDACTED]") + .field("nick", &self.nick) + .finish() } } diff --git a/twixel_core/src/connection/mod.rs b/twixel_core/src/connection/mod.rs index eda70c5..5dfc177 100644 --- a/twixel_core/src/connection/mod.rs +++ b/twixel_core/src/connection/mod.rs @@ -1,25 +1,19 @@ -use std::task::Poll; +use std::{collections::VecDeque, task::Poll}; use error::ConnectionError; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{Sink, SinkExt, Stream, StreamExt}; use hashbrown::HashSet; use log::{debug, warn}; -use smallvec::SmallVec; use tokio::net::TcpStream; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite::Message as WsMessage}; pub mod pool; -// #[cfg(feature = "unstable")] -// pub mod stream; pub use pool::ConnectionPool; -use crate::{ - auth::Auth, - irc_message::{ - ToIrcMessage, builder::MessageBuilder, command::IrcCommand, message::IrcMessage, - }, -}; +use crate::{auth::AuthProvider, irc_message::{ + builder::MessageBuilder, command::IrcCommand, message::IrcMessage, ToIrcMessage + }}; pub mod error { use thiserror::Error; @@ -39,6 +33,8 @@ pub mod error { TungsteniteError(TungsteniteError), #[error("the received message from the websocket was not a valid IRC message:\n {0}")] InvalidMessage(#[from] IrcMessageParseError), + #[error("the Connection received a websocket message, but no valid content was found")] + NoMessage } #[derive(Debug, Error)] @@ -72,11 +68,12 @@ const TWITCH_IRC_URL: &str = "wss://irc-ws.chat.twitch.tv:443"; type Websocket = WebSocketStream>; /// handles the interface between the raw `Socket` and the `TwitchIrcClient` -pub struct Connection { +pub struct Connection { socket: Option, state: ConnectionState, channel_list: HashSet, - auth_info: Auth, + buffer: VecDeque>, + auth_info: Box, } pub enum ConnectionState { @@ -89,13 +86,14 @@ pub enum ConnectionState { } // TODO: add logging -impl Connection { - pub fn new(channels: impl IntoIterator>, auth: Auth) -> Self { +impl Connection { + pub fn new(channels: impl IntoIterator>, auth: A) -> Self { Self { socket: None, state: ConnectionState::Closed, channel_list: channels.into_iter().map(|i| i.into()).collect(), - auth_info: auth, + buffer: VecDeque::new(), + auth_info: Box::new(auth), } } @@ -112,7 +110,7 @@ impl Connection { self.socket = Some(new_socket); self.state = ConnectionState::StartedUnauthed; - let (pass, nick) = self.auth_info.into_commands(); + let (pass, nick) = self.auth_info.get_commands(); let join_msg = { if !self.channel_list.is_empty() { @@ -132,7 +130,7 @@ impl Connection { if let Some(join_msg) = join_msg { self.feed(join_msg.to_owned()).await?; } - self.flush().await?; + >::flush(self).await?; Ok(()) } @@ -161,25 +159,23 @@ impl Connection { } /// receives twitch messages directly - pub async fn receive(&mut self) -> Result; 4]>, ConnectionError> { + pub async fn receive(&mut self) -> Result { + if let Some(next) = self.buffer.pop_front() { + log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + return next + } + if let Some(socket) = &mut self.socket { let received_msg = socket.next().await.ok_or(ConnectionError::Closed)??; - let mut received = SmallVec::new(); + let mut msgs = IrcMessage::from_ws_message(&received_msg).map(|n| n.map_err(Into::into)); - for recv in IrcMessage::from_ws_message(&received_msg) { - match recv { - Ok(r) => { - if r.get_command() == IrcCommand::AuthSuccessful { - self.state = ConnectionState::Working; - } - received.push(r.to_static()) - } - Err(e) => return Err(e.into()), - } - } + let next = msgs.next().ok_or(ConnectionError::NoMessage)?; + + self.buffer.extend(msgs); - Ok(received) + log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + next } else { Err(ConnectionError::NotStarted) } @@ -233,43 +229,46 @@ impl Connection { self.channel_list.len() } - pub fn to_stream(self) -> impl futures_util::Stream { + pub fn to_stream(self) -> impl Stream { futures_util::stream::unfold(self, |mut state| async move { Some((state.receive().await, state)) }) } } -impl futures_util::Stream for Connection { - type Item = Result; 4]>, ConnectionError>; +impl Stream for Connection { + type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { + if let Some(next) = self.buffer.pop_front() { + log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + return Poll::Ready(Some(next)) + } let Some(socket) = self.socket.as_mut() else { - return Poll::Ready(None); + return Poll::Ready(Some(Err(ConnectionError::NotStarted))); }; let ready = futures_util::ready!(socket.poll_next_unpin(cx)); match ready { Some(Ok(recv)) => { - let mut received = SmallVec::new(); - for msg in IrcMessage::from_ws_message(&recv) { - match msg { - Ok(msg) => received.push(msg.to_static()), - Err(e) => return Poll::Ready(Some(Err(e.into()))), - } - } + let mut msgs = IrcMessage::from_ws_message(&recv).map(|n| n.map_err(Into::into)); + + let next = msgs.next().ok_or(ConnectionError::NoMessage)?; + + self.buffer.extend(msgs); - Poll::Ready(Some(Ok(received))) + log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + Poll::Ready(Some(next)) } Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), - None => todo!(), + None => Poll::Ready(Some(Err(ConnectionError::Closed))), } } } -impl<'a> futures_util::Sink> for Connection { +impl Sink for Connection { type Error = ConnectionError; fn poll_ready( @@ -285,12 +284,12 @@ impl<'a> futures_util::Sink> for Connection { fn start_send( mut self: std::pin::Pin<&mut Self>, - item: MessageBuilder<'a>, + item: T, ) -> Result<(), Self::Error> { self.socket .as_mut() .ok_or(ConnectionError::NotStarted)? - .start_send_unpin(WsMessage::Text(item.build().into())) + .start_send_unpin(WsMessage::Text(item.to_message().into())) .map_err(Into::into) } diff --git a/twixel_core/src/connection/pool.rs b/twixel_core/src/connection/pool.rs index fd8a1c3..b8c793d 100644 --- a/twixel_core/src/connection/pool.rs +++ b/twixel_core/src/connection/pool.rs @@ -3,11 +3,10 @@ use std::task::Poll; use either::Either; use futures_util::{FutureExt, Sink, SinkExt, Stream, future::select_all}; use hashbrown::HashMap; -use smallvec::SmallVec; use crate::{ - auth::Auth, - irc_message::{ToIrcMessage, builder::MessageBuilder, message::IrcMessage}, + auth::AuthProvider, + irc_message::{builder::MessageBuilder, message::IrcMessage, ToIrcMessage}, }; use super::{Connection, error::PoolError}; @@ -15,17 +14,17 @@ use super::{Connection, error::PoolError}; // current limit const MAX_CHANNELS_PER_CONNECTION: usize = 100; -pub struct ConnectionPool { - pool: Vec, +pub struct ConnectionPool { + pool: Vec>, // relation between channel and connection index in the pool channels: HashMap>, - auth_info: Auth, + auth_info: Box, } -impl ConnectionPool { +impl ConnectionPool { pub async fn new( channels: impl IntoIterator>, - auth: Auth, + auth: A, ) -> Result { let mut pool = Vec::new(); let mut channel_list = HashMap::new(); @@ -43,7 +42,7 @@ impl ConnectionPool { Ok(Self { pool, channels: channel_list, - auth_info: auth, + auth_info: Box::new(auth), }) } @@ -76,7 +75,7 @@ impl ConnectionPool { } None => { let mut conn = - Connection::new(core::iter::once(channel_login), self.auth_info.clone()); + Connection::new(core::iter::once(channel_login), (*self.auth_info).clone()); conn.start().await?; self.pool.push(conn); self.channels @@ -132,8 +131,8 @@ impl ConnectionPool { } } -impl Stream for ConnectionPool { - type Item = Result<(SmallVec<[IrcMessage<'static>; 4]>, usize), PoolError>; +impl Stream for ConnectionPool { + type Item = Result<(IrcMessage, usize), PoolError>; fn poll_next( mut self: std::pin::Pin<&mut Self>, @@ -144,7 +143,7 @@ impl Stream for ConnectionPool { } if let Poll::Ready((received, idx, _futures)) = - select_all(self.pool.iter_mut().map(|c| c.receive().boxed())).poll_unpin(cx) + select_all(self.pool.iter_mut().map(|c| Box::pin(c.receive()))).poll_unpin(cx) { let received = received.map_err(Into::::into); Poll::Ready(Some(received.map(|r| (r, idx)))) @@ -154,7 +153,7 @@ impl Stream for ConnectionPool { } } -impl<'a> Sink<(Either, MessageBuilder<'a>)> for ConnectionPool { +impl Sink<(Either, T)> for ConnectionPool { type Error = PoolError; fn poll_ready( @@ -163,7 +162,7 @@ impl<'a> Sink<(Either, MessageBuilder<'a>)> for ConnectionPool { ) -> Poll> { let mut readied = 0; for i in self.pool.iter_mut() { - match futures_util::ready!(i.poll_ready_unpin(cx)) { + match futures_util::ready!( as SinkExt>::poll_ready_unpin(i, cx)) { Ok(()) => readied += 1, Err(e) => return Poll::Ready(Err(e.into())), } @@ -179,7 +178,7 @@ impl<'a> Sink<(Either, MessageBuilder<'a>)> for ConnectionPool { fn start_send( mut self: std::pin::Pin<&mut Self>, - (target, msg): (Either, MessageBuilder<'a>), + (target, msg): (Either, T), ) -> Result<(), Self::Error> { let conn_idx = match target { Either::Left(idx) => idx, @@ -200,7 +199,7 @@ impl<'a> Sink<(Either, MessageBuilder<'a>)> for ConnectionPool { ) -> Poll> { let mut flushed = 0; for i in self.pool.iter_mut() { - match futures_util::ready!(i.poll_flush_unpin(cx)) { + match futures_util::ready!( as SinkExt>::poll_flush_unpin(i, cx)) { Ok(()) => flushed += 1, Err(e) => return Poll::Ready(Err(e.into())), } @@ -220,7 +219,7 @@ impl<'a> Sink<(Either, MessageBuilder<'a>)> for ConnectionPool { ) -> Poll> { let mut closed = 0; for i in self.pool.iter_mut() { - match futures_util::ready!(i.poll_close_unpin(cx)) { + match futures_util::ready!( as SinkExt>::poll_close_unpin(i, cx)) { Ok(()) => closed += 1, Err(e) => return Poll::Ready(Err(e.into())), } diff --git a/twixel_core/src/irc_message/builder.rs b/twixel_core/src/irc_message/builder.rs index 345e0ea..f78afda 100644 --- a/twixel_core/src/irc_message/builder.rs +++ b/twixel_core/src/irc_message/builder.rs @@ -1,6 +1,8 @@ use std::{borrow::Cow, fmt::Write}; -use crate::IrcMessage; +use hashbrown::HashMap; + +use crate::irc_message::PrivMsg; use super::{ToIrcMessage, command::IrcCommand, prefix::OwnedPrefix, tags::OwnedTag}; @@ -10,7 +12,7 @@ pub struct MessageBuilder<'a> { #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_tags"))] #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_tags"))] #[cfg_attr(feature = "serde", serde(borrow))] - pub tags: Vec<(OwnedTag, Cow<'a, str>)>, + pub tags: HashMap>, pub prefix: Option, pub command: IrcCommand, #[cfg_attr(feature = "serde", serde(borrow))] @@ -44,19 +46,19 @@ impl std::fmt::Debug for MessageBuilder<'_> { } impl<'a> MessageBuilder<'a> { + /// Make a new [MessageBuilder] with the Given [IrcCommand] pub fn new(command: IrcCommand) -> Self { Self { - tags: vec![], + tags: HashMap::new(), prefix: None, command, params: vec![], } } - pub fn reply(msg: &'a IrcMessage<'_>, message: &'a str) -> Result { - if msg.get_command() != IrcCommand::PrivMsg { - return Err(MessageBuilderError::WrongMessageType(msg.get_command())); - } + /// Convenience method to make a new `PRIVMSG` that reponds to another `PRIVMSG`, + /// using Twitch's `reply-parent-msg-id` tag. + pub fn reply(msg: &'a PrivMsg, message: &'a str) -> Result { let Some(parent_id) = msg .get_tag(OwnedTag::ReplyThreadParentMsgId) .or(msg.get_tag(OwnedTag::Id)) @@ -69,6 +71,7 @@ impl<'a> MessageBuilder<'a> { ) } + /// Build a finished IRC message in string form pub fn build(self) -> String { let mut out = String::new(); @@ -79,7 +82,7 @@ impl<'a> MessageBuilder<'a> { } else { write!(&mut out, ";").unwrap(); } - write!(&mut out, "{}={}", Into::<&str>::into(&tag.0), tag.1).unwrap() + write!(&mut out, "{}={}", &tag.0, tag.1).unwrap() } if !self.tags.is_empty() { @@ -105,21 +108,25 @@ impl<'a> MessageBuilder<'a> { out } + /// Add new tag-value pair pub fn add_tag(mut self, tag: OwnedTag, value: impl Into>) -> Self { - self.tags.push((tag, value.into())); + self.tags.insert(tag, value.into()); self } + /// Add new param pub fn add_param(mut self, param: impl Into>) -> Self { self.params.push(param.into()); self } + /// Set message prefix pub fn prefix(mut self, prefix: OwnedPrefix) -> Self { let _ = self.prefix.insert(prefix); self } + /// Convenience method to make a new `PRIVMSG` message pub fn privmsg(channel: &'a str, message: &str) -> Self { let chan_param = if channel.starts_with('#') { Cow::Borrowed(channel) @@ -128,13 +135,17 @@ impl<'a> MessageBuilder<'a> { }; Self::new(IrcCommand::PrivMsg) .add_param(chan_param) - .add_param(Cow::Owned(format!(":{message}"))) + .add_param(format!(":{message}")) } + /// Convenience method to repond to data from a `PING` + /// + /// `data` MUST be the `PING` message's last param pub fn pong(data: &'a str) -> Self { - Self::new(IrcCommand::Pong).add_param(Cow::Borrowed(data)) + Self::new(IrcCommand::Pong).add_param(data) } + /// Convenience method to make a new `JOIN` message for many channels pub fn join(channels: impl IntoIterator) -> Self { let mut channel_list = String::new(); for (idx, chan) in channels.into_iter().enumerate() { @@ -146,6 +157,7 @@ impl<'a> MessageBuilder<'a> { Self::new(IrcCommand::Join).add_param(channel_list) } + /// Convenience method to make a new `PART` message for many channels pub fn part(channels: impl IntoIterator) -> Self { let mut channel_list = String::new(); for (idx, chan) in channels.into_iter().enumerate() { @@ -157,12 +169,14 @@ impl<'a> MessageBuilder<'a> { Self::new(IrcCommand::Part).add_param(channel_list) } + /// Convenience method to make a new `CAP REQ` message for Twitch pub fn cap_req() -> Self { Self::new(IrcCommand::Cap) - .add_param(Cow::Borrowed("REQ")) - .add_param(Cow::Borrowed(":twitch.tv/commands twitch.tv/tags")) + .add_param("REQ") + .add_param(":twitch.tv/commands twitch.tv/tags") } + /// Convert from a [MessageBuilder] using borrowed data to using owned data pub fn to_owned(self) -> MessageBuilder<'static> { let mut new = MessageBuilder::<'static>::new(self.command); new.params = self @@ -192,28 +206,28 @@ impl ToIrcMessage for MessageBuilder<'_> { #[cfg(feature = "serde")] fn serialize_tags( - value: &Vec<(OwnedTag, Cow<'_, str>)>, + value: &HashMap>, ser: S, ) -> Result { - ser.collect_map(value.iter().map(|(k, v)| (k, v))) + ser.collect_map(value.iter()) } #[cfg(feature = "serde")] fn deserialize_tags<'de, D: serde::Deserializer<'de>>( deser: D, -) -> Result)>, D::Error> { +) -> Result>, D::Error> { struct MapVisitor; impl<'v> serde::de::Visitor<'v> for MapVisitor { - type Value = Vec<(OwnedTag, Cow<'v, str>)>; + type Value = HashMap>; fn visit_map(self, mut map: A) -> Result where A: serde::de::MapAccess<'v>, { - let mut tags = Vec::new(); + let mut tags = HashMap::new(); while let Some((key, value)) = map.next_entry()? { - tags.push((key, value)); + tags.insert(key, value); } Ok(tags) @@ -228,9 +242,13 @@ fn deserialize_tags<'de, D: serde::Deserializer<'de>>( } #[test] fn message_builder() { + use crate::IrcMessage; + const TEST_MESSAGE: &str = "@tag1=val1;tag2=val2;tag3=val3 :juliapixel!julia@juliapixel.com PRIVMSG #juliapixel :hi hello there!\r\n"; - let owned = MessageBuilder::new(IrcCommand::PrivMsg) + let test_parsed: IrcMessage = TEST_MESSAGE.parse().unwrap(); + + let built = MessageBuilder::new(IrcCommand::PrivMsg) .add_tag(OwnedTag::Unknown("tag1".into()), "val1") .add_tag(OwnedTag::Unknown("tag2".into()), "val2") .add_tag(OwnedTag::Unknown("tag3".into()), "val3") @@ -240,7 +258,10 @@ fn message_builder() { host: "juliapixel.com".into(), }) .add_param("#juliapixel") - .add_param(":hi hello there!"); + .add_param(":hi hello there!") + .build(); + + let built_parsed: IrcMessage = built.parse().unwrap(); - assert_eq!(owned.build(), TEST_MESSAGE); + assert_eq!(built_parsed, test_parsed); } diff --git a/twixel_core/src/irc_message/command.rs b/twixel_core/src/irc_message/command.rs index fedad00..b35bc16 100644 --- a/twixel_core/src/irc_message/command.rs +++ b/twixel_core/src/irc_message/command.rs @@ -5,20 +5,28 @@ use thiserror::Error; macro_rules! commands { ( $name:ident, $error:ident, - [$($var:ident),+] + [ + $( + $(#[$comment:meta])* + $var:ident + ),+ + ] $($key:literal = $val:ident),+ ) => { + /// All of Twitch's supported IRC commands #[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(into = "&'static str", try_from = "&str"))] #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum $name { - $($var,)* + $( + $(#[$comment])* + $var, + )* } + /// An unidentified IRC command was received #[derive(Debug, Clone, PartialEq, Eq, Error)] - pub enum $error { - #[error("the IRC command \"{0}\" was not identified!")] - Failed(String), - } + #[error("the IRC command \"{0}\" was not identified!")] + pub struct $error(String); impl TryFrom<&str> for $name { type Error = $error; @@ -26,7 +34,7 @@ macro_rules! commands { fn try_from(val: &str) -> Result { match val { $($key => Ok(Self::$val),)* - _ => Err($error::Failed(String::from(val))) + _ => Err($error(String::from(val))) } } } @@ -51,27 +59,49 @@ macro_rules! commands { commands! { IrcCommand, IrcCommandError, [ + /// The IRC `PASS` command Pass, + /// The IRC `NICK` command Nick, + /// The IRC `JOIN` command Join, + /// The IRC `PART` command Part, + /// The IRC `NOTICE` command Notice, + /// The IRC `CLEARMSG` command ClearMsg, + /// The IRC `CLEARCHAT` command ClearChat, + /// The IRC `HOSTTARGET` command HostTarget, + /// The IRC `PRIVMSG` command PrivMsg, + /// The IRC `PING` command Ping, + /// The IRC `PONG` command Pong, + /// The IRC `CAP` command Cap, + /// The IRC `GLOBALUSERSTATE` command GlobalUserState, + /// The IRC `USERSTATE` command UserState, + /// The IRC `ROOMSTATE` command RoomState, + /// The IRC `USERNOTICE` command UserNotice, + /// The IRC `RECONNECT` command Reconnect, + /// The IRC `WHISPER` command Whisper, + /// The IRC `421` command UnsupportedError, + /// The IRC `353` and `366` commands UserList, + /// The IRC `001` command AuthSuccessful, + /// Many different IRC commands that are sent during twitch's MOTD messages Useless ] "PASS" = Pass, diff --git a/twixel_core/src/irc_message/iter.rs b/twixel_core/src/irc_message/iter.rs index e67f16c..95bf88e 100644 --- a/twixel_core/src/irc_message/iter.rs +++ b/twixel_core/src/irc_message/iter.rs @@ -4,13 +4,15 @@ use crate::IrcMessage; use super::error::IrcMessageParseError; +/// Iterator over many IRC messages in a single string, separated by CRLF sequences pub struct IrcMessageParseIter<'a> { pos: usize, inner: &'a str, } impl<'a> IrcMessageParseIter<'a> { - pub fn new(text: &'a str) -> Self { + /// Create a new [IrcMessageParseIter] + pub(crate) fn new(text: &'a str) -> Self { Self { inner: text, pos: 0, @@ -19,23 +21,24 @@ impl<'a> IrcMessageParseIter<'a> { } impl<'a> Iterator for IrcMessageParseIter<'a> { - type Item = Result, IrcMessageParseError>; + type Item = Result; fn next(&mut self) -> Option { let next = memchr(b'\n', &self.inner.as_bytes()[self.pos..])?; - let parsed = self.inner[self.pos..=(self.pos + next)].parse::>(); + let parsed = self.inner[self.pos..=(self.pos + next)].parse::(); self.pos += next + 1; Some(parsed) } } +/// Iterate over a user's badges pub struct BadgeIter<'a> { src: &'a str, pos: usize, } impl<'a> BadgeIter<'a> { - pub fn new(src: &'a str) -> Self { + pub(crate) fn new(src: &'a str) -> Self { Self { src, pos: 0 } } } diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index b01a9b4..a0ab81b 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, fmt::Display, ops::Range, slice::Iter, str::FromStr}; +use std::{borrow::Cow, fmt::Display, ops::{Deref, Range}, slice::Iter, str::FromStr}; #[cfg(feature = "serde")] use serde::{ @@ -6,91 +6,189 @@ use serde::{ ser::{SerializeStruct, SerializeStructVariant}, }; use smallvec::SmallVec; +#[cfg(feature = "connection")] use tokio_tungstenite::tungstenite::Message as WsMessage; -use crate::irc_message::{error::IrcMessageStructureError, prefix::RawPrefix, tags::RawIrcTags}; +use crate::irc_message::{error::IrcMessageStructureError, iter::IrcMessageParseIter, prefix::RawPrefix, tags::RawIrcTags}; use super::{ - ToIrcMessage, command::IrcCommand, error::IrcMessageParseError, iter::IrcMessageParseIter, - tags::OwnedTag, + ToIrcMessage, command::IrcCommand, error::IrcMessageParseError, tags::OwnedTag, }; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IrcMessage<'a> { - raw: Cow<'a, str>, +type ParamVec = SmallVec<[Range; 3]>; +type MessageParts = (Option, Option, IrcCommand, ParamVec); + +/// An IRCv3 Message +#[derive(Debug, Clone)] +pub struct IrcMessage { + raw: String, tags: Option, prefix: Option, command: IrcCommand, - params: SmallVec<[Range; 3]>, + params: ParamVec, } -impl<'a> IrcMessage<'a> { - pub fn new(val: Cow<'a, str>) -> Result { - Self::try_from(val) - } +impl IrcMessage { + /// Parses an IRCv3 message into this struct + pub fn new(value: impl Into + Deref) -> Result { + let (tags, prefix, command, params) = Self::get_parts(&value)?; - pub(crate) fn from_ws_message(ws_message: &'a WsMessage) -> IrcMessageParseIter<'a> { - let text = ws_message.to_text().unwrap_or_default(); - - IrcMessageParseIter::new(text) + Ok(Self { + raw: value.into(), + tags, + prefix, + command, + params, + }) } - #[deprecated = "use IrcMessage::to_static instead"] - pub fn to_owned(self) -> IrcMessage<'static> { - self.to_static() + /// Parses multiple IRCv3 messages from one multiline string, where each line + /// is a message + pub fn new_multiline<'a>(value: &'a str) -> IrcMessageParseIter<'a> { + IrcMessageParseIter::new(value) } - pub fn to_static(self) -> IrcMessage<'static> { - IrcMessage::<'static> { - raw: Cow::Owned(self.raw.into_owned()), - tags: self.tags, - prefix: self.prefix, - command: self.command, - params: self.params, + fn get_parts(value: &str) -> Result { + use IrcMessageParseError as E; + + let raw = value; + let mut pos: usize = 0; + + // parses tags if there are any, and sets `pos` to right + // right after the trailing space after the tags + let tags = if raw.starts_with('@') { + let tag_end = memchr::memchr(b' ', raw[..].as_bytes()) + .ok_or(IrcMessageStructureError::MissingTagSeparator)?; + let tags = RawIrcTags::new(raw, 1, tag_end); + pos = tag_end + 1; + tags + } else { + None + }; + + // parses the prefix, if there is one and then sets `pos` + // to the first character of the command + let prefix = if raw[pos..].starts_with(':') { + let prefix_end = memchr::memchr(b' ', &raw.as_bytes()[pos..]) + .ok_or(IrcMessageStructureError::MissingPrefixSeparator)? + + pos; + let out = RawPrefix::parse(raw, pos + 1, prefix_end); + pos = prefix_end + 1; + out + } else { + None + }; + + // splits the command from its parameters (if present) + let cmd = match memchr::memchr2(b' ', b'\r', raw[pos..].as_bytes()) { + Some(s) => { + let cmd = &raw[pos..pos + s]; + pos = pos + s + 1; + cmd + } + None => return Err(E::NoCommand), + }; + + let command = IrcCommand::try_from(cmd)?; + + let mut params = ParamVec::new(); + + let mut last_param_start = pos; + for i in memchr::memchr3_iter(b' ', b'\r', b'\n', raw[pos..].as_bytes()) { + if raw.as_bytes()[last_param_start] == b':' { + params.push( + if let Some(found_end) = memchr::memchr2(b'\r', b'\n', raw[pos..].as_bytes()) { + last_param_start..(found_end + pos) + } else { + last_param_start..raw.len() + }, + ); + break; + } else { + params.push(last_param_start..pos + i); + } + last_param_start = pos + i + 1; } + + Ok((tags, prefix, command, params)) } - pub fn raw(&self) -> &str { + #[cfg(feature = "connection")] + pub(crate) fn from_ws_message<'a>(ws_message: &'a WsMessage) -> IrcMessageParseIter<'a> { + let text = ws_message.to_text().unwrap_or_default(); + + IrcMessageParseIter::new(text) + } + + /// Returns the message's raw string representation + pub fn inner(&self) -> &str { &self.raw } - pub fn into_inner(self) -> Cow<'a, str> { + /// Returns the raw string representation of the message + pub fn into_inner(self) -> String { self.raw } - pub fn badges(&'a self) -> impl Iterator { + /// Iterates over the user's badges + pub fn badges(&self) -> impl Iterator { self.tags .as_ref() - .and_then(|t| t.get_value(&self.raw, OwnedTag::Badges).map(|s| (t, s))) + .and_then(|t| t.get_raw_value(&self.raw, OwnedTag::Badges).map(|s| (t, s))) .map(|(t, src)| t.badge_iter(src)) .into_iter() .flatten() } - pub fn get_tag(&self, tag: OwnedTag) -> Option<&str> { + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no value is present + /// - The value associated with the tag, with escape sequences removed. + /// + /// See also [IrcMessage::get_tag_raw()] + pub fn get_tag(&self, tag: OwnedTag) -> Option> { match &self.tags { Some(s) => s.get_value(&self.raw, tag), None => None, } } + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no value is present + /// - The value associated with the tag, with escape sequences not removed + /// + /// See also [IrcMessage::get_tag()] + pub fn get_tag_raw(&self, tag: OwnedTag) -> Option<&str> { + match &self.tags { + Some(s) => s.get_raw_value(&self.raw, tag), + None => None, + } + } + + /// Iterates over the tags of the message pub fn tags(&self) -> impl Iterator { self.tags .as_ref() - .map(|t| t.iter(self.raw())) + .map(|t| t.iter(self.inner())) .into_iter() .flatten() } #[cfg(feature = "chrono")] + /// Returns the timestamp of the message in UTC time pub fn get_timestamp(&self) -> Option> { self.tags.as_ref().and_then(|t| t.get_timestamp(&self.raw)) } + /// Returns the user's color as RGB8 pub fn get_color(&self) -> Option<[u8; 3]> { self.tags.as_ref().and_then(|t| t.get_color(&self.raw)) } + /// Returns the message's hostname field in its prefix pub fn get_host(&self) -> Option<&str> { match &self.prefix { Some(o) => match o { @@ -105,6 +203,7 @@ impl<'a> IrcMessage<'a> { } } + /// Returns the message's nickname field in its prefix pub fn get_nickname(&self) -> Option<&str> { match &self.prefix { Some(RawPrefix::Full { @@ -116,6 +215,7 @@ impl<'a> IrcMessage<'a> { } } + /// Returns the message's username field in its prefix pub fn get_username(&self) -> Option<&str> { match &self.prefix { Some(RawPrefix::Full { @@ -127,10 +227,12 @@ impl<'a> IrcMessage<'a> { } } + /// Returns the nth parameter of the message pub fn get_param(&self, idx: usize) -> Option<&str> { self.raw.get(self.params.get(idx)?.clone()) } + /// Iterates over the message's parameters pub fn params(&self) -> Params<'_> { Params { src: &self.raw, @@ -138,87 +240,28 @@ impl<'a> IrcMessage<'a> { } } + /// Returns the message's [IrcCommand] pub fn get_command(&self) -> IrcCommand { self.command } } -impl FromStr for IrcMessage<'static> { +impl FromStr for IrcMessage { type Err = IrcMessageParseError; fn from_str(s: &str) -> Result { - Self::try_from(s.to_owned()) + Self::try_from(s) } } -impl<'a> TryFrom> for IrcMessage<'a> { +impl TryFrom<&str> for IrcMessage { type Error = IrcMessageParseError; - #[inline] - fn try_from(value: Cow<'a, str>) -> Result { - use IrcMessageParseError as E; - - let raw = value; - let mut pos: usize = 0; - - // parses tags if there are any, and sets `pos` to right - // right after the trailing space after the tags - let tags = if raw.starts_with('@') { - let tag_end = memchr::memchr(b' ', raw[..].as_bytes()) - .ok_or(IrcMessageStructureError::MissingTagSeparator)?; - let tags = RawIrcTags::new(&raw, 1, tag_end); - pos = tag_end + 1; - tags - } else { - None - }; - - // parses the prefix, if there is one and then sets `pos` - // to the first character of the command - let prefix = if raw[pos..].starts_with(':') { - let prefix_end = memchr::memchr(b' ', &raw.as_bytes()[pos..]) - .ok_or(IrcMessageStructureError::MissingPrefixSeparator)? - + pos; - let out = RawPrefix::parse(&raw, pos + 1, prefix_end); - pos = prefix_end + 1; - out - } else { - None - }; - - // splits the command from its parameters (if present) - let cmd = match memchr::memchr2(b' ', b'\r', raw[pos..].as_bytes()) { - Some(s) => { - let cmd = &raw[pos..pos + s]; - pos = pos + s + 1; - cmd - } - None => return Err(E::NoCommand), - }; - - let command = IrcCommand::try_from(cmd)?; - - let mut params = SmallVec::new(); - - let mut last_param_start = pos; - for i in memchr::memchr3_iter(b' ', b'\r', b'\n', raw[pos..].as_bytes()) { - if raw.as_bytes()[last_param_start] == b':' { - params.push( - if let Some(found_end) = memchr::memchr2(b'\r', b'\n', raw[pos..].as_bytes()) { - last_param_start..(found_end + pos) - } else { - last_param_start..raw.len() - }, - ); - break; - } else { - params.push(last_param_start..pos + i); - } - last_param_start = pos + i + 1; - } + fn try_from(value: &str) -> Result { + let (tags, prefix, command, params) = Self::get_parts(value)?; Ok(Self { - raw, + raw: value.to_string(), tags, prefix, command, @@ -227,34 +270,32 @@ impl<'a> TryFrom> for IrcMessage<'a> { } } -impl<'a> TryFrom<&'a str> for IrcMessage<'a> { - type Error = IrcMessageParseError; - - fn try_from(value: &'a str) -> Result { - Self::try_from(Cow::Borrowed(value)) - } -} - -impl TryFrom for IrcMessage<'static> { +impl TryFrom for IrcMessage { type Error = IrcMessageParseError; + #[inline] fn try_from(value: String) -> Result { - Self::try_from(Cow::Owned(value)) + let (tags, prefix, command, params) = Self::get_parts(&value)?; + + Ok(Self { + raw: value, + tags, + prefix, + command, + params, + }) } } -impl Display for IrcMessage<'_> { +impl Display for IrcMessage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", &*self.raw) } } -impl ToIrcMessage for IrcMessage<'_> { +impl ToIrcMessage for IrcMessage { fn to_message(self) -> String { - match self.raw { - Cow::Borrowed(b) => b.to_string(), - Cow::Owned(o) => o, - } + self.raw } fn get_command(&self) -> IrcCommand { @@ -262,8 +303,36 @@ impl ToIrcMessage for IrcMessage<'_> { } } +impl PartialEq for IrcMessage { + fn eq(&self, other: &IrcMessage) -> bool { + // is this correct?? + // if self.raw.len() != other.raw.len() { return false } + + if let (Some(lhs), Some(rhs)) = (&self.tags, &other.tags) { + if lhs.tags.len() != rhs.tags.len() { return false } + for (kl, vl) in &lhs.tags { + let (kl, vl) = (kl.to_string(&self.raw), &self.raw[vl.clone()]); + let rhs_has_lhs = rhs.tags + .iter() + .any(|(kr,vr)| { + let (kr, vr) = (kr.to_string(&other.raw), &other.raw[vr.clone()]); + kl == kr && vl == vr + }); + if !rhs_has_lhs { return false }; + } + } + self.get_host() == other.get_host() + && self.get_nickname() == other.get_nickname() + && self.get_username() == other.get_username() + && self.command == other.command + && self.params().eq(other.params()) + } +} + +impl Eq for IrcMessage{} + #[cfg(all(feature = "serde", feature = "unstable"))] -impl Serialize for IrcMessage<'_> { +impl Serialize for IrcMessage { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -361,6 +430,7 @@ impl Serialize for IrcMessage<'_> { } } +/// Iterator over an [IrcMessage]'s params pub struct Params<'a> { src: &'a str, iter: Iter<'a, Range>, @@ -375,6 +445,7 @@ impl<'a> Iterator for Params<'a> { } #[test] +#[cfg(feature = "connection")] fn from_ws_message() { const MSGS: &str = "@badge-info=;badges=moments/2;client-nonce=9297a96d510091fa87c81eaa9e5bb8e3;color=#E4E5FF;display-name=MELLOWFLEUR;emotes=;first-msg=0;flags=;id=1ada6902-aafe-452a-8651-1fe711ddd7d1;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910689;turbo=0;user-id=45179149;user-type= :mellowfleur!mellowfleur@mellowfleur.tmi.twitch.tv PRIVMSG #xqc :yes\r @badge-info=;badges=moments/2;client-nonce=da0ef47ebddf148067c685599dd6bc90;color=#8A2BE2;display-name=lonelythomas;emotes=;first-msg=0;flags=;id=91c3b354-95b7-4509-a337-3b86c194b141;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910693;turbo=0;user-id=217061103;user-type= :lonelythomas!lonelythomas@lonelythomas.tmi.twitch.tv PRIVMSG #xqc :LETHIMCOOK\r diff --git a/twixel_core/src/irc_message/mod.rs b/twixel_core/src/irc_message/mod.rs index 52567ba..c358b3e 100644 --- a/twixel_core/src/irc_message/mod.rs +++ b/twixel_core/src/irc_message/mod.rs @@ -10,9 +10,12 @@ pub use command::IrcCommand; pub use message::IrcMessage; pub use semantic::*; +/// Trait for types which can be sent over an IRC connection pub trait ToIrcMessage { + /// Convert to a valid IRC message fn to_message(self) -> String; + /// Get the message's IRC command fn get_command(&self) -> IrcCommand; } @@ -21,30 +24,44 @@ pub mod error { use super::{command::IrcCommandError, tags::IRCTagParseError}; + /// Errors that may occur when parsing an [IrcMessage](crate::IrcMessage) #[derive(Debug, Error)] pub enum IrcMessageParseError { + /// Error when parsing the IRCv3 tags of the message #[error("failed to parse message due to bad tags: {0}")] TagParseError(#[from] IRCTagParseError), + /// The message's prefix could not be found #[error("failed to parse message due to a missing prefix")] NoPrefix, + /// The message's command could not be found #[error("failed to parse message due to a missing command")] NoCommand, + /// The message's command could not be parsed #[error(transparent)] CommandParseError(#[from] IrcCommandError), + /// There was no message to parse #[error("failed to parse message due to a missing message")] NoMessage, + /// There was an error while parsing the message's structure #[error(transparent)] StructureError(#[from] IrcMessageStructureError), + /// The provided message was an empty string #[error("failed to parse message due to it being empty")] Empty, } + /// Structural errors that may occur when parsing an [IrcMessage](crate::IrcMessage) #[derive(Debug, Error)] pub enum IrcMessageStructureError { + /// There was no space character separating the tags segment from the rest + /// of the message #[error("missing separator from tags")] MissingTagSeparator, + /// There was no space character separating the prefix segment from the rest + /// of the message #[error("missing separator from prefix")] MissingPrefixSeparator, + /// There was no CRLF sequence at the end of the message #[error("missing final CRLF sequence in message")] MissingCrlf, } @@ -53,10 +70,8 @@ pub mod error { #[cfg(test)] mod tests { use crate::irc_message::{ - builder::MessageBuilder, command::IrcCommand, message::IrcMessage, - prefix::OwnedPrefix, tags::{OwnedTag, RawTag}, }; @@ -126,38 +141,20 @@ mod tests { #[cfg(all(feature = "serde", feature = "unstable"))] #[test] fn roundtrip_deserialization() { + use crate::MessageBuilder; + const TEST_MESSAGE: &str = "@tag1=val1;tag2=val2;tag3=val3 :juliapixel!julia@juliapixel.com PRIVMSG #juliapixel :hi hello there!\r\n"; let parsed: IrcMessage = TEST_MESSAGE.parse().unwrap(); - let owned = MessageBuilder::new(IrcCommand::PrivMsg) - .add_tag(OwnedTag::Unknown("tag1".into()), "val1") - .add_tag(OwnedTag::Unknown("tag2".into()), "val2") - .add_tag(OwnedTag::Unknown("tag3".into()), "val3") - .prefix(OwnedPrefix::Full { - nickname: "juliapixel".into(), - username: "julia".into(), - host: "juliapixel.com".into(), - }) - .add_param("#juliapixel") - .add_param(":hi hello there!"); - let json_parsed = serde_json::to_string(&parsed).unwrap(); - assert_eq!( - json_parsed, - serde_json::to_string(&owned).unwrap(), - "the OwnedIrcMessage and IrcMessage serde::Serialize implementations don't match" - ); let deserialized_owned: MessageBuilder = serde_json::from_str(&json_parsed).expect(&json_parsed); + + let rebuilt = IrcMessage::new(deserialized_owned.build()).unwrap(); assert_eq!( - deserialized_owned, owned, + parsed, rebuilt, "an OwnedIrcMessage could not be deserialized from a serialized IrcMessage" ); - - assert_eq!( - serde_json::to_string(&deserialized_owned).unwrap(), - json_parsed - ) } } diff --git a/twixel_core/src/irc_message/prefix.rs b/twixel_core/src/irc_message/prefix.rs index 1d99fa3..ea40a40 100644 --- a/twixel_core/src/irc_message/prefix.rs +++ b/twixel_core/src/irc_message/prefix.rs @@ -1,4 +1,4 @@ -use std::{fmt::Display, ops::Range}; +use std::{convert::Infallible, fmt::Display, ops::Range, str::FromStr}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -38,36 +38,49 @@ impl RawPrefix { } } +/// The "prefix" part of the IRC message #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum OwnedPrefix { + /// A prefix which only specifies the hostname OnlyHostname { + /// The hostname host: String, }, + /// A full prefix Full { + /// The nickname segment nickname: String, + /// The username segment username: String, + /// The hostname host: String, }, } +impl FromStr for OwnedPrefix { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + match value.split_once('@').and_then(|(l,r)| Some((l.split_once('!')?, r))) { + Some(((nickname, username), host)) => { + Ok(Self::Full { + nickname: nickname.into(), + username: username.into(), + host: host.into(), + }) + } + None => Ok(Self::OnlyHostname { + host: value.into(), + }), + } + } +} + impl From<&str> for OwnedPrefix { #[inline] fn from(value: &str) -> Self { - match value.split_once('@') { - Some(splits) => { - let (nickname, username) = splits.0.split_once('!').unwrap(); - let hostname = splits.1.to_string(); - Self::Full { - nickname: String::from(nickname), - username: String::from(username), - host: hostname, - } - } - None => Self::OnlyHostname { - host: value.to_string(), - }, - } + value.parse().unwrap() } } diff --git a/twixel_core/src/irc_message/semantic/clearchat.rs b/twixel_core/src/irc_message/semantic/clearchat.rs index d4da0ce..c4b8b0f 100644 --- a/twixel_core/src/irc_message/semantic/clearchat.rs +++ b/twixel_core/src/irc_message/semantic/clearchat.rs @@ -7,17 +7,17 @@ pub enum TimeoutDuration { Temporary(std::time::Duration), } -impl ClearChat<'_> { +impl ClearChat { pub fn target_msg_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::TargetMsgId) + self.get_tag_raw(OwnedTag::TargetMsgId) } pub fn target_user_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::TargetUserId) + self.get_tag_raw(OwnedTag::TargetUserId) } pub fn room_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::RoomId) + self.get_tag_raw(OwnedTag::RoomId) } pub fn duration(&self) -> TimeoutDuration { diff --git a/twixel_core/src/irc_message/semantic/clearmsg.rs b/twixel_core/src/irc_message/semantic/clearmsg.rs index 75f9317..b16c152 100644 --- a/twixel_core/src/irc_message/semantic/clearmsg.rs +++ b/twixel_core/src/irc_message/semantic/clearmsg.rs @@ -2,9 +2,9 @@ use crate::irc_message::tags::OwnedTag; use super::{ClearMsg, util::msg_from_param}; -impl ClearMsg<'_> { +impl ClearMsg { pub fn target_msg_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::TargetMsgId) + self.get_tag_raw(OwnedTag::TargetMsgId) } pub fn message_text(&self) -> &str { @@ -17,14 +17,14 @@ impl ClearMsg<'_> { } pub fn target_user_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::TargetUserId) + self.get_tag_raw(OwnedTag::TargetUserId) } pub fn room_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::RoomId) + self.get_tag_raw(OwnedTag::RoomId) } pub fn target_login(&self) -> Option<&str> { - self.get_tag(OwnedTag::Login) + self.get_tag_raw(OwnedTag::Login) } } diff --git a/twixel_core/src/irc_message/semantic/mod.rs b/twixel_core/src/irc_message/semantic/mod.rs index e17ab98..eafcbcd 100644 --- a/twixel_core/src/irc_message/semantic/mod.rs +++ b/twixel_core/src/irc_message/semantic/mod.rs @@ -15,45 +15,45 @@ use either::Either; use crate::IrcMessage; -pub trait SemanticIrcMessage<'a>: Sized { - fn to_inner(self) -> IrcMessage<'a> - where - Self: 'a; +pub trait SemanticIrcMessage: Sized + private::Sealed { + fn to_inner(self) -> IrcMessage; - fn inner(&self) -> &IrcMessage<'a>; + fn inner(&self) -> &IrcMessage; #[allow(clippy::result_large_err, reason = "intended")] - fn from_message(msg: IrcMessage<'a>) -> Result> - where - Self: 'a; + fn from_message(msg: IrcMessage) -> Result; } -impl<'a, L, R> SemanticIrcMessage<'a> for either::Either +mod private { + pub trait Sealed {} +} + +impl private::Sealed for either::Either +where + L: SemanticIrcMessage, + R: SemanticIrcMessage, +{} + +impl SemanticIrcMessage for either::Either where - L: SemanticIrcMessage<'a>, - R: SemanticIrcMessage<'a>, + L: SemanticIrcMessage, + R: SemanticIrcMessage, { - fn to_inner(self) -> IrcMessage<'a> - where - Self: 'a, - { + fn to_inner(self) -> IrcMessage { match self { either::Either::Left(l) => l.to_inner(), either::Either::Right(r) => r.to_inner(), } } - fn inner(&self) -> &IrcMessage<'a> { + fn inner(&self) -> &IrcMessage { match self { either::Either::Left(l) => l.inner(), either::Either::Right(r) => r.inner(), } } - fn from_message(msg: IrcMessage<'a>) -> Result> - where - Self: 'a, - { + fn from_message(msg: IrcMessage) -> Result { match L::from_message(msg) { Ok(l) => Ok(Either::Left(l)), Err(m) => R::from_message(m).map(|r| Either::Right(r)), @@ -67,30 +67,31 @@ macro_rules! impl_semantic { #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(transparent))] - #[doc = concat!("a semantic wrapper around a ", stringify!($cmd), " [IrcMessage](super::message::IrcMessage)")] - pub struct $cmd<'a> { - inner: $crate::irc_message::message::IrcMessage<'a> + #[doc = concat!("a semantic wrapper around a [", stringify!($cmd), "](crate::IrcCommand::", stringify!($cmd), ") [IrcMessage](super::message::IrcMessage)")] + pub struct $cmd { + inner: $crate::irc_message::message::IrcMessage } - impl<'a> ::std::ops::Deref for $cmd<'a> { - type Target = $crate::irc_message::message::IrcMessage<'a>; + impl ::std::ops::Deref for $cmd { + type Target = $crate::irc_message::message::IrcMessage; fn deref(&self) -> &Self::Target { &self.inner } } - impl<'a> $crate::irc_message::semantic::SemanticIrcMessage<'a> for $cmd<'a> { - fn to_inner(self) -> IrcMessage<'a> - where Self: 'a { + impl private::Sealed for $cmd {} + + impl $crate::irc_message::semantic::SemanticIrcMessage for $cmd { + fn to_inner(self) -> IrcMessage { self.inner } - fn inner(&self) -> &$crate::irc_message::message::IrcMessage<'a> { + fn inner(&self) -> &$crate::irc_message::message::IrcMessage { &self.inner } - fn from_message(msg: $crate::irc_message::message::IrcMessage<'a>) -> Result> { + fn from_message(msg: $crate::irc_message::message::IrcMessage) -> Result { if msg.get_command() == $crate::irc_message::command::IrcCommand::$cmd { Ok(Self { inner: msg }) } else { @@ -99,43 +100,41 @@ macro_rules! impl_semantic { } } - impl<'a> $cmd<'a> { - pub fn from_any(any: AnySemantic<'a>) -> Option { + impl $cmd { + /// Tries to convert from [AnySemantic] to this type + pub fn from_any(any: AnySemantic) -> Option { match any { AnySemantic::$cmd(c) => Some(c), _ => None } } - pub fn from_any_ref(any: &'a AnySemantic<'a>) -> Option<&'a Self> { + /// Tries to convert from [&AnySemantic](AnySemantic) to a reference to this type + pub fn from_any_ref(any: &AnySemantic) -> Option<&Self> { match any { AnySemantic::$cmd(c) => Some(c), _ => None } } - - pub fn to_static(self) -> $cmd<'static> { - $cmd::<'static>::from_message(self.to_inner().to_static()).unwrap() - } } )+ - /// enum containing all semantic wrappers around [](super::message::IrcMessage) + /// enum containing all semantic wrappers around [crate::IrcMessage] #[derive(Debug, Clone)] - pub enum AnySemantic<'a> { - $($cmd($cmd<'a>)),+ + pub enum AnySemantic { + $($cmd($cmd)),+ } - impl<'a> ::std::ops::Deref for AnySemantic<'a> { - type Target = $crate::irc_message::message::IrcMessage<'a>; + impl ::std::ops::Deref for AnySemantic { + type Target = $crate::irc_message::message::IrcMessage; fn deref(&self) -> &Self::Target { &self.inner() } } - impl<'a> From> for AnySemantic<'a> { - fn from(value: IrcMessage<'a>) -> Self { + impl From for AnySemantic { + fn from(value: IrcMessage) -> Self { match value.get_command() { $($crate::irc_message::command::IrcCommand::$cmd => Self::$cmd($cmd::from_message(value).unwrap()),)+ // _ => todo!() @@ -143,34 +142,28 @@ macro_rules! impl_semantic { } } - impl<'a> $crate::irc_message::semantic::SemanticIrcMessage<'a> for AnySemantic<'a> { - fn to_inner(self) -> IrcMessage<'a> - where Self: 'a - { + impl private::Sealed for AnySemantic {} + + impl $crate::irc_message::semantic::SemanticIrcMessage for AnySemantic { + fn to_inner(self) -> IrcMessage { match self { $(Self::$cmd(inner) => inner.to_inner()),+ } } - fn inner(&self) -> &$crate::irc_message::message::IrcMessage<'a> { + fn inner(&self) -> &$crate::irc_message::message::IrcMessage { match self { $(Self::$cmd(inner) => inner.inner()),+ } } - fn from_message(msg: $crate::irc_message::message::IrcMessage<'a>) -> Result> { + fn from_message(msg: $crate::irc_message::message::IrcMessage) -> Result { Ok(Self::from(msg)) } } }; } -impl<'a> AnySemantic<'a> { - pub fn to_static(self) -> AnySemantic<'static> { - AnySemantic::<'static>::from_message(self.to_inner().to_static()).unwrap() - } -} - impl_semantic!( Pass, Nick, @@ -196,8 +189,8 @@ impl_semantic!( Useless ); -impl Display for AnySemantic<'_> { +impl Display for AnySemantic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.inner().raw()) + f.write_str(self.inner().inner()) } } diff --git a/twixel_core/src/irc_message/semantic/notice.rs b/twixel_core/src/irc_message/semantic/notice.rs index b139e79..54a860a 100644 --- a/twixel_core/src/irc_message/semantic/notice.rs +++ b/twixel_core/src/irc_message/semantic/notice.rs @@ -2,7 +2,7 @@ use crate::irc_message::tags::OwnedTag; use super::{Notice, util::msg_from_param}; -impl Notice<'_> { +impl Notice { pub fn message_text(&self) -> &str { let msg_param = self .inner @@ -24,7 +24,7 @@ impl Notice<'_> { } pub fn target_user_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::TargetUserId) + self.get_tag_raw(OwnedTag::TargetUserId) } pub fn kind(&self) -> Option> { diff --git a/twixel_core/src/irc_message/semantic/ping.rs b/twixel_core/src/irc_message/semantic/ping.rs index 28e7d07..2002854 100644 --- a/twixel_core/src/irc_message/semantic/ping.rs +++ b/twixel_core/src/irc_message/semantic/ping.rs @@ -2,8 +2,8 @@ use crate::MessageBuilder; use super::Ping; -impl Ping<'_> { - pub fn respond(&self) -> MessageBuilder<'_> { +impl Ping { + pub fn respond(&'_ self) -> MessageBuilder<'_> { MessageBuilder::pong(self.get_param(0).unwrap()) } } diff --git a/twixel_core/src/irc_message/semantic/privmsg.rs b/twixel_core/src/irc_message/semantic/privmsg.rs index 7a2ad7e..a566bcb 100644 --- a/twixel_core/src/irc_message/semantic/privmsg.rs +++ b/twixel_core/src/irc_message/semantic/privmsg.rs @@ -2,7 +2,7 @@ use crate::{MessageBuilder, irc_message::tags::OwnedTag, user::ChannelRoles}; use super::{PrivMsg, util::msg_from_param}; -impl PrivMsg<'_> { +impl PrivMsg { // TODO: treat repeat message avoiders pub fn message_text(&self) -> &str { let msg_param = self @@ -46,11 +46,11 @@ impl PrivMsg<'_> { } pub fn sender_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::UserId) + self.get_tag_raw(OwnedTag::UserId) } pub fn channel_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::RoomId) + self.get_tag_raw(OwnedTag::RoomId) } pub fn channel_login(&self) -> &str { @@ -67,8 +67,8 @@ impl PrivMsg<'_> { /// message ID to be used in the ReplyParentMsgId tag when replying pub fn reply_to_id(&self) -> Option<&str> { - self.get_tag(OwnedTag::ReplyThreadParentMsgId) - .or_else(|| self.get_tag(OwnedTag::Id)) + self.get_tag_raw(OwnedTag::ReplyThreadParentMsgId) + .or_else(|| self.get_tag_raw(OwnedTag::Id)) } pub fn reply_to(&self, msg: &str) -> MessageBuilder<'_> { diff --git a/twixel_core/src/irc_message/semantic/userstate.rs b/twixel_core/src/irc_message/semantic/userstate.rs index 6e8410c..288c441 100644 --- a/twixel_core/src/irc_message/semantic/userstate.rs +++ b/twixel_core/src/irc_message/semantic/userstate.rs @@ -2,7 +2,7 @@ use crate::{irc_message::tags::OwnedTag, user::ChannelRoles}; use super::UserState; -impl UserState<'_> { +impl UserState { pub fn channel_login(&self) -> &str { self.get_param(0) .expect("malformed channel login param") diff --git a/twixel_core/src/irc_message/tags.rs b/twixel_core/src/irc_message/tags.rs index 0b9e43d..e84fed2 100644 --- a/twixel_core/src/irc_message/tags.rs +++ b/twixel_core/src/irc_message/tags.rs @@ -1,13 +1,89 @@ #[cfg(feature = "chrono")] use chrono::{DateTime, Utc}; +use hashbrown::HashMap; use memchr::memchr_iter; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use std::ops::Range; +use std::{borrow::Cow, ops::Range}; use thiserror::Error; use super::iter::BadgeIter; +enum Escape { + Space, + Backslash, + Cr, + Lf, + Semicolon, + Other, + TrailingSlash +} + +fn find_escape_seq(val: &str) -> Option<(Escape, Range)> { + let backslash = val.find("\\")?; + let Some(next) = val[backslash..].chars().nth(1) else { + return Some((Escape::TrailingSlash, backslash..backslash + 1)) + }; + + let range = backslash..(backslash + 1 + next.len_utf8()); + + match next { + 's' => Some((Escape::Space, range)), + '\\' => Some((Escape::Backslash, range)), + 'r' => Some((Escape::Cr, range)), + 'n' => Some((Escape::Lf, range)), + ':' => Some((Escape::Semicolon, range)), + _ => Some((Escape::Other, range)), + } +} + +fn unescape_tag_value(val: &str) -> Cow<'_, str> { + let mut pos = 0; + let mut out = String::with_capacity(val.len()); + while let Some((esc, range)) = find_escape_seq(&val[pos..]) { + out.push_str(&val[pos..(pos + range.start)]); + out.push_str(match esc { + Escape::Space => " ", + Escape::Backslash => "\\", + Escape::Cr => "\r", + Escape::Lf => "\n", + Escape::Semicolon => ";", + Escape::Other => &val[(pos + range.start + 1)..(pos + range.end)], + Escape::TrailingSlash => "", + }); + pos += range.end + } + if out.is_empty() { + Cow::Borrowed(val) + } else { + out.push_str(&val[pos..]); + Cow::Owned(out) + } +} + +fn escape_tag_value(val: &str) -> Cow<'_, str> { + let mut last = 0; + let mut out = String::new(); + for (idx, escapable) in val.match_indices(['\\', ' ', '\r', '\n', ';']) { + out.push_str(&val[last..idx]); + out.push_str(match escapable { + "\\" => "\\\\", + " " => "\\s", + "\r" => "\\r", + "\n" => "\\n", + ";" => "\\:", + _ => unreachable!() + }); + last = idx + 1; + } + if out.is_empty() { + Cow::Borrowed(val) + } else { + out.push_str(&val[last..]); + Cow::Owned(out) + } +} + macro_rules! raw_tags { ( $(#[$top_comment:meta])* @@ -18,14 +94,16 @@ macro_rules! raw_tags { ),* ) => { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] - #[derive(Debug, PartialEq, Eq, Clone)] + #[derive(Debug, PartialEq, Eq, Clone, Hash)] #[non_exhaustive] $(#[$top_comment])* - pub enum $raw_tag { + pub(crate) enum $raw_tag { $( + #[doc = concat!("the \"", $key, "\" tag")] $(#[$comment])* $name, )+ + /// An unknown tag key value Unknown(Range) } @@ -34,7 +112,6 @@ macro_rules! raw_tags { match &src[range.clone()] { $($key => Self::$name,)* _ => { - log::warn!("unknown tag parsed! please notify the developers of this issue: {:?}", &src[range.clone()]); Self::Unknown(range) } } @@ -57,14 +134,16 @@ macro_rules! raw_tags { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", serde(into = "String", from = "&str"))] - #[derive(Debug, PartialEq, Eq, Clone)] + #[derive(Debug, PartialEq, Eq, Clone, Hash)] #[non_exhaustive] $(#[$top_comment])* pub enum $tag { $( + #[doc = concat!("the \"", $key, "\" tag")] $(#[$comment])* $name, )+ + /// An unknown tag key value Unknown(String) } @@ -77,6 +156,15 @@ macro_rules! raw_tags { } } + impl ::std::fmt::Display for $tag { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match self { + $($tag::$name => f.write_str($key),)+ + $tag::Unknown(val) => f.write_str(val) + } + } + } + impl From<$tag> for String { fn from(val: $tag) -> String { match val { @@ -207,12 +295,16 @@ raw_tags!( "custom-reward-id" = CustomRewardId ); +/// Error enum for erros when parsing tags #[derive(Debug, Error)] pub enum IRCTagParseError { + /// The structure of the tags did not match what was expected #[error("failed to parse the tag due to invalid structure: {0}")] TagStructureParseError(String), + /// Unknown error #[error("failed to parse the tag due to unknown error: {0}")] ContentParseFailed(String), + /// Tag identifier was not a known value #[error("tag identifier not recognized: {0}")] UnknownIdentifier(String), } @@ -220,7 +312,8 @@ pub enum IRCTagParseError { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RawIrcTags { /// first item is the [RawTag] enum and the second is the position of the tag's value - pub(crate) tags: Vec<(RawTag, Range)>, + // FIXME: makes duplicates for Unknown tags + pub(crate) tags: HashMap>, } impl RawIrcTags { @@ -228,7 +321,7 @@ impl RawIrcTags { /// the leading `@` and the trailing space #[inline] pub(crate) fn new(raw: &str, raw_start_idx: usize, raw_end_idx: usize) -> Option { - let mut tags = Vec::new(); + let mut tags = HashMap::new(); // position of last found start of tag let mut last_pos: usize = raw_start_idx; @@ -237,22 +330,48 @@ impl RawIrcTags { let pos = i + raw_start_idx + 1; // positon of current parsed tag's divider - let divider = memchr::memchr(b'=', &raw.as_bytes()[last_pos..pos - 1])? + last_pos; - tags.push((RawTag::parse(raw, last_pos..divider), divider + 1..pos - 1)); + if let Some(divider) = memchr::memchr(b'=', &raw.as_bytes()[last_pos..pos - 1]) { + let divider = divider + last_pos; + tags.insert(RawTag::parse(raw, last_pos..divider), divider + 1..pos - 1); + } else { + tags.insert(RawTag::parse(raw, last_pos..pos-1), pos-1..pos-1); + } last_pos = pos; } + // parsing the last tag - let divider = memchr::memchr(b'=', &raw.as_bytes()[last_pos..])? + last_pos; - tags.push(( - RawTag::parse(raw, last_pos..divider), - divider + 1..raw_end_idx, - )); + if let Some(divider) = memchr::memchr(b'=', &raw.as_bytes()[last_pos..]).map(|d| d + last_pos) { + tags.insert( + RawTag::parse(raw, last_pos..divider), + divider + 1..raw_end_idx, + ); + } else { + tags.insert( + RawTag::parse(raw, last_pos..raw_end_idx), + raw_end_idx..raw_end_idx, + ); + } Some(Self { tags }) } - pub fn get_value<'a>(&self, src: &'a str, tag: OwnedTag) -> Option<&'a str> { + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no key is present + /// - The value associated with the tag, with escape sequences removed + pub fn get_value<'a>(&self, src: &'a str, tag: OwnedTag) -> Option> { + let found = self.tags.iter().find(|t| t.0.to_owned(src) == tag)?; + src.get(found.1.clone()).map(unescape_tag_value) + } + + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no key is present + /// - The value associated with the tag, with escape sequences not removed + pub fn get_raw_value<'a>(&self, src: &'a str, tag: OwnedTag) -> Option<&'a str> { let found = self.tags.iter().find(|t| t.0.to_owned(src) == tag)?; src.get(found.1.clone()) } @@ -292,10 +411,11 @@ impl RawIrcTags { } } +/// An iterator for every tag in an [IrcMessage](crate::IrcMessage) #[derive(Debug, Clone)] pub struct TagsIter<'a> { src: &'a str, - iter: core::slice::Iter<'a, (RawTag, std::ops::Range)>, + iter: hashbrown::hash_map::Iter<'a, RawTag, std::ops::Range>, } impl<'a> TagsIter<'a> { @@ -316,3 +436,187 @@ impl<'a> Iterator for TagsIter<'a> { .map(|(rt, range)| (rt.to_owned(self.src), &self.src[range.clone()])) } } + +#[cfg(test)] +mod tests { + use crate::irc_message::tags::{escape_tag_value, unescape_tag_value, OwnedTag, RawIrcTags}; + + #[test] + fn parse_normal() { + let source = "buh=123;vip=1;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("123")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn parse_empty() { + let source = "buh=;vip=1;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn parse_empty_trailing() { + let source = "vip=1;color=#123123;buh"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + } + + #[test] + fn parse_empty_no_equals() { + let source = "buh;vip=1;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + dbg!(tags.iter(source)); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn parse_multi() { + let source = "vip=123;vip=321;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("321")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn parse_multi_unknown() { + let source = "buh=123;buh=321;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("321")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn parse_multi_unknown2() { + let source = "buh=123;buh=321;buh=hub;color=#123123"; + + let tags = RawIrcTags::new(source, 0, source.len()) + .expect("failed to parse tags from string"); + + assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("hub")); + assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); + } + + #[test] + fn unescape_tags() { + let space = "Hello,\\sworld!"; + assert_eq!( + unescape_tag_value(space), + "Hello, world!" + ); + + let semicolon = "semi\\:"; + assert_eq!( + unescape_tag_value(semicolon), + "semi;" + ); + + let backslash = "\\\\/"; + assert_eq!( + unescape_tag_value(backslash), + "\\/" + ); + + let backslash_s = "\\\\s"; + assert_eq!( + unescape_tag_value(backslash_s), + "\\s" + ); + + let fake = "\\b"; + assert_eq!( + unescape_tag_value(fake), + "b" + ); + + let multi = "\\s\\s"; + assert_eq!( + unescape_tag_value(multi), + " " + ); + + let all = "\\\\\\s\\:\\r\\n\\a"; + assert_eq!( + unescape_tag_value(all), + "\\ ;\r\na" + ); + + let trailing = "test\\"; + assert_eq!( + unescape_tag_value(trailing), + "test" + ); + } + + #[test] + fn escape_tags() { + let space = "Hello, world!"; + assert_eq!( + escape_tag_value(space), + "Hello,\\sworld!" + ); + + let semicolon = "semi;"; + assert_eq!( + escape_tag_value(semicolon), + "semi\\:" + ); + + let backslash = "\\/"; + assert_eq!( + escape_tag_value(backslash), + "\\\\/" + ); + + let space = " "; + assert_eq!( + escape_tag_value(space), + "\\s" + ); + + let fake = "\\b"; + assert_eq!( + escape_tag_value(fake), + "\\\\b" + ); + + let multi = " "; + assert_eq!( + escape_tag_value(multi), + "\\s\\s" + ); + + let all = "\\ ;\r\n\\a"; + assert_eq!( + escape_tag_value(all), + "\\\\\\s\\:\\r\\n\\\\a" + ); + } +} diff --git a/twixel_core/src/lib.rs b/twixel_core/src/lib.rs index d74c3a6..26b4902 100644 --- a/twixel_core/src/lib.rs +++ b/twixel_core/src/lib.rs @@ -1,13 +1,15 @@ #![deny(clippy::missing_safety_doc)] +#![warn(missing_docs)] // utf-8 char boundary checking is cool #![allow(clippy::sliced_string_as_bytes)] pub mod auth; +#[cfg(feature = "connection")] pub mod connection; pub mod irc_message; pub mod user; -pub use crate::auth::Auth; +#[cfg(feature = "connection")] pub use crate::connection::{Connection, ConnectionPool}; pub use crate::irc_message::builder::MessageBuilder; pub use crate::irc_message::command::IrcCommand; diff --git a/twixel_core/src/user.rs b/twixel_core/src/user.rs index 432ef11..dc82b3e 100644 --- a/twixel_core/src/user.rs +++ b/twixel_core/src/user.rs @@ -1,10 +1,15 @@ bitflags::bitflags! { + /// Bitflags indicating a user's roles in a channel #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Default, Debug)] pub struct ChannelRoles: u8 { + /// Whether the user is a moderator const Moderator = 1; + /// Whether the user is a VIP const Vip = 1 << 1; + /// Whether the user is a subscriber const Subscriber = 1 << 2; + /// Whether the user is the broadcaster const Broadcaster = 1 << 3; } } @@ -15,7 +20,7 @@ impl ChannelRoles { .union(ChannelRoles::Vip) .union(ChannelRoles::Broadcaster); - /// whether you have higher chat privileges in IRC + /// `true` if the user has higher chat privileges in IRC pub fn is_privileged(&self) -> bool { self.intersects(Self::PRIVILEGED_MASK) } From 1aab07012942e3dcc735a989147b6589299af9fc Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Fri, 23 Jan 2026 03:51:05 -0300 Subject: [PATCH 2/8] yolo idek what is in this commit i just want it to go away --- Cargo.lock | 617 +++++++----------- twixel/Cargo.toml | 2 +- twixel/src/bot.rs | 33 +- twixel/src/commands.rs | 2 + twixel/src/commands/raw.rs | 23 + twixel/src/eval.rs | 5 +- twixel/src/guard.rs | 4 +- twixel/src/handler.rs | 9 +- twixel/src/handler/extract.rs | 32 +- twixel/src/handler/response.rs | 2 + twixel/src/main.rs | 5 +- twixel/src/util/db.rs | 2 +- twixel_core/src/connection/mod.rs | 12 +- twixel_core/src/irc_message/builder.rs | 25 +- twixel_core/src/irc_message/mod.rs | 1 + .../src/irc_message/semantic/clearchat.rs | 28 +- .../src/irc_message/semantic/clearmsg.rs | 17 +- twixel_core/src/irc_message/semantic/mod.rs | 27 +- .../src/irc_message/semantic/notice.rs | 9 +- twixel_core/src/irc_message/semantic/ping.rs | 4 +- .../src/irc_message/semantic/privmsg.rs | 23 +- .../src/irc_message/semantic/userstate.rs | 8 + twixel_core/src/irc_message/semantic/util.rs | 4 +- twixel_core/src/irc_message/tags.rs | 4 +- twixel_core/src/user.rs | 3 + 25 files changed, 444 insertions(+), 457 deletions(-) create mode 100644 twixel/src/commands/raw.rs diff --git a/Cargo.lock b/Cargo.lock index 0541505..d76ccd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,17 +62,11 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -100,22 +94,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -132,9 +126,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -144,9 +138,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.24" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d615619615a650c571269c00dca41db04b9210037fa76ed8239f70404ab56985" +checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" dependencies = [ "brotli", "flate2", @@ -160,9 +154,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ "event-listener", "event-listener-strategy", @@ -197,9 +191,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" @@ -243,7 +237,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -298,9 +292,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byteorder" @@ -314,17 +308,11 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" -version = "1.2.26" +version = "1.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2" dependencies = [ "jobserver", "libc", @@ -367,33 +355,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "clang-sys" version = "1.8.1" @@ -407,9 +368,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.5.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "ed87a9d530bb41a67537289bafcac159cb3ee28460e0a4571123d2a778a6a882" dependencies = [ "clap_builder", "clap_derive", @@ -417,21 +378,22 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "64f4f3f3c77c94aff3c7e9aac9a2ca1974a5adf392a8bb751e827d6d127ab966" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" dependencies = [ "heck", "proc-macro2", @@ -460,11 +422,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "config" -version = "0.15.11" +version = "0.15.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595aae20e65c3be792d05818e8c63025294ac3cb7e200f11459063a352a6ef80" +checksum = "5b1eb4fb07bc7f012422df02766c7bd5971effb894f573865642f06fa3265440" dependencies = [ "async-trait", "convert_case", @@ -566,68 +534,13 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -645,9 +558,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" @@ -713,6 +626,31 @@ dependencies = [ "syn", ] +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -777,12 +715,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -798,9 +736,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1019,9 +957,9 @@ dependencies = [ [[package]] name = "getset" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3586f256131df87204eb733da72e3d3eb4f343c639f4b7be279ac7c48baeafe" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -1043,9 +981,9 @@ checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "h2" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" dependencies = [ "atomic-waker", "bytes", @@ -1060,16 +998,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" -dependencies = [ - "cfg-if", - "crunchy", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1103,12 +1031,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1221,9 +1143,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -1237,7 +1159,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -1382,14 +1304,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown 0.15.4", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1406,32 +1339,12 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.12.1" @@ -1449,9 +1362,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a194df1107f33c79f4f93d02c80798520551949d59dfad22b6157048a88cca93" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", @@ -1462,9 +1375,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c6e1db7ed32c6c71b759497fae34bf7933636f75a251b9e736555da426f6442" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", @@ -1519,9 +1432,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" @@ -1530,7 +1443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.0", + "windows-targets 0.53.3", ] [[package]] @@ -1539,6 +1452,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libmimalloc-sys" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88cd67e9de251c1781dbe2f641a1a3ad66eaae831b8a2c38fbdc5ddae16d4d" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1606,6 +1529,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "mimalloc" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1791cbe101e95af5764f06f20f6760521f7158f69dbf9d6baf941ee1bf6bc40" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -1733,12 +1665,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "openssl" version = "0.10.73" @@ -1795,9 +1721,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.1" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" [[package]] name = "parking" @@ -1851,9 +1777,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", "thiserror", @@ -1862,9 +1788,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" dependencies = [ "pest", "pest_generator", @@ -1872,9 +1798,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" dependencies = [ "pest", "pest_meta", @@ -1885,11 +1811,10 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" dependencies = [ - "once_cell", "pest", "sha2", ] @@ -1933,34 +1858,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -1996,9 +1893,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.33" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" dependencies = [ "proc-macro2", "syn", @@ -2057,7 +1954,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2", + "socket2 0.5.10", "thiserror", "tokio", "tracing", @@ -2073,7 +1970,7 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand 0.9.1", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", "rustls", @@ -2087,14 +1984,14 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.12" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -2110,9 +2007,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" @@ -2127,9 +2024,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -2173,31 +2070,11 @@ dependencies = [ "getrandom 0.3.3", ] -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" -version = "0.5.12" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] @@ -2225,6 +2102,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2239,9 +2122,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "async-compression", "base64 0.22.1", @@ -2379,20 +2262,19 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +checksum = "e7295b7ce3bf4806b419dc3420745998b447178b7005e2011947b38fc5aa6791" dependencies = [ "cfg-if", "ordered-multimap", - "trim-in-place", ] [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -2421,22 +2303,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "once_cell", "ring", @@ -2470,9 +2352,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -2491,15 +2373,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.27" @@ -2573,9 +2446,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -2585,9 +2458,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" dependencies = [ "serde", ] @@ -2634,9 +2507,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -2653,12 +2526,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" [[package]] name = "smallvec" @@ -2690,6 +2560,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -2932,9 +2812,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.102" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6397daf94fa90f058bd0fd88429dd9e5738999cca8d701813c80723add80462" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -2970,7 +2850,17 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", + "rustix 1.0.8", + "windows-sys 0.59.0", +] + +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -3013,16 +2903,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.9.0" @@ -3040,20 +2920,22 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3118,29 +3000,29 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", - "hashbrown 0.15.4", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.8.23" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.7.0", + "toml_parser", + "winnow", ] [[package]] @@ -3148,6 +3030,12 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" dependencies = [ "serde", ] @@ -3159,9 +3047,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "serde", - "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ "winnow", ] @@ -3224,9 +3119,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -3242,12 +3137,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "trim-in-place" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" - [[package]] name = "try-lock" version = "0.2.5" @@ -3266,7 +3155,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.1", + "rand 0.9.2", "rustls", "rustls-pki-types", "sha1", @@ -3293,7 +3182,7 @@ dependencies = [ "log", "owo-colors", "parking_lot", - "rand 0.9.1", + "rand 0.9.2", "regex", "reqwest", "rquickjs", @@ -3314,13 +3203,14 @@ version = "0.1.0" dependencies = [ "bitflags", "chrono", - "criterion", + "divan", "either", "futures-util", "hashbrown 0.15.4", "log", "memchr", - "rand 0.9.1", + "mimalloc", + "rand 0.9.2", "serde", "serde_json", "smallvec", @@ -3421,16 +3311,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -3554,9 +3434,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] @@ -3583,15 +3463,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -3629,9 +3500,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-result" @@ -3678,6 +3549,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3711,10 +3591,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -3865,9 +3746,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] @@ -3924,18 +3805,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", @@ -3982,9 +3863,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", diff --git a/twixel/Cargo.toml b/twixel/Cargo.toml index 00ac73a..d885287 100644 --- a/twixel/Cargo.toml +++ b/twixel/Cargo.toml @@ -28,7 +28,7 @@ sqlx = { version = "0.8", features = ["chrono", "derive", "json", "macros", "mig thiserror = "2.0" tokio = { version = "1.45", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -twixel_core = { path = "../twixel_core", features = ["rustls", "chrono"] } +twixel_core = { path = "../twixel_core", features = ["rustls", "chrono", "connection", "serde", "unstable"] } unicode-segmentation = "1.12" [dependencies.reqwest] diff --git a/twixel/src/bot.rs b/twixel/src/bot.rs index 389d72d..48ff602 100644 --- a/twixel/src/bot.rs +++ b/twixel/src/bot.rs @@ -4,8 +4,7 @@ use futures::StreamExt; use hashbrown::HashMap; use tokio::signal::unix::{SignalKind, signal}; use twixel_core::{ - Auth, ConnectionPool, MessageBuilder, - irc_message::{AnySemantic, PrivMsg, tags::OwnedTag}, + auth::OAuth, irc_message::{tags::OwnedTag, AnySemantic, PrivMsg}, ConnectionPool, MessageBuilder }; use crate::{ @@ -41,7 +40,7 @@ impl BotData { } pub struct Bot { - conn_pool: ConnectionPool, + conn_pool: ConnectionPool, commands: Vec, catchall: Vec, data: BotData, @@ -87,7 +86,7 @@ impl Bot { Self { conn_pool: ConnectionPool::new( core::iter::empty::(), - Auth::OAuth { username, token }, + OAuth{ nick: username, oauth: token }, ) .await .unwrap(), @@ -123,7 +122,7 @@ impl Bot { /// Returns whether to shut down or not async fn handle_cmd( - conn_pool: &mut ConnectionPool, + conn_pool: &mut ConnectionPool, cmd: BotCommand, last_sent_msg: &mut HashMap, ) -> bool { @@ -202,19 +201,17 @@ impl Bot { loop { tokio::select! { // Handle message received from twitch IRC - Some(recv) = self.conn_pool.next() => { - let idx = recv.as_ref().map(|r| r.1).ok(); - for msg in recv.map(|r| r.0).into_iter().flatten() { - let cx = HandlerContext { - msg: msg.into(), - connection_idx: idx.unwrap(), - bot_tx: self.cmd_tx.clone(), - data_store: Arc::clone(&data_store) - }; + Some(msg) = self.conn_pool.next() => { + let (msg, idx) = msg.unwrap(); + let cx = HandlerContext { + msg: msg.into(), + connection_idx: idx, + bot_tx: self.cmd_tx.clone(), + data_store: Arc::clone(&data_store) + }; - let new_tx = tx.clone(); - tokio::spawn(async move { new_tx.send(cx).await.unwrap(); }); - } + let new_tx = tx.clone(); + tokio::spawn(async move { new_tx.send(cx).await.unwrap(); }); } // Handle bot actions cmd = self.cmd_rx.recv() => { match cmd { @@ -301,7 +298,7 @@ async fn bot_worker( continue; } msg => { - log::warn!("untreated message kind: {:?}", msg.raw()); + log::warn!("untreated message kind: {:?}", msg.inner()); continue; } } diff --git a/twixel/src/commands.rs b/twixel/src/commands.rs index dcb6920..bf22766 100644 --- a/twixel/src/commands.rs +++ b/twixel/src/commands.rs @@ -4,6 +4,7 @@ mod catfact; mod gpt; mod join; mod part; +mod raw; mod remindfish; mod sql; mod strdbg; @@ -16,6 +17,7 @@ pub use catfact::*; pub use gpt::*; pub use join::*; pub use part::*; +pub use raw::*; pub use remindfish::*; pub use sql::*; pub use strdbg::*; diff --git a/twixel/src/commands/raw.rs b/twixel/src/commands/raw.rs new file mode 100644 index 0000000..ba11e65 --- /dev/null +++ b/twixel/src/commands/raw.rs @@ -0,0 +1,23 @@ +use reqwest::Client; +use serde::Deserialize; +use twixel_core::irc_message::PrivMsg; + +#[derive(Debug, Deserialize)] +struct HasteResp { + key: String +} + +pub async fn raw(msg: PrivMsg) -> String { + + let resp = Client::new().post("https://haste.potat.app/documents") + .body(reqwest::Body::from(serde_json::to_string_pretty(&*msg).unwrap())) + .send() + .await; + let Ok(r) = resp else { + return "Error: couldnt upload to hastebin lole".into(); + }; + let Ok(key) = r.json::().await.map(|b| b.key) else { + return "Error: couldnt deserialize hastebin response lole".into(); + }; + format!("https://haste.potat.app/{key}") +} diff --git a/twixel/src/eval.rs b/twixel/src/eval.rs index f332362..458a0c4 100644 --- a/twixel/src/eval.rs +++ b/twixel/src/eval.rs @@ -153,10 +153,7 @@ fn repl_print_value(val: Value<'_>) -> LocalBoxFuture<'_, String> { Type::String => val .as_string() .map(|i| { - format!( - "\"{}\"", - i.to_string().unwrap_or("invalid UTF-8 string".into()) - ) + i.to_string().unwrap_or("invalid UTF-8 string".into()) }) .unwrap(), Type::Array | Type::Exception | Type::Object | Type::Module | Type::Unknown => ctx diff --git a/twixel/src/guard.rs b/twixel/src/guard.rs index 60d01aa..27a999b 100644 --- a/twixel/src/guard.rs +++ b/twixel/src/guard.rs @@ -13,7 +13,7 @@ use crate::bot::BotData; pub struct GuardContext<'a> { pub data_store: &'a BotData, - pub message: &'a AnySemantic<'a>, + pub message: &'a AnySemantic, } impl<'a> GuardContext<'a> { @@ -21,7 +21,7 @@ impl<'a> GuardContext<'a> { self.data_store } - pub fn message(&self) -> &AnySemantic<'a> { + pub fn message(&self) -> &AnySemantic { self.message } } diff --git a/twixel/src/handler.rs b/twixel/src/handler.rs index 2c3eb9d..2616a9d 100644 --- a/twixel/src/handler.rs +++ b/twixel/src/handler.rs @@ -17,7 +17,7 @@ pub mod response; #[derive(Clone)] pub struct HandlerContext { - pub msg: AnySemantic<'static>, + pub msg: AnySemantic, pub connection_idx: usize, pub bot_tx: tokio::sync::mpsc::Sender, pub data_store: Arc, @@ -213,7 +213,7 @@ impl Command { } } - async fn handle_resp(resp: BotResponse, privmsg: &AnySemantic<'_>, sender: Sender) { + async fn handle_resp(resp: BotResponse, privmsg: &AnySemantic, sender: Sender) { match resp { BotResponse::Message(msg) => { if let AnySemantic::PrivMsg(privmsg) = privmsg { @@ -223,6 +223,11 @@ impl Command { .unwrap(); } } + BotResponse::Raw(raw) => { + sender.send(BotCommand::SendRawIrc(raw, 0)) + .await + .unwrap() + } BotResponse::Join(chan) => { sender.send(BotCommand::JoinChannel(chan)).await.unwrap(); } diff --git a/twixel/src/handler/extract.rs b/twixel/src/handler/extract.rs index d1c60ce..6f97d26 100644 --- a/twixel/src/handler/extract.rs +++ b/twixel/src/handler/extract.rs @@ -19,7 +19,7 @@ pub trait Extract: Sized + Send + 'static { type Error: IntoResponse + Send + 'static; fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, data: Arc, ) -> impl Future> + Send; } @@ -29,16 +29,16 @@ pub trait ExtractFull: Sized { type Error: IntoResponse + Send + 'static; fn extract_full( - msg: AnySemantic<'static>, + msg: AnySemantic, data: Arc, ) -> impl Future> + Send; } -impl ExtractFull for AnySemantic<'static> { +impl ExtractFull for AnySemantic { type Error = Infallible; fn extract_full( - msg: AnySemantic<'static>, + msg: AnySemantic, _data: Arc, ) -> impl Future> + Send { ready(Ok(msg)) @@ -49,7 +49,7 @@ impl ExtractFull for T { type Error = ::Error; async fn extract_full( - msg: AnySemantic<'static>, + msg: AnySemantic, data: Arc, ) -> Result { T::extract(&msg, data).await @@ -60,11 +60,11 @@ macro_rules! impl_semantic { ($($ty:tt),+) => { mod semantic { $( - impl super::ExtractFull for twixel_core::irc_message::$ty<'static> { + impl super::ExtractFull for twixel_core::irc_message::$ty { type Error = (); fn extract_full( - msg: twixel_core::irc_message::AnySemantic<'static>, + msg: twixel_core::irc_message::AnySemantic, _data: std::sync::Arc ) -> impl futures::Future> + std::marker::Send { std::future::ready(twixel_core::irc_message::$ty::from_any(msg).ok_or(())) @@ -104,7 +104,7 @@ impl Extract for Option { type Error = Infallible; fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, data: Arc, ) -> impl Future> + Send { T::extract(msg, data).map(|t| Ok(t.ok())) @@ -140,8 +140,8 @@ where { type Error = Infallible; - fn extract(msg: &AnySemantic<'_>, data: Arc) -> Ready> { - let msg = msg.clone().to_static(); + fn extract(msg: &AnySemantic, data: Arc) -> Ready> { + let msg = msg.clone(); let init = Box::pin(async move { T::extract_full(msg, data).await }); ready(Ok(Self::NotInitialized { init })) } @@ -159,7 +159,7 @@ impl Extract for MessageText { type Error = (); fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, _data: Arc, ) -> impl Future> + Send { let text = match msg { @@ -186,7 +186,7 @@ impl Extract for Username { type Error = (); fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, _data: Arc, ) -> impl Future> + Send { ready(match msg { @@ -204,7 +204,7 @@ impl Extract for SenderId { type Error = (); fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, _data: Arc, ) -> impl Future> + Send { match msg { @@ -225,7 +225,7 @@ impl Extract for Channel { type Error = (); fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, _data: Arc, ) -> impl Future> + Send { let chan = if msg.get_command() == IrcCommand::PrivMsg { @@ -254,7 +254,7 @@ impl Extract for Data { type Error = Infallible; fn extract( - _msg: &AnySemantic<'_>, + _msg: &AnySemantic, data: Arc, ) -> impl Future> + Send { let data = data.get::().expect("Failed to find data"); @@ -278,7 +278,7 @@ impl Extract for Clap { type Error = Option; fn extract( - msg: &AnySemantic<'_>, + msg: &AnySemantic, _data: Arc, ) -> impl Future> + Send { let AnySemantic::PrivMsg(msg) = msg else { diff --git a/twixel/src/handler/response.rs b/twixel/src/handler/response.rs index 4475b73..fd5f4ee 100644 --- a/twixel/src/handler/response.rs +++ b/twixel/src/handler/response.rs @@ -5,9 +5,11 @@ use std::{ }; use clap::error::{ContextKind, ErrorKind}; +use twixel_core::MessageBuilder; pub enum BotResponse { Message(String), + Raw(MessageBuilder<'static>), Join(String), Part(String), Shutdown, diff --git a/twixel/src/main.rs b/twixel/src/main.rs index ce41f25..ac9dd90 100644 --- a/twixel/src/main.rs +++ b/twixel/src/main.rs @@ -12,7 +12,7 @@ use guard::UserGuard; use handler::{Command, CommandBuilder, response::BotResponse}; use sqlx::{Sqlite, sqlite::SqliteConnectOptions}; -use crate::commands::gpt; +use crate::commands::{gpt, raw}; mod anymap; mod bot; @@ -130,10 +130,11 @@ async fn main() -> Result<(), anyhow::Error> { .add_command(Command::new(bread_fact, vec!["breadfact".into()], "%")) .add_command(Command::new(argtest, vec!["argtest".into()], "%")) .add_command(Command::new(test, vec!["test".into()], "%")) + .add_command(Command::new(raw, vec!["raw".into()], "%")) .add_command( CommandBuilder::new( async || ("shutting down!", BotResponse::Shutdown), - vec!["strdbg".into()], + vec!["shutdown".into()], "%", ) .and(UserGuard::allow([JULIA_ID])) diff --git a/twixel/src/util/db.rs b/twixel/src/util/db.rs index 743f009..00b0e15 100644 --- a/twixel/src/util/db.rs +++ b/twixel/src/util/db.rs @@ -125,7 +125,7 @@ impl IntoResponse for sqlx::Error { impl Extract for TwixelUser { type Error = Option; - async fn extract(msg: &AnySemantic<'_>, data: Arc) -> Result { + async fn extract(msg: &AnySemantic, data: Arc) -> Result { let pool = Data::::extract(msg, data).await.unwrap(); let AnySemantic::PrivMsg(msg) = msg else { diff --git a/twixel_core/src/connection/mod.rs b/twixel_core/src/connection/mod.rs index 5dfc177..d34dba9 100644 --- a/twixel_core/src/connection/mod.rs +++ b/twixel_core/src/connection/mod.rs @@ -1,7 +1,7 @@ use std::{collections::VecDeque, task::Poll}; use error::ConnectionError; -use futures_util::{Sink, SinkExt, Stream, StreamExt}; +use futures_util::{Sink, SinkExt, Stream, StreamExt, stream::FusedStream}; use hashbrown::HashSet; use log::{debug, warn}; use tokio::net::TcpStream; @@ -97,6 +97,10 @@ impl Connection { } } + pub fn started(&self) -> bool { + self.socket.is_some() + } + pub async fn start(&mut self) -> Result<(), ConnectionError> { if self.socket.is_some() { warn!("tried starting connection when it was already started"); @@ -236,6 +240,12 @@ impl Connection { } } +impl FusedStream for Connection { + fn is_terminated(&self) -> bool { + self.socket.as_ref().is_some_and(|s| s.is_terminated()) + } +} + impl Stream for Connection { type Item = Result; diff --git a/twixel_core/src/irc_message/builder.rs b/twixel_core/src/irc_message/builder.rs index f78afda..394ea41 100644 --- a/twixel_core/src/irc_message/builder.rs +++ b/twixel_core/src/irc_message/builder.rs @@ -2,27 +2,29 @@ use std::{borrow::Cow, fmt::Write}; use hashbrown::HashMap; -use crate::irc_message::PrivMsg; +use crate::irc_message::{PrivMsg, tags::escape_tag_value}; use super::{ToIrcMessage, command::IrcCommand, prefix::OwnedPrefix, tags::OwnedTag}; +/// Helper struct to make new IRCv3 messages #[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MessageBuilder<'a> { #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_tags"))] #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_tags"))] #[cfg_attr(feature = "serde", serde(borrow))] - pub tags: HashMap>, - pub prefix: Option, + tags: HashMap>, + prefix: Option, + /// The IRCv3 command pub command: IrcCommand, #[cfg_attr(feature = "serde", serde(borrow))] - pub params: Vec>, + params: Vec>, } +/// Error occurred while creating a [MessageBuilder] #[derive(Debug, thiserror::Error)] pub enum MessageBuilderError { - #[error("cannot create a builder from {0}")] - WrongMessageType(IrcCommand), + /// The message used to create a [MessageBuilder] was missing a required tag #[error("could not create builder from message due to a missing tag")] MissingTag, } @@ -110,7 +112,16 @@ impl<'a> MessageBuilder<'a> { /// Add new tag-value pair pub fn add_tag(mut self, tag: OwnedTag, value: impl Into>) -> Self { - self.tags.insert(tag, value.into()); + let value = match value.into() { + Cow::Borrowed(s) => escape_tag_value(s), + Cow::Owned(s) => { + match escape_tag_value(&s) { + Cow::Borrowed(_) => Cow::Owned(s), + Cow::Owned(s) => Cow::Owned(s), + } + }, + }; + self.tags.insert(tag, value); self } diff --git a/twixel_core/src/irc_message/mod.rs b/twixel_core/src/irc_message/mod.rs index c358b3e..7f7bb6e 100644 --- a/twixel_core/src/irc_message/mod.rs +++ b/twixel_core/src/irc_message/mod.rs @@ -19,6 +19,7 @@ pub trait ToIrcMessage { fn get_command(&self) -> IrcCommand; } +/// Error types associated with [IrcMessage] and related operations pub mod error { use thiserror::Error; diff --git a/twixel_core/src/irc_message/semantic/clearchat.rs b/twixel_core/src/irc_message/semantic/clearchat.rs index c4b8b0f..0da4e4a 100644 --- a/twixel_core/src/irc_message/semantic/clearchat.rs +++ b/twixel_core/src/irc_message/semantic/clearchat.rs @@ -1,27 +1,32 @@ use crate::irc_message::tags::OwnedTag; -use super::{ClearChat, util::msg_from_param}; +use super::ClearChat; +/// Duration of the timeout/ban pub enum TimeoutDuration { + /// Permanent ban Permanent, + /// Temporary timeout, specified in seconds Temporary(std::time::Duration), } impl ClearChat { - pub fn target_msg_id(&self) -> Option<&str> { - self.get_tag_raw(OwnedTag::TargetMsgId) - } - + /// User ID of the target of the timeout/ban pub fn target_user_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::TargetUserId) } + /// ID of the channel the timeout/ban occurred in pub fn room_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::RoomId) } + /// Duration of time timeout/ban pub fn duration(&self) -> TimeoutDuration { match self.get_tag(OwnedTag::BanDuration) { + Some(dur) if dur.is_empty() => { + TimeoutDuration::Permanent + } Some(dur) => { TimeoutDuration::Temporary(std::time::Duration::from_secs(dur.parse().unwrap())) } @@ -29,12 +34,13 @@ impl ClearChat { } } - pub fn target_login(&self) -> &str { - let msg_param = self - .inner - .get_param(1) - .expect("no message in PrivMsg elisWot"); + /// Login of the target of the timeout/ban + pub fn channel_login(&self) -> Option<&str> { + self.get_param(0).and_then(|p| p.split_at_checked(1).map(|s| s.1)) + } - msg_from_param(msg_param) + /// Login of the channel the timeout/ban occurred in + pub fn target_login(&self) -> Option<&str> { + self.get_param(1).and_then(|p| p.split_at_checked(1).map(|s| s.1)) } } diff --git a/twixel_core/src/irc_message/semantic/clearmsg.rs b/twixel_core/src/irc_message/semantic/clearmsg.rs index b16c152..84f415b 100644 --- a/twixel_core/src/irc_message/semantic/clearmsg.rs +++ b/twixel_core/src/irc_message/semantic/clearmsg.rs @@ -3,28 +3,31 @@ use crate::irc_message::tags::OwnedTag; use super::{ClearMsg, util::msg_from_param}; impl ClearMsg { + /// ID of the message that was deleted pub fn target_msg_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::TargetMsgId) } - pub fn message_text(&self) -> &str { - let msg_param = self + /// Text of the deleted message + pub fn message_text(&self) -> Option<&str> { + self .inner .get_param(1) - .expect("no message in PrivMsg elisWot"); - - msg_from_param(msg_param) + .map(msg_from_param) } + /// ID of the user whose message was deleted pub fn target_user_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::TargetUserId) } + /// ID of the channel where the message was deleted pub fn room_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::RoomId) } - pub fn target_login(&self) -> Option<&str> { - self.get_tag_raw(OwnedTag::Login) + /// Login of the channel where the message was deleted + pub fn channel_login(&self) -> Option<&str> { + self.get_param(0).and_then(|p| p.split_at_checked(1).map(|s| s.1)) } } diff --git a/twixel_core/src/irc_message/semantic/mod.rs b/twixel_core/src/irc_message/semantic/mod.rs index eafcbcd..663fb05 100644 --- a/twixel_core/src/irc_message/semantic/mod.rs +++ b/twixel_core/src/irc_message/semantic/mod.rs @@ -1,12 +1,19 @@ -//! semantic wrappers around each kind of IRC message command, most of these don't +//! Semantic wrappers around each kind of IRC message command, most of these don't //! even do anything useful, but are there for completeness' sake -mod clearchat; -mod clearmsg; -mod notice; -mod ping; -mod privmsg; -mod userstate; +/// Utilities related to the [CLEARCHAT](ClearChat) message kind +pub mod clearchat; +/// Utilities related to the [CLEARMSG](ClearMsg) message kind +pub mod clearmsg; +/// Utilities related to the [NOTICE](Notice) message kind +pub mod notice; +/// Utilities related to the [PING](Ping) message kind +pub mod ping; +/// Utilities related to the [PRIVMSG](PrivMsg) message kind +pub mod privmsg; +/// Utilities related to the [USERSTATE](UserState) message kind +pub mod userstate; + mod util; use std::fmt::Display; @@ -15,11 +22,15 @@ use either::Either; use crate::IrcMessage; +/// Trait for the semantic wrappers around the different message types pub trait SemanticIrcMessage: Sized + private::Sealed { + /// Take the untyped [IrcMessage] fn to_inner(self) -> IrcMessage; + /// Take a reference to the untyped [IrcMessage] fn inner(&self) -> &IrcMessage; + /// Convert from an untyped [IrcMessage] #[allow(clippy::result_large_err, reason = "intended")] fn from_message(msg: IrcMessage) -> Result; } @@ -121,6 +132,7 @@ macro_rules! impl_semantic { /// enum containing all semantic wrappers around [crate::IrcMessage] #[derive(Debug, Clone)] + #[allow(missing_docs)] pub enum AnySemantic { $($cmd($cmd)),+ } @@ -137,7 +149,6 @@ macro_rules! impl_semantic { fn from(value: IrcMessage) -> Self { match value.get_command() { $($crate::irc_message::command::IrcCommand::$cmd => Self::$cmd($cmd::from_message(value).unwrap()),)+ - // _ => todo!() } } } diff --git a/twixel_core/src/irc_message/semantic/notice.rs b/twixel_core/src/irc_message/semantic/notice.rs index 54a860a..9ef245e 100644 --- a/twixel_core/src/irc_message/semantic/notice.rs +++ b/twixel_core/src/irc_message/semantic/notice.rs @@ -3,6 +3,7 @@ use crate::irc_message::tags::OwnedTag; use super::{Notice, util::msg_from_param}; impl Notice { + /// Text of the message, with invisible and special characters removed pub fn message_text(&self) -> &str { let msg_param = self .inner @@ -27,7 +28,8 @@ impl Notice { self.get_tag_raw(OwnedTag::TargetUserId) } - pub fn kind(&self) -> Option> { + /// The kind of notice that was received + pub fn kind(&self) -> Option> { self.get_tag(OwnedTag::MsgId).map(|t| t.parse()) } } @@ -56,6 +58,7 @@ macro_rules! notice { } } + /// Unknown notice kind #[derive(Debug, Clone, Copy)] pub struct $error_name; @@ -68,6 +71,7 @@ macro_rules! notice { impl ::std::error::Error for $error_name {} impl $enum_name { + /// String representation of the notice kind pub fn as_str(self) -> &'static str { match self { $(Self::$name => $key),* @@ -78,7 +82,6 @@ macro_rules! notice { impl ::core::str::FromStr for $enum_name { type Err = $error_name; - // Required method fn from_str(s: &str) -> Result { match s { $( @@ -93,7 +96,7 @@ macro_rules! notice { notice!( /// the kind of NOTICE message this is - NoticeKind, NoticeParseError + NoticeKind, UnknownNotice /// This room is no longer in emote-only mode. "emote_only_off" = EmoteOnlyOff, /// This room is now in emote-only mode. diff --git a/twixel_core/src/irc_message/semantic/ping.rs b/twixel_core/src/irc_message/semantic/ping.rs index 2002854..c458662 100644 --- a/twixel_core/src/irc_message/semantic/ping.rs +++ b/twixel_core/src/irc_message/semantic/ping.rs @@ -3,7 +3,9 @@ use crate::MessageBuilder; use super::Ping; impl Ping { + /// Creates a new [MessageBuilder](crate::MessageBuilder) containing a PONG + /// for this PING pub fn respond(&'_ self) -> MessageBuilder<'_> { - MessageBuilder::pong(self.get_param(0).unwrap()) + MessageBuilder::pong(self.get_param(0).unwrap_or_default()) } } diff --git a/twixel_core/src/irc_message/semantic/privmsg.rs b/twixel_core/src/irc_message/semantic/privmsg.rs index a566bcb..dc9c22d 100644 --- a/twixel_core/src/irc_message/semantic/privmsg.rs +++ b/twixel_core/src/irc_message/semantic/privmsg.rs @@ -4,6 +4,7 @@ use super::{PrivMsg, util::msg_from_param}; impl PrivMsg { // TODO: treat repeat message avoiders + /// Text of the message, with invisible and special characters removed pub fn message_text(&self) -> &str { let msg_param = self .inner @@ -12,6 +13,8 @@ impl PrivMsg { msg_from_param(msg_param) } + /// Returns the senders's role in the channel this was sent in, depending on + /// tags and badges pub fn sender_roles(&self) -> ChannelRoles { let mut roles = ChannelRoles::empty(); @@ -27,6 +30,11 @@ impl PrivMsg { .map(|t| t == "1") .unwrap_or(false), ); + roles.set( + ChannelRoles::LeadModerator, + self.badges() + .any(|(n, _)| n == "lead_moderator"), + ); roles.set( ChannelRoles::Subscriber, self.get_tag(OwnedTag::Subscriber) @@ -41,18 +49,22 @@ impl PrivMsg { roles } + /// Login of the user who sent this PRIVMSG pub fn sender_login(&self) -> Option<&str> { self.get_username() } + /// ID of the user who sent this PRIVMSG pub fn sender_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::UserId) } + /// ID of the chat where this PRIVMSG was sent pub fn channel_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::RoomId) } + /// Login of the chat where this PRIVMSG was sent pub fn channel_login(&self) -> &str { let chan_param = self .inner @@ -65,12 +77,21 @@ impl PrivMsg { } } - /// message ID to be used in the ReplyParentMsgId tag when replying + /// Whether the message is a /me command and should be highlighted/colored + pub fn is_me(&self) -> bool { + self.get_param(1).is_some_and(|p| + p.starts_with(":\u{0001}ACTION ") && p.ends_with('\u{0001}') + ) + } + + /// The message ID to be used in the ReplyParentMsgId tag when replying pub fn reply_to_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::ReplyThreadParentMsgId) .or_else(|| self.get_tag_raw(OwnedTag::Id)) } + /// Make a new [MessageBuilder](crate::MessageBuilder) that is a reply PRIVMSG + /// to this pub fn reply_to(&self, msg: &str) -> MessageBuilder<'_> { let reply_id = self.reply_to_id(); diff --git a/twixel_core/src/irc_message/semantic/userstate.rs b/twixel_core/src/irc_message/semantic/userstate.rs index 288c441..9b21917 100644 --- a/twixel_core/src/irc_message/semantic/userstate.rs +++ b/twixel_core/src/irc_message/semantic/userstate.rs @@ -10,6 +10,7 @@ impl UserState { .1 } + /// Returns the user's role in a chanel, depending on tags and badges pub fn roles(&self) -> ChannelRoles { let mut roles = ChannelRoles::empty(); @@ -25,6 +26,11 @@ impl UserState { .map(|t| t == "1") .unwrap_or(false), ); + roles.set( + ChannelRoles::LeadModerator, + self.badges() + .any(|(n, _)| n == "lead_moderator"), + ); roles.set( ChannelRoles::Subscriber, self.get_tag(OwnedTag::Subscriber) @@ -39,7 +45,9 @@ impl UserState { roles } + /// Returns whether the user is a moderator or lead moderator pub fn is_mod(&self) -> bool { self.get_tag(OwnedTag::Mod).is_some() + || self.badges().any(|(k, _)| k == "lead_moderator" || k == "moderator") } } diff --git a/twixel_core/src/irc_message/semantic/util.rs b/twixel_core/src/irc_message/semantic/util.rs index 1b899a7..56d0157 100644 --- a/twixel_core/src/irc_message/semantic/util.rs +++ b/twixel_core/src/irc_message/semantic/util.rs @@ -3,8 +3,8 @@ pub fn msg_from_param(param_str: &str) -> &str { return ""; }; - if text.starts_with('\u{0001}') && text.ends_with('\u{0001}') && text.len() > 1 { - &text[1..(text.len() - 1)] + if text.starts_with("\u{0001}ACTION ") && text.ends_with('\u{0001}') { + &text[("\u{0001}ACTION ".len())..(text.len() - 1)] } else { text } diff --git a/twixel_core/src/irc_message/tags.rs b/twixel_core/src/irc_message/tags.rs index e84fed2..566ae4e 100644 --- a/twixel_core/src/irc_message/tags.rs +++ b/twixel_core/src/irc_message/tags.rs @@ -37,7 +37,7 @@ fn find_escape_seq(val: &str) -> Option<(Escape, Range)> { } } -fn unescape_tag_value(val: &str) -> Cow<'_, str> { +pub(crate) fn unescape_tag_value(val: &str) -> Cow<'_, str> { let mut pos = 0; let mut out = String::with_capacity(val.len()); while let Some((esc, range)) = find_escape_seq(&val[pos..]) { @@ -61,7 +61,7 @@ fn unescape_tag_value(val: &str) -> Cow<'_, str> { } } -fn escape_tag_value(val: &str) -> Cow<'_, str> { +pub(crate) fn escape_tag_value(val: &str) -> Cow<'_, str> { let mut last = 0; let mut out = String::new(); for (idx, escapable) in val.match_indices(['\\', ' ', '\r', '\n', ';']) { diff --git a/twixel_core/src/user.rs b/twixel_core/src/user.rs index dc82b3e..cd851d3 100644 --- a/twixel_core/src/user.rs +++ b/twixel_core/src/user.rs @@ -11,12 +11,15 @@ bitflags::bitflags! { const Subscriber = 1 << 2; /// Whether the user is the broadcaster const Broadcaster = 1 << 3; + /// Whether the user is the lead moderator + const LeadModerator = 1 << 4; } } impl ChannelRoles { const PRIVILEGED_MASK: ChannelRoles = ChannelRoles::empty() .union(ChannelRoles::Moderator) + .union(ChannelRoles::LeadModerator) .union(ChannelRoles::Vip) .union(ChannelRoles::Broadcaster); From 1e5f7318ae51529ef951efbca28a622f448f924c Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Sat, 24 Jan 2026 22:44:42 -0300 Subject: [PATCH 3/8] add docs, fmt --- twixel_core/benches/benches.rs | 18 +-- twixel_core/src/auth.rs | 5 +- twixel_core/src/connection/mod.rs | 100 ++++++++---- twixel_core/src/connection/pool.rs | 15 +- twixel_core/src/irc_message/builder.rs | 8 +- twixel_core/src/irc_message/message.rs | 61 ++++--- twixel_core/src/irc_message/prefix.rs | 19 ++- .../src/irc_message/semantic/clearchat.rs | 10 +- .../src/irc_message/semantic/clearmsg.rs | 8 +- twixel_core/src/irc_message/semantic/mod.rs | 3 +- .../src/irc_message/semantic/notice.rs | 2 + .../src/irc_message/semantic/privmsg.rs | 8 +- .../src/irc_message/semantic/userstate.rs | 8 +- twixel_core/src/irc_message/tags.rs | 149 ++++++++---------- twixel_core/src/lib.rs | 4 + 15 files changed, 233 insertions(+), 185 deletions(-) diff --git a/twixel_core/benches/benches.rs b/twixel_core/benches/benches.rs index 088b838..0ed394d 100644 --- a/twixel_core/benches/benches.rs +++ b/twixel_core/benches/benches.rs @@ -1,9 +1,9 @@ use std::hint::black_box; -use divan::{counter::BytesCount, Bencher}; +use divan::{Bencher, counter::BytesCount}; use mimalloc::MiMalloc; use twixel_core::irc_message::{ - builder::MessageBuilder, message::IrcMessage, prefix::OwnedPrefix, tags::OwnedTag + builder::MessageBuilder, message::IrcMessage, prefix::OwnedPrefix, tags::OwnedTag, }; #[cfg(test)] @@ -14,20 +14,14 @@ const COUNT: usize = 20000; // static ALLOC: AllocProfiler = AllocProfiler::new(MiMalloc{}); #[global_allocator] -static ALLOC: MiMalloc = MiMalloc{}; +static ALLOC: MiMalloc = MiMalloc {}; #[divan::bench(threads = [0, 1], min_time = 1)] fn deserialize_shit_ton(bencher: Bencher) { bencher - .with_inputs(|| { - SHIT_TON.lines().take(COUNT).collect::>() - }) - .input_counter(|i| { - i.len() - }) - .input_counter(|i| { - BytesCount::new(i.iter().fold(0, |r, i| r + i.len())) - }) + .with_inputs(|| SHIT_TON.lines().take(COUNT).collect::>()) + .input_counter(|i| i.len()) + .input_counter(|i| BytesCount::new(i.iter().fold(0, |r, i| r + i.len()))) .bench_local_values(move |messages| { for i in messages.into_iter() { IrcMessage::try_from(black_box(i)).unwrap(); diff --git a/twixel_core/src/auth.rs b/twixel_core/src/auth.rs index 1272a41..a8d941d 100644 --- a/twixel_core/src/auth.rs +++ b/twixel_core/src/auth.rs @@ -31,7 +31,10 @@ pub struct Anonymous; impl AuthProvider for Anonymous { fn pass_nick(&mut self) -> (String, String) { - ("POGGERS".into(), format!("justinfan{}", rand::rng().random_range(1..99999))) + ( + "POGGERS".into(), + format!("justinfan{}", rand::rng().random_range(1..99999)), + ) } } diff --git a/twixel_core/src/connection/mod.rs b/twixel_core/src/connection/mod.rs index d34dba9..4681ee1 100644 --- a/twixel_core/src/connection/mod.rs +++ b/twixel_core/src/connection/mod.rs @@ -11,42 +11,60 @@ pub mod pool; pub use pool::ConnectionPool; -use crate::{auth::AuthProvider, irc_message::{ - builder::MessageBuilder, command::IrcCommand, message::IrcMessage, ToIrcMessage - }}; - +use crate::{ + auth::AuthProvider, + irc_message::{ + ToIrcMessage, builder::MessageBuilder, command::IrcCommand, message::IrcMessage, + }, +}; + +/// Error types associated with [Connection] and related operations pub mod error { use thiserror::Error; use tokio_tungstenite::tungstenite::{Error as TungsteniteError, error::ProtocolError}; use crate::irc_message::error::IrcMessageParseError; + /// [Connection](super::Connection) errors #[derive(Debug, Error)] pub enum ConnectionError { + /// A method that requires a started connection was called #[error("this Connection has not been started yet")] NotStarted, + /// An already started [Connection] was attempted to be started #[error("this Connection has already already started")] AlreadyStarted, + /// A closed [Connection] was read/written to #[error("this Connection has been closed")] Closed, + /// An Error in the `tokio_tungstenite` websocket library #[error(transparent)] TungsteniteError(TungsteniteError), + /// An invalid IRCv3 message was received from the websocket #[error("the received message from the websocket was not a valid IRC message:\n {0}")] InvalidMessage(#[from] IrcMessageParseError), + /// No content was received from the underlying websocket connection #[error("the Connection received a websocket message, but no valid content was found")] - NoMessage + NoMessage, } + /// [ConnectionPool](super::pool::ConnectionPool) errors #[derive(Debug, Error)] pub enum PoolError { + /// An error related to an internal [Connection](super::Connection) #[error(transparent)] ConnectionError(#[from] ConnectionError), + /// A channel interaction was requested for a channel that was not joined #[error("The requested channel was not found {0}")] ChannelNotFound(String), + /// A requested channel didn't have a connection assigned to it #[error("requested channel didn't have a connection assigned to it {0}")] NoConnectionAssigned(String), + /// Tried to operate on a [Connection](super::Connection) by index but the + /// index was out of bounds #[error("requested index is {0} but length is {1}")] IndexOutOfBounds(usize, usize), + /// There are no connections to receive from #[error("there are no connections to receive from")] NoConnections, } @@ -76,6 +94,8 @@ pub struct Connection { auth_info: Box, } +/// State of the [Connection] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum ConnectionState { /// Connection is closed Closed, @@ -87,6 +107,7 @@ pub enum ConnectionState { // TODO: add logging impl Connection { + /// Create a new [Connection] that joins `channels` upon being started pub fn new(channels: impl IntoIterator>, auth: A) -> Self { Self { socket: None, @@ -97,10 +118,14 @@ impl Connection { } } - pub fn started(&self) -> bool { - self.socket.is_some() + /// The state of this connection + pub fn state(&self) -> ConnectionState { + self.state } + /// Connects to the IRC websocket and sends `JOIN` messages for added channels. + /// + /// Errors if the connection is already started. pub async fn start(&mut self) -> Result<(), ConnectionError> { if self.socket.is_some() { warn!("tried starting connection when it was already started"); @@ -139,6 +164,7 @@ impl Connection { Ok(()) } + /// Closes the websocket and restarts the connection. pub async fn restart(&mut self) -> Result<(), ConnectionError> { if let Some(mut socket) = self.socket.take() { socket.close(None).await?; @@ -146,45 +172,65 @@ impl Connection { self.start().await } + /// Immediately sends `JOIN` message if the connection has been started, otherwise + /// sends it when [Connection::start] is called pub async fn join(&mut self, channel: &str) -> Result<(), ConnectionError> { - if self.channel_list.insert(channel.into()) { + if self.state != ConnectionState::Working { + self.channel_list.insert(channel.into()); + return Ok(()); + } else if self.channel_list.insert(channel.into()) { self.send(MessageBuilder::join(std::iter::once(&channel))) .await?; } Ok(()) } + /// Sends `PART` message if the connection has been started, otherwise + /// removes it from channels joined when [Connection::start] is called pub async fn part(&mut self, channel: &str) -> Result<(), ConnectionError> { - if self.channel_list.remove(channel) { + if self.state != ConnectionState::Working { + self.channel_list.remove(channel); + return Ok(()); + } else if self.channel_list.remove(channel) { self.send(MessageBuilder::part(std::iter::once(channel))) .await?; } Ok(()) } - /// receives twitch messages directly + /// Receives a single new message from Twitch. Multi-message websocket messages + /// have their IRC messages buffered and are returned immediately upon subsequent calls + /// to this function. pub async fn receive(&mut self) -> Result { if let Some(next) = self.buffer.pop_front() { - log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); - return next + log::trace!( + "Received new message: {:?}", + next.as_ref().map(|i| i.inner()) + ); + return next; } if let Some(socket) = &mut self.socket { let received_msg = socket.next().await.ok_or(ConnectionError::Closed)??; - let mut msgs = IrcMessage::from_ws_message(&received_msg).map(|n| n.map_err(Into::into)); + let mut msgs = + IrcMessage::from_ws_message(&received_msg).map(|n| n.map_err(Into::into)); let next = msgs.next().ok_or(ConnectionError::NoMessage)?; self.buffer.extend(msgs); - log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + log::trace!( + "Received new message: {:?}", + next.as_ref().map(|i| i.inner()) + ); next } else { Err(ConnectionError::NotStarted) } } + /// Immediately sends an IRC message to Twitch pub async fn send(&mut self, message: impl ToIrcMessage) -> Result<(), ConnectionError> { if let Some(socket) = &mut self.socket { let command = message.get_command(); @@ -204,6 +250,8 @@ impl Connection { } } + /// Immediately sends many IRC messages to Twitch. This method should be + /// preferred to using [send](Connection::send) when many messages must be sent pub async fn send_batched( &mut self, messages: impl IntoIterator, @@ -229,15 +277,10 @@ impl Connection { } } + /// Number of channels added to this [Connection] pub fn get_channel_count(&self) -> usize { self.channel_list.len() } - - pub fn to_stream(self) -> impl Stream { - futures_util::stream::unfold(self, |mut state| async move { - Some((state.receive().await, state)) - }) - } } impl FusedStream for Connection { @@ -254,8 +297,11 @@ impl Stream for Connection { cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { if let Some(next) = self.buffer.pop_front() { - log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); - return Poll::Ready(Some(next)) + log::trace!( + "Received new message: {:?}", + next.as_ref().map(|i| i.inner()) + ); + return Poll::Ready(Some(next)); } let Some(socket) = self.socket.as_mut() else { return Poll::Ready(Some(Err(ConnectionError::NotStarted))); @@ -269,7 +315,10 @@ impl Stream for Connection { self.buffer.extend(msgs); - log::trace!("Received new message: {:?}", next.as_ref().map(|i| i.inner())); + log::trace!( + "Received new message: {:?}", + next.as_ref().map(|i| i.inner()) + ); Poll::Ready(Some(next)) } Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), @@ -292,10 +341,7 @@ impl Sink for Connection { .map_err(Into::into) } - fn start_send( - mut self: std::pin::Pin<&mut Self>, - item: T, - ) -> Result<(), Self::Error> { + fn start_send(mut self: std::pin::Pin<&mut Self>, item: T) -> Result<(), Self::Error> { self.socket .as_mut() .ok_or(ConnectionError::NotStarted)? diff --git a/twixel_core/src/connection/pool.rs b/twixel_core/src/connection/pool.rs index b8c793d..29ad65c 100644 --- a/twixel_core/src/connection/pool.rs +++ b/twixel_core/src/connection/pool.rs @@ -6,7 +6,7 @@ use hashbrown::HashMap; use crate::{ auth::AuthProvider, - irc_message::{builder::MessageBuilder, message::IrcMessage, ToIrcMessage}, + irc_message::{ToIrcMessage, builder::MessageBuilder, message::IrcMessage}, }; use super::{Connection, error::PoolError}; @@ -14,6 +14,8 @@ use super::{Connection, error::PoolError}; // current limit const MAX_CHANNELS_PER_CONNECTION: usize = 100; +/// A pool of [Connection](super::Connection)s, useful for bots that requires being connected to more +/// than 100 channels pub struct ConnectionPool { pool: Vec>, // relation between channel and connection index in the pool @@ -22,6 +24,7 @@ pub struct ConnectionPool { } impl ConnectionPool { + /// Create a new [ConnectionPool] that joins `channels immediately pub async fn new( channels: impl IntoIterator>, auth: A, @@ -46,6 +49,7 @@ impl ConnectionPool { }) } + /// Part a specific channel pub async fn part_channel(&mut self, channel_login: &str) -> Result<(), PoolError> { match self .channels @@ -61,6 +65,7 @@ impl ConnectionPool { } } + /// Join a specific channel pub async fn join_channel(&mut self, channel_login: &str) -> Result<(), PoolError> { match self .pool @@ -86,10 +91,12 @@ impl ConnectionPool { } } + /// Get the index of the connection that is joined to the specified channel pub fn get_conn_idx(&self, channel_login: &str) -> Option { self.channels.get(channel_login).copied().flatten() } + /// Send a `PRIVMSG` to the connection that is joined to the specified channel pub async fn send_to_channel(&mut self, message: &str, channel: &str) -> Result<(), PoolError> { let conn_idx = self .channels @@ -106,6 +113,7 @@ impl ConnectionPool { Ok(()) } + /// Restart a connection specified by its index pub async fn restart_connection(&mut self, index: usize) -> Result<(), PoolError> { let pool_len = self.pool.len(); self.pool @@ -116,6 +124,7 @@ impl ConnectionPool { Ok(()) } + /// Send an arbitrary IRC message to a connection specified by its index pub async fn send_to_connection( &mut self, msg: impl ToIrcMessage, @@ -153,7 +162,9 @@ impl Stream for ConnectionPool { } } -impl Sink<(Either, T)> for ConnectionPool { +impl Sink<(Either, T)> + for ConnectionPool +{ type Error = PoolError; fn poll_ready( diff --git a/twixel_core/src/irc_message/builder.rs b/twixel_core/src/irc_message/builder.rs index 394ea41..f2cc2ff 100644 --- a/twixel_core/src/irc_message/builder.rs +++ b/twixel_core/src/irc_message/builder.rs @@ -114,11 +114,9 @@ impl<'a> MessageBuilder<'a> { pub fn add_tag(mut self, tag: OwnedTag, value: impl Into>) -> Self { let value = match value.into() { Cow::Borrowed(s) => escape_tag_value(s), - Cow::Owned(s) => { - match escape_tag_value(&s) { - Cow::Borrowed(_) => Cow::Owned(s), - Cow::Owned(s) => Cow::Owned(s), - } + Cow::Owned(s) => match escape_tag_value(&s) { + Cow::Borrowed(_) => Cow::Owned(s), + Cow::Owned(s) => Cow::Owned(s), }, }; self.tags.insert(tag, value); diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index a0ab81b..8059537 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, fmt::Display, ops::{Deref, Range}, slice::Iter, str::FromStr}; +use std::{ + borrow::Cow, + fmt::Display, + ops::{Deref, Range}, + slice::Iter, + str::FromStr, +}; #[cfg(feature = "serde")] use serde::{ @@ -9,12 +15,12 @@ use smallvec::SmallVec; #[cfg(feature = "connection")] use tokio_tungstenite::tungstenite::Message as WsMessage; -use crate::irc_message::{error::IrcMessageStructureError, iter::IrcMessageParseIter, prefix::RawPrefix, tags::RawIrcTags}; - -use super::{ - ToIrcMessage, command::IrcCommand, error::IrcMessageParseError, tags::OwnedTag, +use crate::irc_message::{ + error::IrcMessageStructureError, iter::IrcMessageParseIter, prefix::RawPrefix, tags::RawIrcTags, }; +use super::{ToIrcMessage, command::IrcCommand, error::IrcMessageParseError, tags::OwnedTag}; + type ParamVec = SmallVec<[Range; 3]>; type MessageParts = (Option, Option, IrcCommand, ParamVec); @@ -30,7 +36,9 @@ pub struct IrcMessage { impl IrcMessage { /// Parses an IRCv3 message into this struct - pub fn new(value: impl Into + Deref) -> Result { + pub fn new( + value: impl Into + Deref, + ) -> Result { let (tags, prefix, command, params) = Self::get_parts(&value)?; Ok(Self { @@ -308,28 +316,37 @@ impl PartialEq for IrcMessage { // is this correct?? // if self.raw.len() != other.raw.len() { return false } + if !(self.get_host() == other.get_host() + && self.get_nickname() == other.get_nickname() + && self.get_username() == other.get_username() + && self.command == other.command + && self.params().eq(other.params())) + { + return false; + } + if let (Some(lhs), Some(rhs)) = (&self.tags, &other.tags) { - if lhs.tags.len() != rhs.tags.len() { return false } + if lhs.tags.len() != rhs.tags.len() { + return false; + } for (kl, vl) in &lhs.tags { let (kl, vl) = (kl.to_string(&self.raw), &self.raw[vl.clone()]); - let rhs_has_lhs = rhs.tags - .iter() - .any(|(kr,vr)| { - let (kr, vr) = (kr.to_string(&other.raw), &other.raw[vr.clone()]); - kl == kr && vl == vr - }); - if !rhs_has_lhs { return false }; + let rhs_has_lhs = rhs.tags.iter().any(|(kr, vr)| { + let (kr, vr) = (kr.to_string(&other.raw), &other.raw[vr.clone()]); + kl == kr && vl == vr + }); + if !rhs_has_lhs { + return false; + }; } + } else if self.tags.is_some() != other.tags.is_some() { + return false; } - self.get_host() == other.get_host() - && self.get_nickname() == other.get_nickname() - && self.get_username() == other.get_username() - && self.command == other.command - && self.params().eq(other.params()) + true } } -impl Eq for IrcMessage{} +impl Eq for IrcMessage {} #[cfg(all(feature = "serde", feature = "unstable"))] impl Serialize for IrcMessage { @@ -451,8 +468,8 @@ fn from_ws_message() { @badge-info=;badges=moments/2;client-nonce=da0ef47ebddf148067c685599dd6bc90;color=#8A2BE2;display-name=lonelythomas;emotes=;first-msg=0;flags=;id=91c3b354-95b7-4509-a337-3b86c194b141;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910693;turbo=0;user-id=217061103;user-type= :lonelythomas!lonelythomas@lonelythomas.tmi.twitch.tv PRIVMSG #xqc :LETHIMCOOK\r @badge-info=subscriber/19;badges=subscriber/18,bits/100;client-nonce=b937ab21b00c4f01bd6b729e9b47b665;color=#FFFFFF;display-name=ink6h;emotes=;first-msg=0;flags=;id=5364e52d-baa5-42fa-95a5-d719e17e41dd;mod=0;returning-chatter=0;room-id=71092938;subscriber=1;tmi-sent-ts=1680318911064;turbo=0;user-id=168511883;user-type= :ink6h!ink6h@ink6h.tmi.twitch.tv PRIVMSG #xqc :ye\r @badge-info=;badges=;color=;display-name=getoutofmyhead123;emote-only=1;emotes=emotesv2_04dd118ef04a49c1aa0caa7fc3144369:0-4,6-10,12-16;first-msg=0;flags=;id=225dcdf8-c734-4f62-bb30-af49f2af32e9;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318911099;turbo=0;user-id=880902531;user-type= :getoutofmyhead123!getoutofmyhead123@getoutofmyhead123.tmi.twitch.tv PRIVMSG #xqc :xqcLL xqcLL xqcLL\r"; - let msg = WsMessage::Text(MSGS.into()); - for msg in IrcMessage::from_ws_message(&msg) { + let msg = WsMessage::Text(MSGS.into()); + for msg in IrcMessage::from_ws_message(&msg) { assert!(msg.is_ok(), "{msg:?}"); } } diff --git a/twixel_core/src/irc_message/prefix.rs b/twixel_core/src/irc_message/prefix.rs index ea40a40..5bdcfe4 100644 --- a/twixel_core/src/irc_message/prefix.rs +++ b/twixel_core/src/irc_message/prefix.rs @@ -62,17 +62,16 @@ impl FromStr for OwnedPrefix { type Err = Infallible; fn from_str(value: &str) -> Result { - match value.split_once('@').and_then(|(l,r)| Some((l.split_once('!')?, r))) { - Some(((nickname, username), host)) => { - Ok(Self::Full { - nickname: nickname.into(), - username: username.into(), - host: host.into(), - }) - } - None => Ok(Self::OnlyHostname { - host: value.into(), + match value + .split_once('@') + .and_then(|(l, r)| Some((l.split_once('!')?, r))) + { + Some(((nickname, username), host)) => Ok(Self::Full { + nickname: nickname.into(), + username: username.into(), + host: host.into(), }), + None => Ok(Self::OnlyHostname { host: value.into() }), } } } diff --git a/twixel_core/src/irc_message/semantic/clearchat.rs b/twixel_core/src/irc_message/semantic/clearchat.rs index 0da4e4a..d1a0031 100644 --- a/twixel_core/src/irc_message/semantic/clearchat.rs +++ b/twixel_core/src/irc_message/semantic/clearchat.rs @@ -24,9 +24,7 @@ impl ClearChat { /// Duration of time timeout/ban pub fn duration(&self) -> TimeoutDuration { match self.get_tag(OwnedTag::BanDuration) { - Some(dur) if dur.is_empty() => { - TimeoutDuration::Permanent - } + Some(dur) if dur.is_empty() => TimeoutDuration::Permanent, Some(dur) => { TimeoutDuration::Temporary(std::time::Duration::from_secs(dur.parse().unwrap())) } @@ -36,11 +34,13 @@ impl ClearChat { /// Login of the target of the timeout/ban pub fn channel_login(&self) -> Option<&str> { - self.get_param(0).and_then(|p| p.split_at_checked(1).map(|s| s.1)) + self.get_param(0) + .and_then(|p| p.split_at_checked(1).map(|s| s.1)) } /// Login of the channel the timeout/ban occurred in pub fn target_login(&self) -> Option<&str> { - self.get_param(1).and_then(|p| p.split_at_checked(1).map(|s| s.1)) + self.get_param(1) + .and_then(|p| p.split_at_checked(1).map(|s| s.1)) } } diff --git a/twixel_core/src/irc_message/semantic/clearmsg.rs b/twixel_core/src/irc_message/semantic/clearmsg.rs index 84f415b..0cad5e1 100644 --- a/twixel_core/src/irc_message/semantic/clearmsg.rs +++ b/twixel_core/src/irc_message/semantic/clearmsg.rs @@ -10,10 +10,7 @@ impl ClearMsg { /// Text of the deleted message pub fn message_text(&self) -> Option<&str> { - self - .inner - .get_param(1) - .map(msg_from_param) + self.inner.get_param(1).map(msg_from_param) } /// ID of the user whose message was deleted @@ -28,6 +25,7 @@ impl ClearMsg { /// Login of the channel where the message was deleted pub fn channel_login(&self) -> Option<&str> { - self.get_param(0).and_then(|p| p.split_at_checked(1).map(|s| s.1)) + self.get_param(0) + .and_then(|p| p.split_at_checked(1).map(|s| s.1)) } } diff --git a/twixel_core/src/irc_message/semantic/mod.rs b/twixel_core/src/irc_message/semantic/mod.rs index 663fb05..e28772c 100644 --- a/twixel_core/src/irc_message/semantic/mod.rs +++ b/twixel_core/src/irc_message/semantic/mod.rs @@ -43,7 +43,8 @@ impl private::Sealed for either::Either where L: SemanticIrcMessage, R: SemanticIrcMessage, -{} +{ +} impl SemanticIrcMessage for either::Either where diff --git a/twixel_core/src/irc_message/semantic/notice.rs b/twixel_core/src/irc_message/semantic/notice.rs index 9ef245e..665e618 100644 --- a/twixel_core/src/irc_message/semantic/notice.rs +++ b/twixel_core/src/irc_message/semantic/notice.rs @@ -12,6 +12,7 @@ impl Notice { msg_from_param(msg_param) } + /// Login of the channel the NOTICE message relates to pub fn channel_login(&self) -> &str { let chan_param = self .inner @@ -24,6 +25,7 @@ impl Notice { } } + /// ID of the use the NOTICE message relates to pub fn target_user_id(&self) -> Option<&str> { self.get_tag_raw(OwnedTag::TargetUserId) } diff --git a/twixel_core/src/irc_message/semantic/privmsg.rs b/twixel_core/src/irc_message/semantic/privmsg.rs index dc9c22d..4f778f9 100644 --- a/twixel_core/src/irc_message/semantic/privmsg.rs +++ b/twixel_core/src/irc_message/semantic/privmsg.rs @@ -32,8 +32,7 @@ impl PrivMsg { ); roles.set( ChannelRoles::LeadModerator, - self.badges() - .any(|(n, _)| n == "lead_moderator"), + self.badges().any(|(n, _)| n == "lead_moderator"), ); roles.set( ChannelRoles::Subscriber, @@ -79,9 +78,8 @@ impl PrivMsg { /// Whether the message is a /me command and should be highlighted/colored pub fn is_me(&self) -> bool { - self.get_param(1).is_some_and(|p| - p.starts_with(":\u{0001}ACTION ") && p.ends_with('\u{0001}') - ) + self.get_param(1) + .is_some_and(|p| p.starts_with(":\u{0001}ACTION ") && p.ends_with('\u{0001}')) } /// The message ID to be used in the ReplyParentMsgId tag when replying diff --git a/twixel_core/src/irc_message/semantic/userstate.rs b/twixel_core/src/irc_message/semantic/userstate.rs index 9b21917..b8e8024 100644 --- a/twixel_core/src/irc_message/semantic/userstate.rs +++ b/twixel_core/src/irc_message/semantic/userstate.rs @@ -3,6 +3,7 @@ use crate::{irc_message::tags::OwnedTag, user::ChannelRoles}; use super::UserState; impl UserState { + /// Login of the channel the USERSTATE message relates to pub fn channel_login(&self) -> &str { self.get_param(0) .expect("malformed channel login param") @@ -28,8 +29,7 @@ impl UserState { ); roles.set( ChannelRoles::LeadModerator, - self.badges() - .any(|(n, _)| n == "lead_moderator"), + self.badges().any(|(n, _)| n == "lead_moderator"), ); roles.set( ChannelRoles::Subscriber, @@ -48,6 +48,8 @@ impl UserState { /// Returns whether the user is a moderator or lead moderator pub fn is_mod(&self) -> bool { self.get_tag(OwnedTag::Mod).is_some() - || self.badges().any(|(k, _)| k == "lead_moderator" || k == "moderator") + || self + .badges() + .any(|(k, _)| k == "lead_moderator" || k == "moderator") } } diff --git a/twixel_core/src/irc_message/tags.rs b/twixel_core/src/irc_message/tags.rs index 566ae4e..923ea5d 100644 --- a/twixel_core/src/irc_message/tags.rs +++ b/twixel_core/src/irc_message/tags.rs @@ -16,13 +16,13 @@ enum Escape { Lf, Semicolon, Other, - TrailingSlash + TrailingSlash, } fn find_escape_seq(val: &str) -> Option<(Escape, Range)> { let backslash = val.find("\\")?; let Some(next) = val[backslash..].chars().nth(1) else { - return Some((Escape::TrailingSlash, backslash..backslash + 1)) + return Some((Escape::TrailingSlash, backslash..backslash + 1)); }; let range = backslash..(backslash + 1 + next.len_utf8()); @@ -72,11 +72,11 @@ pub(crate) fn escape_tag_value(val: &str) -> Cow<'_, str> { "\r" => "\\r", "\n" => "\\n", ";" => "\\:", - _ => unreachable!() + _ => unreachable!(), }); last = idx + 1; } - if out.is_empty() { + if out.is_empty() { Cow::Borrowed(val) } else { out.push_str(&val[last..]); @@ -334,14 +334,16 @@ impl RawIrcTags { let divider = divider + last_pos; tags.insert(RawTag::parse(raw, last_pos..divider), divider + 1..pos - 1); } else { - tags.insert(RawTag::parse(raw, last_pos..pos-1), pos-1..pos-1); + tags.insert(RawTag::parse(raw, last_pos..pos - 1), pos - 1..pos - 1); } last_pos = pos; } // parsing the last tag - if let Some(divider) = memchr::memchr(b'=', &raw.as_bytes()[last_pos..]).map(|d| d + last_pos) { + if let Some(divider) = + memchr::memchr(b'=', &raw.as_bytes()[last_pos..]).map(|d| d + last_pos) + { tags.insert( RawTag::parse(raw, last_pos..divider), divider + 1..raw_end_idx, @@ -439,16 +441,19 @@ impl<'a> Iterator for TagsIter<'a> { #[cfg(test)] mod tests { - use crate::irc_message::tags::{escape_tag_value, unescape_tag_value, OwnedTag, RawIrcTags}; + use crate::irc_message::tags::{OwnedTag, RawIrcTags, escape_tag_value, unescape_tag_value}; #[test] fn parse_normal() { let source = "buh=123;vip=1;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("123")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("123") + ); assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); } @@ -457,10 +462,13 @@ mod tests { fn parse_empty() { let source = "buh=;vip=1;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("") + ); assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); } @@ -469,24 +477,30 @@ mod tests { fn parse_empty_trailing() { let source = "vip=1;color=#123123;buh"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("") + ); } #[test] fn parse_empty_no_equals() { let source = "buh;vip=1;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); dbg!(tags.iter(source)); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("") + ); assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("1")); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); } @@ -495,8 +509,8 @@ mod tests { fn parse_multi() { let source = "vip=123;vip=321;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); assert_eq!(tags.get_raw_value(source, OwnedTag::Vip), Some("321")); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); @@ -506,10 +520,13 @@ mod tests { fn parse_multi_unknown() { let source = "buh=123;buh=321;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("321")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("321") + ); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); } @@ -517,106 +534,64 @@ mod tests { fn parse_multi_unknown2() { let source = "buh=123;buh=321;buh=hub;color=#123123"; - let tags = RawIrcTags::new(source, 0, source.len()) - .expect("failed to parse tags from string"); + let tags = + RawIrcTags::new(source, 0, source.len()).expect("failed to parse tags from string"); - assert_eq!(tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), Some("hub")); + assert_eq!( + tags.get_raw_value(source, OwnedTag::Unknown("buh".into())), + Some("hub") + ); assert_eq!(tags.get_raw_value(source, OwnedTag::Color), Some("#123123")); } #[test] fn unescape_tags() { let space = "Hello,\\sworld!"; - assert_eq!( - unescape_tag_value(space), - "Hello, world!" - ); + assert_eq!(unescape_tag_value(space), "Hello, world!"); let semicolon = "semi\\:"; - assert_eq!( - unescape_tag_value(semicolon), - "semi;" - ); + assert_eq!(unescape_tag_value(semicolon), "semi;"); let backslash = "\\\\/"; - assert_eq!( - unescape_tag_value(backslash), - "\\/" - ); + assert_eq!(unescape_tag_value(backslash), "\\/"); let backslash_s = "\\\\s"; - assert_eq!( - unescape_tag_value(backslash_s), - "\\s" - ); + assert_eq!(unescape_tag_value(backslash_s), "\\s"); let fake = "\\b"; - assert_eq!( - unescape_tag_value(fake), - "b" - ); + assert_eq!(unescape_tag_value(fake), "b"); let multi = "\\s\\s"; - assert_eq!( - unescape_tag_value(multi), - " " - ); + assert_eq!(unescape_tag_value(multi), " "); let all = "\\\\\\s\\:\\r\\n\\a"; - assert_eq!( - unescape_tag_value(all), - "\\ ;\r\na" - ); + assert_eq!(unescape_tag_value(all), "\\ ;\r\na"); let trailing = "test\\"; - assert_eq!( - unescape_tag_value(trailing), - "test" - ); + assert_eq!(unescape_tag_value(trailing), "test"); } #[test] fn escape_tags() { let space = "Hello, world!"; - assert_eq!( - escape_tag_value(space), - "Hello,\\sworld!" - ); + assert_eq!(escape_tag_value(space), "Hello,\\sworld!"); let semicolon = "semi;"; - assert_eq!( - escape_tag_value(semicolon), - "semi\\:" - ); + assert_eq!(escape_tag_value(semicolon), "semi\\:"); let backslash = "\\/"; - assert_eq!( - escape_tag_value(backslash), - "\\\\/" - ); + assert_eq!(escape_tag_value(backslash), "\\\\/"); let space = " "; - assert_eq!( - escape_tag_value(space), - "\\s" - ); + assert_eq!(escape_tag_value(space), "\\s"); let fake = "\\b"; - assert_eq!( - escape_tag_value(fake), - "\\\\b" - ); + assert_eq!(escape_tag_value(fake), "\\\\b"); let multi = " "; - assert_eq!( - escape_tag_value(multi), - "\\s\\s" - ); + assert_eq!(escape_tag_value(multi), "\\s\\s"); let all = "\\ ;\r\n\\a"; - assert_eq!( - escape_tag_value(all), - "\\\\\\s\\:\\r\\n\\\\a" - ); + assert_eq!(escape_tag_value(all), "\\\\\\s\\:\\r\\n\\\\a"); } } diff --git a/twixel_core/src/lib.rs b/twixel_core/src/lib.rs index 26b4902..91d71b3 100644 --- a/twixel_core/src/lib.rs +++ b/twixel_core/src/lib.rs @@ -1,3 +1,7 @@ +//! # twixel_core +//! +//! A [Twitch](https://twitch.tv/) IRC chat client library + #![deny(clippy::missing_safety_doc)] #![warn(missing_docs)] // utf-8 char boundary checking is cool From fcc90b7149d3985d8a017fa3b1619c976a1494a6 Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Wed, 28 Jan 2026 14:13:17 -0300 Subject: [PATCH 4/8] add emote iterator --- twixel_core/src/irc_message/message.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index 8059537..41aaa3a 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -1,7 +1,7 @@ use std::{ borrow::Cow, fmt::Display, - ops::{Deref, Range}, + ops::{Deref, Range, RangeInclusive}, slice::Iter, str::FromStr, }; @@ -148,6 +148,30 @@ impl IrcMessage { .flatten() } + /// Iterates over the message's emotes, yields emote id and **char** ranges of ocurrences + pub fn emotes(&self) -> impl Iterator>)> { + self.tags + .as_ref() + .and_then(|t| t.get_raw_value(&self.raw, OwnedTag::Emotes)) + .map(|s: &str| { + s.split('/').filter_map(|e: &str| { + e.split_once(':').map(|(e, r)| { + ( + e, + r.split(',') + .filter_map(|r| { + r.split_once('-') + .and_then(|(s, e)| Some(s.parse().ok()?..=e.parse().ok()?)) + }) + .collect::>(), + ) + }) + }) + }) + .into_iter() + .flatten() + } + /// Retrieves the value associated with the given tag. /// # Returns /// - `None` if the tag is not present From 1842d0c2762e279d6193748eaf83ea704ef72563 Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Sat, 31 Jan 2026 03:32:35 -0300 Subject: [PATCH 5/8] fix Connection state oopsie doopsie --- twixel_core/src/connection/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/twixel_core/src/connection/mod.rs b/twixel_core/src/connection/mod.rs index 4681ee1..3b00b29 100644 --- a/twixel_core/src/connection/mod.rs +++ b/twixel_core/src/connection/mod.rs @@ -161,6 +161,8 @@ impl Connection { } >::flush(self).await?; + self.state = ConnectionState::Working; + Ok(()) } From 079feaafd5ee66108e8520120d67ec06e55f353b Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Mon, 2 Feb 2026 23:31:03 -0300 Subject: [PATCH 6/8] fix trailing param parsing, add getting tags by str --- twixel_core/src/irc_message/message.rs | 121 +++++++++++++++++-- twixel_core/src/irc_message/mod.rs | 2 +- twixel_core/src/irc_message/semantic/util.rs | 12 +- twixel_core/src/irc_message/tags.rs | 20 +++ 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index 41aaa3a..d1f77a3 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -103,18 +103,23 @@ impl IrcMessage { let mut last_param_start = pos; for i in memchr::memchr3_iter(b' ', b'\r', b'\n', raw[pos..].as_bytes()) { + // the : should be ignored in the parsed parameter list, it is merely + // a trick to allow spaces within parameters if raw.as_bytes()[last_param_start] == b':' { params.push( if let Some(found_end) = memchr::memchr2(b'\r', b'\n', raw[pos..].as_bytes()) { - last_param_start..(found_end + pos) + (last_param_start + 1)..(found_end + pos) } else { - last_param_start..raw.len() + (last_param_start + 1)..raw.len() }, ); break; } else { params.push(last_param_start..pos + i); } + if raw.get(pos + i..=pos + i) != Some(" ") { + break; + } last_param_start = pos + i + 1; } @@ -200,6 +205,34 @@ impl IrcMessage { } } + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no value is present + /// - The value associated with the tag, with escape sequences removed. + /// + /// See also [IrcMessage::get_tag_raw()] + pub fn get_tag_by_str(&self, tag: &str) -> Option> { + match &self.tags { + Some(s) => s.get_value_by_str(&self.raw, tag), + None => None, + } + } + + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no value is present + /// - The value associated with the tag, with escape sequences not removed + /// + /// See also [IrcMessage::get_tag()] + pub fn get_tag_raw_by_str(&self, tag: &str) -> Option<&str> { + match &self.tags { + Some(s) => s.get_raw_value_by_str(&self.raw, tag), + None => None, + } + } + /// Iterates over the tags of the message pub fn tags(&self) -> impl Iterator { self.tags @@ -485,15 +518,85 @@ impl<'a> Iterator for Params<'a> { } } -#[test] -#[cfg(feature = "connection")] -fn from_ws_message() { - const MSGS: &str = "@badge-info=;badges=moments/2;client-nonce=9297a96d510091fa87c81eaa9e5bb8e3;color=#E4E5FF;display-name=MELLOWFLEUR;emotes=;first-msg=0;flags=;id=1ada6902-aafe-452a-8651-1fe711ddd7d1;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910689;turbo=0;user-id=45179149;user-type= :mellowfleur!mellowfleur@mellowfleur.tmi.twitch.tv PRIVMSG #xqc :yes\r +#[cfg(test)] +mod test { + use crate::IrcMessage; + + #[test] + #[cfg(feature = "connection")] + fn from_ws_message() { + use tokio_tungstenite::tungstenite::Message as WsMessage; + + const MSGS: &str = "@badge-info=;badges=moments/2;client-nonce=9297a96d510091fa87c81eaa9e5bb8e3;color=#E4E5FF;display-name=MELLOWFLEUR;emotes=;first-msg=0;flags=;id=1ada6902-aafe-452a-8651-1fe711ddd7d1;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910689;turbo=0;user-id=45179149;user-type= :mellowfleur!mellowfleur@mellowfleur.tmi.twitch.tv PRIVMSG #xqc :yes\r @badge-info=;badges=moments/2;client-nonce=da0ef47ebddf148067c685599dd6bc90;color=#8A2BE2;display-name=lonelythomas;emotes=;first-msg=0;flags=;id=91c3b354-95b7-4509-a337-3b86c194b141;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318910693;turbo=0;user-id=217061103;user-type= :lonelythomas!lonelythomas@lonelythomas.tmi.twitch.tv PRIVMSG #xqc :LETHIMCOOK\r @badge-info=subscriber/19;badges=subscriber/18,bits/100;client-nonce=b937ab21b00c4f01bd6b729e9b47b665;color=#FFFFFF;display-name=ink6h;emotes=;first-msg=0;flags=;id=5364e52d-baa5-42fa-95a5-d719e17e41dd;mod=0;returning-chatter=0;room-id=71092938;subscriber=1;tmi-sent-ts=1680318911064;turbo=0;user-id=168511883;user-type= :ink6h!ink6h@ink6h.tmi.twitch.tv PRIVMSG #xqc :ye\r @badge-info=;badges=;color=;display-name=getoutofmyhead123;emote-only=1;emotes=emotesv2_04dd118ef04a49c1aa0caa7fc3144369:0-4,6-10,12-16;first-msg=0;flags=;id=225dcdf8-c734-4f62-bb30-af49f2af32e9;mod=0;returning-chatter=0;room-id=71092938;subscriber=0;tmi-sent-ts=1680318911099;turbo=0;user-id=880902531;user-type= :getoutofmyhead123!getoutofmyhead123@getoutofmyhead123.tmi.twitch.tv PRIVMSG #xqc :xqcLL xqcLL xqcLL\r"; - let msg = WsMessage::Text(MSGS.into()); - for msg in IrcMessage::from_ws_message(&msg) { - assert!(msg.is_ok(), "{msg:?}"); + let msg = WsMessage::Text(MSGS.into()); + for msg in IrcMessage::from_ws_message(&msg) { + assert!(msg.is_ok(), "{msg:?}"); + } + } + + /// From RFC-2812: + /// > After extracting the parameter list, all parameters are equal + /// > whether matched by or . is just a + /// > syntactic trick to allow SPACE within the parameter. + #[test] + fn test_eq_trailing_middle() { + let trailing: IrcMessage = + "@tag1=1;tag2=2 :user!user@example.com PRIVMSG #room :hello!\r\n" + .parse() + .unwrap(); + let middle: IrcMessage = "@tag1=1;tag2=2 :user!user@example.com PRIVMSG #room hello!\r\n" + .parse() + .unwrap(); + + assert_eq!( + trailing, + middle, + "{:?} {:?}", + trailing.get_param(1), + trailing.get_param(1) + ); + + assert_eq!(trailing.params().count(), 2, "{:?}", trailing); + assert_eq!(middle.params().count(), 2, "{:?}", middle); + + let multiple: IrcMessage = + "@tag1=1;tag2=2 :user!user@example.com PRIVMSG #room hello, world!" + .parse() + .unwrap(); + let trailing_multiple: IrcMessage = + "@tag1=1;tag2=2 :user!user@example.com PRIVMSG #room :hello, world!" + .parse() + .unwrap(); + + assert_ne!(multiple, trailing_multiple) + } + + #[test] + fn test_eq_tag_order() { + let a: IrcMessage = + "@tag1=1;tag2=2;tag3=3;tag4=4 :user!user@example.com PRIVMSG #room :hello!" + .parse() + .unwrap(); + let b: IrcMessage = + "@tag2=2;tag1=1;tag4=4;tag3=3 :user!user@example.com PRIVMSG #room :hello!" + .parse() + .unwrap(); + + assert_eq!(a, b) + } + + #[test] + fn test_eq_tag_empty_missing() { + let a: IrcMessage = "@tag1 :user!user@example.com PRIVMSG #room :hello!" + .parse() + .unwrap(); + let b: IrcMessage = "@tag1= :user!user@example.com PRIVMSG #room :hello!" + .parse() + .unwrap(); + + assert_eq!(a, b) } } diff --git a/twixel_core/src/irc_message/mod.rs b/twixel_core/src/irc_message/mod.rs index 7f7bb6e..b3cce36 100644 --- a/twixel_core/src/irc_message/mod.rs +++ b/twixel_core/src/irc_message/mod.rs @@ -87,7 +87,7 @@ mod tests { assert_eq!(parsed.get_command(), IrcCommand::PrivMsg); assert_eq!(parsed.get_tag(OwnedTag::Vip).unwrap(), "1"); assert_eq!(parsed.get_param(0).unwrap(), "#a"); - assert_eq!(parsed.get_param(1).unwrap(), ":hi there"); + assert_eq!(parsed.get_param(1).unwrap(), "hi there"); assert_eq!(parsed.get_host().unwrap(), "guh"); } diff --git a/twixel_core/src/irc_message/semantic/util.rs b/twixel_core/src/irc_message/semantic/util.rs index 56d0157..29aeb2c 100644 --- a/twixel_core/src/irc_message/semantic/util.rs +++ b/twixel_core/src/irc_message/semantic/util.rs @@ -1,11 +1,7 @@ -pub fn msg_from_param(param_str: &str) -> &str { - let Some((_colon, text)) = param_str.split_at_checked(1) else { - return ""; - }; - - if text.starts_with("\u{0001}ACTION ") && text.ends_with('\u{0001}') { - &text[("\u{0001}ACTION ".len())..(text.len() - 1)] +pub(crate) fn msg_from_param(param_str: &str) -> &str { + if param_str.starts_with("\u{0001}ACTION ") && param_str.ends_with('\u{0001}') { + ¶m_str[("\u{0001}ACTION ".len())..(param_str.len() - 1)] } else { - text + param_str } } diff --git a/twixel_core/src/irc_message/tags.rs b/twixel_core/src/irc_message/tags.rs index 923ea5d..bd76710 100644 --- a/twixel_core/src/irc_message/tags.rs +++ b/twixel_core/src/irc_message/tags.rs @@ -378,6 +378,26 @@ impl RawIrcTags { src.get(found.1.clone()) } + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no key is present + /// - The value associated with the tag, with escape sequences removed + pub fn get_value_by_str<'a>(&self, src: &'a str, tag: &str) -> Option> { + let found = self.tags.iter().find(|t| t.0.to_string(src) == tag)?; + src.get(found.1.clone()).map(unescape_tag_value) + } + + /// Retrieves the value associated with the given tag. + /// # Returns + /// - `None` if the tag is not present + /// - An empty string if the tag is present but no key is present + /// - The value associated with the tag, with escape sequences not removed + pub fn get_raw_value_by_str<'a>(&self, src: &'a str, tag: &str) -> Option<&'a str> { + let found = self.tags.iter().find(|t| t.0.to_string(src) == tag)?; + src.get(found.1.clone()) + } + pub fn iter<'a>(&'a self, src: &'a str) -> TagsIter<'a> { TagsIter::new(self, src) } From d9a4297c128812da813dfae39b1b67a720bf048d Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Tue, 3 Feb 2026 01:47:05 -0300 Subject: [PATCH 7/8] add irc parsing with no CRLF --- twixel_core/src/irc_message/message.rs | 57 ++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index d1f77a3..58f6051 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -94,33 +94,51 @@ impl IrcMessage { pos = pos + s + 1; cmd } - None => return Err(E::NoCommand), + None => { + let cmd = &raw[pos..raw.len()]; + if cmd.is_empty() { + return Err(E::NoCommand); + } + pos = raw.len() - 1; + cmd + } }; let command = IrcCommand::try_from(cmd)?; let mut params = ParamVec::new(); - let mut last_param_start = pos; + let mut param_start = pos; for i in memchr::memchr3_iter(b' ', b'\r', b'\n', raw[pos..].as_bytes()) { // the : should be ignored in the parsed parameter list, it is merely // a trick to allow spaces within parameters - if raw.as_bytes()[last_param_start] == b':' { + if raw.as_bytes()[param_start] == b':' { params.push( if let Some(found_end) = memchr::memchr2(b'\r', b'\n', raw[pos..].as_bytes()) { - (last_param_start + 1)..(found_end + pos) + (param_start + 1)..(found_end + pos) } else { - (last_param_start + 1)..raw.len() + (param_start + 1)..raw.len() }, ); break; } else { - params.push(last_param_start..pos + i); + params.push(param_start..pos + i); } - if raw.get(pos + i..=pos + i) != Some(" ") { + if raw.as_bytes().get(pos + i) != Some(&b' ') { + if ![b'\r', b'\n'].contains(&raw.as_bytes()[pos + i]) { + params.push(pos + i + 1..raw.len()); + } break; } - last_param_start = pos + i + 1; + param_start = pos + i + 1; + } + + let last_pos = params.last().map(|l| l.end).unwrap_or(pos); + if last_pos != raw.len() - 1 + && let Some(start) = raw.as_bytes().get(last_pos) + && ![b'\r', b'\n'].contains(start) + { + params.push(last_pos..raw.len()); } Ok((tags, prefix, command, params)) @@ -537,6 +555,29 @@ mod test { } } + #[test] + fn no_crlf() { + let no_crlf: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room no_CRLF" + .parse() + .unwrap(); + let no_crlf_trailing: IrcMessage = + ":user!user@user.tmi.twitch.tv PRIVMSG #room :no_CRLF middle" + .parse() + .unwrap(); + + let no_crlf_single_param: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room" + .parse() + .unwrap(); + + let no_crlf_paramless: IrcMessage = + ":user!user@user.tmi.twitch.tv PRIVMSG".parse().unwrap(); + + assert_eq!(no_crlf.params().count(), 2); + assert_eq!(no_crlf_trailing.params().count(), 2,); + assert_eq!(no_crlf_single_param.params().count(), 1); + assert_eq!(no_crlf_paramless.params().count(), 0); + } + /// From RFC-2812: /// > After extracting the parameter list, all parameters are equal /// > whether matched by or . is just a From af055d401bda20684bdd8f67365381bc396cd333 Mon Sep 17 00:00:00 2001 From: Juliapixel Date: Tue, 3 Feb 2026 02:43:24 -0300 Subject: [PATCH 8/8] improve parsing somewhat --- twixel_core/src/irc_message/message.rs | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/twixel_core/src/irc_message/message.rs b/twixel_core/src/irc_message/message.rs index 58f6051..abb3278 100644 --- a/twixel_core/src/irc_message/message.rs +++ b/twixel_core/src/irc_message/message.rs @@ -133,8 +133,8 @@ impl IrcMessage { param_start = pos + i + 1; } - let last_pos = params.last().map(|l| l.end).unwrap_or(pos); - if last_pos != raw.len() - 1 + let last_pos = params.last().map(|l| l.end + 1).unwrap_or(pos); + if last_pos != raw.len() && let Some(start) = raw.as_bytes().get(last_pos) && ![b'\r', b'\n'].contains(start) { @@ -560,6 +560,11 @@ mod test { let no_crlf: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room no_CRLF" .parse() .unwrap(); + + let no_crlf_single_char: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room a" + .parse() + .unwrap(); + let no_crlf_trailing: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room :no_CRLF middle" .parse() @@ -573,11 +578,47 @@ mod test { ":user!user@user.tmi.twitch.tv PRIVMSG".parse().unwrap(); assert_eq!(no_crlf.params().count(), 2); + assert_eq!(no_crlf.get_param(0), Some("#room")); + assert_eq!(no_crlf.get_param(1), Some("no_CRLF")); + assert_eq!(no_crlf_single_char.params().count(), 2); + assert_eq!(no_crlf_single_char.get_param(0), Some("#room")); + assert_eq!(no_crlf_single_char.get_param(1), Some("a")); assert_eq!(no_crlf_trailing.params().count(), 2,); + assert_eq!(no_crlf_trailing.get_param(0), Some("#room")); + assert_eq!(no_crlf_trailing.get_param(1), Some("no_CRLF middle")); assert_eq!(no_crlf_single_param.params().count(), 1); + assert_eq!(no_crlf_single_param.get_param(0), Some("#room")); assert_eq!(no_crlf_paramless.params().count(), 0); } + #[test] + fn with_crlf() { + let with_crlf: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room no_CRLF\r\n" + .parse() + .unwrap(); + let with_crlf_trailing: IrcMessage = + ":user!user@user.tmi.twitch.tv PRIVMSG #room :no_CRLF middle\r\n" + .parse() + .unwrap(); + + let with_crlf_single_param: IrcMessage = ":user!user@user.tmi.twitch.tv PRIVMSG #room\r\n" + .parse() + .unwrap(); + + let with_crlf_paramless: IrcMessage = + ":user!user@user.tmi.twitch.tv PRIVMSG\r\n".parse().unwrap(); + + assert_eq!(with_crlf.params().count(), 2); + assert_eq!(with_crlf.get_param(0), Some("#room")); + assert_eq!(with_crlf.get_param(1), Some("no_CRLF")); + assert_eq!(with_crlf_trailing.params().count(), 2,); + assert_eq!(with_crlf_trailing.get_param(0), Some("#room")); + assert_eq!(with_crlf_trailing.get_param(1), Some("no_CRLF middle")); + assert_eq!(with_crlf_single_param.params().count(), 1); + assert_eq!(with_crlf_single_param.get_param(0), Some("#room")); + assert_eq!(with_crlf_paramless.params().count(), 0); + } + /// From RFC-2812: /// > After extracting the parameter list, all parameters are equal /// > whether matched by or . is just a