diff --git a/Cargo.toml b/Cargo.toml index 176927e..70b34b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,117 +1,17 @@ -[package] -name = "twitchchat" -edition = "2018" -version = "0.14.5" -authors = ["museun "] -keywords = ["twitch", "irc", "async", "asynchronous", "tokio"] -license = "MIT OR Apache-2.0" -readme = "README.md" -description = "interface to the irc-side of twitch's chat system" -documentation = "https://docs.rs/twitchchat/latest/twitchchat/" -repository = "https://github.com/museun/twitchchat" -categories = ["asynchronous", "network-programming", "parser-implementations"] - -[package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] -all-features = true - -[features] -default = [] -testing = [ - "async", - "async-mutex", -] - -async = [ - "async-channel", - "async-dup", - "fastrand", - "futures-lite", - "futures-timer", - "log", - "pin-project-lite", +[workspace] +members = [ + "twitchchat", + "twitchchat_async_io", + "twitchchat_async_net", + "twitchchat_async_std", + "twitchchat_smol", + "twitchchat_tokio", + "twitchchat_tokio02", + "twitchchat_tungstenite", + + # internal crates + # "examples" ] -[dependencies] -# logging support -log = { version = "0.4", optional = true, features = ["std"] } - -# just the futures traits -futures-lite = { version = "1.11", optional = true } - -# field pin projection -pin-project-lite = { version = "0.1", optional = true } - -# cloneable async writes -async-dup = { version = "1.2", optional = true } - -# message passing -async-channel = { version = "1.5", optional = true } - -# for timing out futures -futures-timer = { version = "3.0", optional = true } - -# for 'fairness' in the main loop -fastrand = { version = "1.4", optional = true } - -# for optional serialization and deserialization -serde = { version = "1.0", features = ["derive"], optional = true } - -# optional runtimes (for TcpStream) -# these use the futures AsyncWrite+AsyncRead -async-io = { version = "1.1", optional = true } -smol = { version = "1.2", optional = true } -async-tls = { version = "0.10", default-features = false, features = ["client"], optional = true } -# TODO look into what their features do. the ones they have enabled by default seem important -async-std = { version = "1.6", optional = true } - -# tokio has its own AsyncWrite+AsyncRead -tokio = { version = "0.3", features = ["net"], optional = true } -tokio-util = { version = "0.4", features = ["compat"], optional = true } - -# rustls -tokio-rustls = { version = "0.20", optional = true } -webpki-roots = { version = "0.20", optional = true } - -# native-tls -tokio-native-tls = { version = "0.2", optional = true } -native-tls = { version = "0.2", optional = true } - -# openssl -tokio-openssl = { version = "0.5", optional = true } -openssl = { version = "0.10", optional = true, features = ["v111"] } - -# for some test utilities -async-mutex = { version = "1.4", optional = true } - - -[dev-dependencies] -anyhow = "1.0.33" -async-executor = { version = "1.3.0", default-features = false } -serde_json = "1.0.59" -rmp-serde = "0.14.4" - -[[example]] -name = "message_parse" -required-features = ["async"] - -[[example]] -name = "smol_demo" -required-features = ["smol", "async"] - -[[example]] -name = "async_io_demo" -required-features = ["async-io", "async"] - -[[example]] -name = "async_std_demo" -required-features = ["async-std", "async-std/attributes", "async"] - -[[example]] -name = "tokio_demo" -required-features = ["tokio/full", "tokio-util", "async"] - -[[example]] -name = "simple_bot" -required-features = ["smol", "async"] - +[patch.crates-io] +twitchchat = { path = "twitchchat" } diff --git a/README.md b/README.md index 32387c9..a2daac4 100644 --- a/README.md +++ b/README.md @@ -4,83 +4,9 @@ [![Crates][crates_badge]][crates] [![Actions][actions_badge]][actions] -This crate provides a way to interact with [Twitch]'s chat. +This crate provides a way to interact with [Twitch][twitch]'s chat. -Along with parse messages as Rust types, it provides methods for sending messages. - -It also provides an 'event' loop which you can use to make a bot. - -## Opt-in features - -By default, this crate depends on zero external crates -- but it makes it rather limited in scope. It can just parse/decode/encode to standard trait types (`std::io::{Read, Write}`). - -To use the `AsyncRunner` (an async-event loop) you must able the `async` feature. - -**_NOTE_** This is a breaking change from `0.12` which had the async stuff enabled by default. - -```toml -twitchchat = { version = "0.14", features = ["async"] } -``` - -To use a specific `TcpStream`/`TlStream` refer to the runtime table below. - -## Serde support - -To enable serde support, simply enable the optional `serde` feature - -## Runtime - -This crate is runtime agonostic. To use.. - -| Read/Write provider | Features | -| ---------------------------------------------------------- | ------------------------ | -| [`async_io`](https://docs.rs/async-io/latest/async_io/) | `async-io` | -| [`smol`](https://docs.rs/smol/latest/smol/) | `smol` | -| [`async_std`](https://docs.rs/async-std/latest/async_std/) | `async-std` | -| [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` and `tokio-util` | - -### TLS - -If you want TLS supports, enable the above runtime and also enable the cooresponding features: - -| Read/Write provider | Runtime | Features | TLS backend | -| ---------------------------------------------------------- | ----------- | ---------------------------------------------------- | -------------------------- | -| [`async_io`](https://docs.rs/async-io/latest/async_io/) | `async_io` | `"async-tls"` | [`rustls`][rustls] | -| [`smol`](https://docs.rs/smol/latest/smol/) | `smol` | `"async-tls"` | [`rustls`][rustls] | -| [`async_std`](https://docs.rs/async-std/latest/async_std/) | `async_std` | `"async-tls"` | [`rustls`][rustls] | -| [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-rustls"`, `"webpki-roots"` | [`rustls`][rustls] | -| [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-native-tls"`, `"native-tls"` | [`native-tls`][native-tls] | -| [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-openssl"`, `"openssl"` | [`openssl`][openssl] | - -[rustls]: https://docs.rs/rustls/0.18.1/rustls/ -[native-tls]: https://docs.rs/native-tls/0.2.4/native_tls/ -[openssl]: https://docs.rs/openssl/0.10/openssl/ - -## Examples - -#### Using async_io to connect with.. - -- [async_io_demo.rs](./examples/async_io_demo.rs) - -#### Using async_std to connect with.. - -- [async_std_demo.rs](./examples/async_std_demo.rs) - -#### Using smol to connect with.. - -- [smol_demo.rs](./examples/smol_demo.rs) - -#### Using tokio to connect with.. - -- [tokio_demo.rs](./examples/tokio_demo.rs) - -#### How to use the crate as just a message parser(decoder)/encoder - -- [message_parse.rs](./examples/message_parse.rs) - -#### An a simple example of how one could built a bot with this - -- [simple_bot.rs](./examples/simple_bot.rs) +# Note, This branch is a major WIP for the next (`v0.15.0`) release ## License diff --git a/examples/async_io_demo.rs b/examples/async_io_demo.rs deleted file mode 100644 index a0d99b2..0000000 --- a/examples/async_io_demo.rs +++ /dev/null @@ -1,85 +0,0 @@ -// NOTE: this demo requires `--feature async-io`. -use twitchchat::{commands, connector, runner::AsyncRunner, UserConfig}; - -// this is a helper module to reduce code deduplication -mod include; -use crate::include::{channels_to_join, get_user_config, main_loop}; - -async fn connect(user_config: &UserConfig, channels: &[String]) -> anyhow::Result { - // create a connector using ``async_io``, this connects to Twitch. - // you can provide a different address with `custom` - // this can fail if DNS resolution cannot happen - let connector = connector::async_io::Connector::twitch()?; - - println!("we're connecting!"); - // create a new runner. this is a provided async 'main loop' - // this method will block until you're ready - let mut runner = AsyncRunner::connect(connector, user_config).await?; - println!("..and we're connected"); - - // and the identity Twitch gave you - println!("our identity: {:#?}", runner.identity); - - for channel in channels { - // the runner itself has 'blocking' join/part to ensure you join/leave a channel. - // these two methods return whether the connection was closed early. - // we'll ignore it for this demo - println!("attempting to join '{}'", channel); - let _ = runner.join(channel).await?; - println!("joined '{}'!", channel); - } - - Ok(runner) -} - -fn main() -> anyhow::Result<()> { - // create a user configuration - let user_config = get_user_config()?; - // get some channels to join from the environment - let channels = channels_to_join()?; - - // any executor would work, we'll use async_executor so can spawn tasks - let executor = async_executor::Executor::new(); - futures_lite::future::block_on(executor.run(async { - // connect and join the provided channels - let runner = connect(&user_config, &channels).await?; - - // you can get a handle to shutdown the runner - let quit_handle = runner.quit_handle(); - - // you can get a clonable writer - let mut writer = runner.writer(); - - // spawn something off in the background that'll exit in 10 seconds - executor - .spawn({ - let mut writer = writer.clone(); - let channels = channels.clone(); - async move { - println!("in 10 seconds we'll exit"); - async_io::Timer::after(std::time::Duration::from_secs(10)).await; - - // send one final message to all channels - for channel in channels { - let cmd = commands::privmsg(&channel, "goodbye, world"); - writer.encode(cmd).await.unwrap(); - } - - println!("sending quit signal"); - quit_handle.notify().await; - } - }) - .detach(); - - // you can encode all sorts of 'commands' - for channel in &channels { - writer - .encode(commands::privmsg(channel, "hello world!")) - .await?; - } - - println!("starting main loop"); - // your 'main loop'. you'll just call next_message() until you're done - main_loop(runner).await - })) -} diff --git a/examples/async_std_demo.rs b/examples/async_std_demo.rs deleted file mode 100644 index 662fd72..0000000 --- a/examples/async_std_demo.rs +++ /dev/null @@ -1,84 +0,0 @@ -// NOTE: this demo requires `--features="async-std async-std/attributes"`. -use twitchchat::{ - commands, connector, messages, - runner::{AsyncRunner, Status}, - UserConfig, -}; - -// this is a helper module to reduce code deduplication -mod include; -use crate::include::{channels_to_join, get_user_config, main_loop}; - -async fn connect(user_config: &UserConfig, channels: &[String]) -> anyhow::Result { - // create a connector using ``async_std``, this connects to Twitch. - // you can provide a different address with `custom` - let connector = connector::async_std::Connector::twitch()?; - - println!("we're connecting!"); - // create a new runner. this is a provided async 'main loop' - // this method will block until you're ready - let mut runner = AsyncRunner::connect(connector, user_config).await?; - println!("..and we're connected"); - - // and the identity Twitch gave you - println!("our identity: {:#?}", runner.identity); - - for channel in channels { - // the runner itself has 'blocking' join/part to ensure you join/leave a channel. - // these two methods return whether the connection was closed early. - // we'll ignore it for this demo - println!("attempting to join '{}'", channel); - let _ = runner.join(&channel).await?; - println!("joined '{}'!", channel); - } - - Ok(runner) -} - -#[async_std::main] -async fn main() -> anyhow::Result<()> { - // create a user configuration - let user_config = get_user_config()?; - // get some channels to join from the environment - let channels = channels_to_join()?; - - // connect and join the provided channels - let runner = connect(&user_config, &channels).await?; - - // you can get a handle to shutdown the runner - let quit_handle = runner.quit_handle(); - - // you can get a clonable writer - let mut writer = runner.writer(); - - // spawn something off in the background that'll exit in 10 seconds - async_std::task::spawn({ - let mut writer = writer.clone(); - let channels = channels.clone(); - async move { - println!("in 10 seconds we'll exit"); - async_std::task::sleep(std::time::Duration::from_secs(10)).await; - - // send one final message to all channels - for channel in channels { - let cmd = commands::privmsg(&channel, "goodbye, world"); - writer.encode(cmd).await.unwrap(); - } - - println!("sending quit signal"); - assert!(quit_handle.notify().await); - } - }); - - // you can encode all sorts of 'commands' - - for channel in &channels { - writer - .encode(commands::privmsg(channel, "hello world!")) - .await?; - } - - println!("starting main loop"); - // your 'main loop'. you'll just call next_message() until you're done - main_loop(runner).await -} diff --git a/examples/include/mod.rs b/examples/include/mod.rs deleted file mode 100644 index 8925582..0000000 --- a/examples/include/mod.rs +++ /dev/null @@ -1,97 +0,0 @@ -#![allow(dead_code)] -use anyhow::Context as _; -use twitchchat::{messages, AsyncRunner, Status, UserConfig}; - -// some helpers for the demo -fn get_env_var(key: &str) -> anyhow::Result { - std::env::var(key).with_context(|| format!("please set `{}`", key)) -} - -pub fn get_user_config() -> anyhow::Result { - let name = get_env_var("TWITCH_NAME")?; - let token = get_env_var("TWITCH_TOKEN")?; - - // you need a `UserConfig` to connect to Twitch - let config = UserConfig::builder() - // the name of the associated twitch account - .name(name) - // and the provided OAuth token - .token(token) - // and enable all of the advanced message signaling from Twitch - .enable_all_capabilities() - .build()?; - - Ok(config) -} - -// channels can be either in the form of '#museun' or 'museun'. the crate will internally add the missing # -pub fn channels_to_join() -> anyhow::Result> { - let channels = get_env_var("TWITCH_CHANNEL")? - .split(',') - .map(ToString::to_string) - .collect(); - Ok(channels) -} - -// a 'main loop' -pub async fn main_loop(mut runner: AsyncRunner) -> anyhow::Result<()> { - loop { - match runner.next_message().await? { - // this is the parsed message -- across all channels (and notifications from Twitch) - Status::Message(msg) => { - handle_message(msg).await; - } - - // you signaled a quit - Status::Quit => { - println!("we signaled we wanted to quit"); - break; - } - // the connection closed normally - Status::Eof => { - println!("we got a 'normal' eof"); - break; - } - } - } - - Ok(()) -} - -// you can generally ignore the lifetime for these types. -async fn handle_message(msg: messages::Commands<'_>) { - use messages::Commands::*; - // All sorts of messages - match msg { - // This is the one users send to channels - Privmsg(msg) => println!("[{}] {}: {}", msg.channel(), msg.name(), msg.data()), - - // This one is special, if twitch adds any new message - // types, this will catch it until future releases of - // this crate add them. - Raw(_) => {} - - // These happen when you initially connect - IrcReady(_) => {} - Ready(_) => {} - Cap(_) => {} - - // and a bunch of other messages you may be interested in - ClearChat(_) => {} - ClearMsg(_) => {} - GlobalUserState(_) => {} - HostTarget(_) => {} - Join(_) => {} - Notice(_) => {} - Part(_) => {} - Ping(_) => {} - Pong(_) => {} - Reconnect(_) => {} - RoomState(_) => {} - UserNotice(_) => {} - UserState(_) => {} - Whisper(_) => {} - - _ => {} - } -} diff --git a/examples/message_parse.rs b/examples/message_parse.rs deleted file mode 100644 index 2c6917c..0000000 --- a/examples/message_parse.rs +++ /dev/null @@ -1,161 +0,0 @@ -use twitchchat::{ - messages, - // for `from_irc()` - FromIrcMessage as _, - // for into_owned() - IntoOwned as _, -}; - -fn main() { - // show off the low-level parser - parse_demo(); - - // this provides a 'reader'/'iterator' instead of just a boring parser - decoder_demo(); - - // and block on a future for the async decoder - futures_lite::future::block_on(decoder_async_demo()) -} - -fn parse_demo() { - let input = - "@key1=val1;key2=true;key3=42 :sender!sender@server PRIVMSG #museun :this is a test\r\n"; - - // you can get an iterator of messages that borrow from the input string - for msg in twitchchat::irc::parse(input) { - let msg: twitchchat::IrcMessage<'_> = msg.unwrap(); - // you can get the raw string back - assert_eq!(msg.get_raw(), input); - - // you can parse it into a specific type. e.g. a PRIVMSG. - // this continues to borrow from the original string slice - let pm = messages::Privmsg::from_irc(msg).unwrap(); - assert_eq!(pm.channel(), "#museun"); - - // you can consume the parsed message to get the raw string back out. - // this gives you a MaybeOwned<'a> because the type can be converted to an owned state (e.g. static); - let msg: twitchchat::maybe_owned::MaybeOwned<'_> = pm.into_inner(); - - // `MaybeOwned<'a>` can be used as a `&'a str`. - let msg = twitchchat::irc::parse(&*msg) - .next() - .map(|s| s.unwrap()) - .unwrap(); - - // parse it as an Commands, which wraps all of the provided messages - let all = messages::Commands::from_irc(msg).unwrap(); - assert!(matches!(all, messages::Commands::Privmsg{..})); - - // this is still borrowing from the 'input' from above. - let all: messages::Commands<'_> = all; - - // to turn it into an 'owned' version (e.g. a 'static lifetime) - let all = all.into_owned(); - let _all: messages::Commands<'static> = all; - } - - // double the string for the test - let old_len = input.len(); - let input = input.repeat(2); - - // you can also parse a 'single' message in a streaming fashion - // this returns a pos > 0 if the index of the start of the next possible message - let (pos, msg_a) = twitchchat::irc::parse_one(&input).unwrap(); - assert_eq!(pos, old_len); - - // and parse the rest of the message - // this returns a pos if 0 if this was the last message - let (pos, msg_b) = twitchchat::irc::parse_one(&input[pos..]).unwrap(); - assert_eq!(pos, 0); - - // and it should've parsed the same message twice - assert_eq!(msg_a, msg_b); - - // and you can get the a tags 'view' from the message, if any tags were provided - let msg = messages::Privmsg::from_irc(msg_a).unwrap(); - // you can get the string value for a key - assert_eq!(msg.tags().get("key1").unwrap(), "val1"); - // or it as a 'truthy' value - assert_eq!(msg.tags().get_as_bool("key2"), true); - // or as a FromStr parsed value - assert_eq!(msg.tags().get_parsed::<_, i32>("key3").unwrap(), 42); - - // you can convert a parsed message into an Commands easily by using From/Into; - let all: messages::Commands<'_> = msg_b.into(); - assert!(matches!(all, messages::Commands::Raw{..})); -} - -fn decoder_demo() { - let input = - "@key1=val1;key2=true;key3=42 :sender!sender@server PRIVMSG #museun :this is a test\r\n"; - - let source = input.repeat(5); - // Cursor> impl std::io::Read. using it for this demo - let reader = std::io::Cursor::new(source.into_bytes()); - - // you can make a decoder over an std::io::Read - let mut decoder = twitchchat::Decoder::new(reader); - - // you use use read_message than the 'msg' is borrowed until the next call of 'read_message' - while let Ok(_msg) = decoder.read_message() { - // msg is borrowed from the decoder here - } - - // you can get the inner reader out - let mut reader = decoder.into_inner(); - // seek back to the beginning for this demo - reader.set_position(0); - - { - // you can also just give it a &mut Reader - let _decoder = twitchchat::Decoder::new(&mut reader); - // which will drop the decoder here and you'll still have the 'reader' from above - } - - // the decoder is also an iterator. - // when using the iterator you'll get an 'owned' message back. - for msg in twitchchat::Decoder::new(&mut reader) { - // and msg is owned here ('static) - // error if it failed to parse, or an IO error. - let _msg: messages::IrcMessage<'static> = msg.unwrap(); - } -} - -// all of the Sync is also applicable to the Async version. -async fn decoder_async_demo() { - use futures_lite::StreamExt as _; // for 'next' on the Stream - - let input = - "@key1=val1;key2=true;key3=42 :sender!sender@server PRIVMSG #museun :this is a test\r\n"; - - let source = input.repeat(5); - // Cursor> impl std::io::Read. using it for this demo - let reader = futures_lite::io::Cursor::new(source.into_bytes()); - - // you can make a decoder over an std::io::Read - let mut decoder = twitchchat::AsyncDecoder::new(reader); - - // you use use read_message than the 'msg' is borrowed until the next call of 'read_message' - while let Ok(_msg) = decoder.read_message().await { - // msg is borrowed from the decoder here - } - - // you can get the inner reader out - let mut reader = decoder.into_inner(); - // seek back to the beginning for this demo - reader.set_position(0); - - { - // you can also just give it a &mut Reader - let _decoder = twitchchat::AsyncDecoder::new(&mut reader); - // which will drop the decoder here and you'll still have the 'reader' from above - } - - // the decoder is also an Stream. - // when using the Stream you'll get an 'owned' message back. - while let Some(msg) = twitchchat::AsyncDecoder::new(&mut reader).next().await { - // and msg is owned here ('static) - // error if it failed to parse, or an IO error. - let _msg: messages::IrcMessage<'static> = msg.unwrap(); - } -} diff --git a/examples/simple_bot.rs b/examples/simple_bot.rs deleted file mode 100644 index fa3705a..0000000 --- a/examples/simple_bot.rs +++ /dev/null @@ -1,148 +0,0 @@ -// note this uses `smol`. you can use `tokio` or `async_std` or `async_io` if you prefer. -// this is a helper module to reduce code deduplication -// extensions to the Privmsg type -use twitchchat::PrivmsgExt as _; -use twitchchat::{ - messages::{Commands, Privmsg}, - runner::{AsyncRunner, NotifyHandle, Status}, - UserConfig, -}; - -// this is a helper module to reduce code deduplication -mod include; -use crate::include::{channels_to_join, get_user_config}; - -use std::collections::HashMap; - -fn main() -> anyhow::Result<()> { - // you'll need a user configuration - let user_config = get_user_config()?; - // and some channels to join - let channels = channels_to_join()?; - - let start = std::time::Instant::now(); - - let mut bot = Bot::default() - .with_command("!hello", |args: Args| { - let output = format!("hello {}!", args.msg.name()); - // We can 'reply' to this message using a writer + our output message - args.writer.reply(args.msg, &output).unwrap(); - }) - .with_command("!uptime", move |args: Args| { - let output = format!("its been running for {:.2?}", start.elapsed()); - // We can send a message back (without quoting the sender) using a writer + our output message - args.writer.say(args.msg, &output).unwrap(); - }) - .with_command("!quit", move |args: Args| { - // because we're using sync stuff, turn async into sync with smol! - smol::block_on(async move { - // calling this will cause read_message() to eventually return Status::Quit - args.quit.notify().await - }); - }); - - // run the bot in the executor - smol::block_on(async move { bot.run(&user_config, &channels).await }) -} - -struct Args<'a, 'b: 'a> { - msg: &'a Privmsg<'b>, - writer: &'a mut twitchchat::Writer, - quit: NotifyHandle, -} - -trait Command: Send + Sync { - fn handle(&mut self, args: Args<'_, '_>); -} - -impl Command for F -where - F: Fn(Args<'_, '_>), - F: Send + Sync, -{ - fn handle(&mut self, args: Args<'_, '_>) { - (self)(args) - } -} - -#[derive(Default)] -struct Bot { - commands: HashMap>, -} - -impl Bot { - // add this command to the bot - fn with_command(mut self, name: impl Into, cmd: impl Command + 'static) -> Self { - self.commands.insert(name.into(), Box::new(cmd)); - self - } - - // run the bot until its done - async fn run(&mut self, user_config: &UserConfig, channels: &[String]) -> anyhow::Result<()> { - // this can fail if DNS resolution cannot happen - let connector = twitchchat::connector::smol::Connector::twitch()?; - - let mut runner = AsyncRunner::connect(connector, user_config).await?; - println!("connecting, we are: {}", runner.identity.username()); - - for channel in channels { - println!("joining: {}", channel); - if let Err(err) = runner.join(channel).await { - eprintln!("error while joining '{}': {}", channel, err); - } - } - - // if you store this somewhere, you can quit the bot gracefully - // let quit = runner.quit_handle(); - - println!("starting main loop"); - self.main_loop(&mut runner).await - } - - // the main loop of the bot - async fn main_loop(&mut self, runner: &mut AsyncRunner) -> anyhow::Result<()> { - // this is clonable, but we can just share it via &mut - // this is rate-limited writer - let mut writer = runner.writer(); - // this is clonable, but using it consumes it. - // this is used to 'quit' the main loop - let quit = runner.quit_handle(); - - loop { - // this drives the internal state of the crate - match runner.next_message().await? { - // if we get a Privmsg (you'll get an Commands enum for all messages received) - Status::Message(Commands::Privmsg(pm)) => { - // see if its a command and do stuff with it - if let Some(cmd) = Self::parse_command(pm.data()) { - if let Some(command) = self.commands.get_mut(cmd) { - println!("dispatching to: {}", cmd.escape_debug()); - - let args = Args { - msg: &pm, - writer: &mut writer, - quit: quit.clone(), - }; - - command.handle(args); - } - } - } - // stop if we're stopping - Status::Quit | Status::Eof => break, - // ignore the rest - Status::Message(..) => continue, - } - } - - println!("end of main loop"); - Ok(()) - } - - fn parse_command(input: &str) -> Option<&str> { - if !input.starts_with('!') { - return None; - } - input.splitn(2, ' ').next() - } -} diff --git a/examples/smol_demo.rs b/examples/smol_demo.rs deleted file mode 100644 index bf016fa..0000000 --- a/examples/smol_demo.rs +++ /dev/null @@ -1,84 +0,0 @@ -// NOTE: this demo requires `--feature smol`. -use twitchchat::{commands, connector, runner::AsyncRunner, UserConfig}; - -// this is a helper module to reduce code deduplication -mod include; -use crate::include::{channels_to_join, get_user_config, main_loop}; - -async fn connect(user_config: &UserConfig, channels: &[String]) -> anyhow::Result { - // create a connector using ``smol``, this connects to Twitch. - // you can provide a different address with `custom` - // this can fail if DNS resolution cannot happen - let connector = connector::smol::Connector::twitch()?; - - println!("we're connecting!"); - // create a new runner. this is a provided async 'main loop' - // this method will block until you're ready - let mut runner = AsyncRunner::connect(connector, user_config).await?; - println!("..and we're connected"); - - // and the identity Twitch gave you - println!("our identity: {:#?}", runner.identity); - - for channel in channels { - // the runner itself has 'blocking' join/part to ensure you join/leave a channel. - // these two methods return whether the connection was closed early. - // we'll ignore it for this demo - println!("attempting to join '{}'", channel); - let _ = runner.join(channel).await?; - println!("joined '{}'!", channel); - } - - Ok(runner) -} - -fn main() -> anyhow::Result<()> { - let fut = async move { - // create a user configuration - let user_config = get_user_config()?; - // get some channels to join from the environment - let channels = channels_to_join()?; - - // connect and join the provided channels - let runner = connect(&user_config, &channels).await?; - - // you can get a handle to shutdown the runner - let quit_handle = runner.quit_handle(); - - // you can get a clonable writer - let mut writer = runner.writer(); - - // spawn something off in the background that'll exit in 10 seconds - smol::spawn({ - let mut writer = writer.clone(); - let channels = channels.clone(); - async move { - println!("in 10 seconds we'll exit"); - smol::Timer::after(std::time::Duration::from_secs(10)).await; - - // send one final message to all channels - for channel in channels { - let cmd = commands::privmsg(&channel, "goodbye, world"); - writer.encode(cmd).await.unwrap(); - } - - println!("sending quit signal"); - quit_handle.notify().await; - } - }) - .detach(); - - // you can encode all sorts of 'commands' - for channel in &channels { - writer - .encode(commands::privmsg(channel, "hello world!")) - .await?; - } - - println!("starting main loop"); - // your 'main loop'. you'll just call next_message() until you're done - main_loop(runner).await - }; - - smol::block_on(fut) -} diff --git a/examples/tokio_demo.rs b/examples/tokio_demo.rs deleted file mode 100644 index 26bc7f6..0000000 --- a/examples/tokio_demo.rs +++ /dev/null @@ -1,83 +0,0 @@ -// NOTE: this demo requires `--features="tokio/full tokio-util"`. -use twitchchat::{ - commands, connector, messages, - runner::{AsyncRunner, Status}, - UserConfig, -}; - -// this is a helper module to reduce code deduplication -mod include; -use crate::include::{channels_to_join, get_user_config, main_loop}; - -async fn connect(user_config: &UserConfig, channels: &[String]) -> anyhow::Result { - // create a connector using ``tokio``, this connects to Twitch. - // you can provide a different address with `custom` - let connector = connector::tokio::Connector::twitch()?; - - println!("we're connecting!"); - // create a new runner. this is a provided async 'main loop' - // this method will block until you're ready - let mut runner = AsyncRunner::connect(connector, user_config).await?; - println!("..and we're connected"); - - // and the identity Twitch gave you - println!("our identity: {:#?}", runner.identity); - - for channel in channels { - // the runner itself has 'blocking' join/part to ensure you join/leave a channel. - // these two methods return whether the connection was closed early. - // we'll ignore it for this demo - println!("attempting to join '{}'", channel); - let _ = runner.join(&channel).await?; - println!("joined '{}'!", channel); - } - - Ok(runner) -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // create a user configuration - let user_config = get_user_config()?; - // get some channels to join from the environment - let channels = channels_to_join()?; - - // connect and join the provided channels - let runner = connect(&user_config, &channels).await?; - - // you can get a handle to shutdown the runner - let quit_handle = runner.quit_handle(); - - // you can get a clonable writer - let mut writer = runner.writer(); - - // spawn something off in the background that'll exit in 10 seconds - tokio::spawn({ - let mut writer = writer.clone(); - let channels = channels.clone(); - async move { - println!("in 10 seconds we'll exit"); - tokio::time::delay_for(std::time::Duration::from_secs(10)).await; - - // send one final message to all channels - for channel in channels { - let cmd = commands::privmsg(&channel, "goodbye, world"); - writer.encode(cmd).await.unwrap(); - } - - println!("sending quit signal"); - quit_handle.notify().await; - } - }); - - // you can encode all sorts of 'commands' - for channel in &channels { - writer - .encode(commands::privmsg(channel, "hello world!")) - .await?; - } - - println!("starting main loop"); - // your 'main loop'. you'll just call next_message() until you're done - main_loop(runner).await -} diff --git a/src/channel.rs b/src/channel.rs deleted file mode 100644 index 7755616..0000000 --- a/src/channel.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Simple async/sync channels used in various parts of this crate. -use std::{ - pin::Pin, - task::{Context, Poll}, -}; - -/// An error on send -#[derive(Debug)] -pub enum TrySendError { - /// The receiver was closed - Closed(T), - /// The receiver was full - Full(T), -} - -/// Async and Sync MPMP Sender. -#[derive(Clone)] -pub struct Sender { - inner: async_channel::Sender, -} - -impl std::fmt::Debug for Sender { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Sender").finish() - } -} - -impl Sender { - /// Send this item asynchronously. - /// - /// On failure, return the sent item. - pub async fn send(&self, item: T) -> Result<(), T> - where - T: Send, - { - self.inner.send(item).await.map_err(|e| e.into_inner()) - } - - /// Send this item synchronously. - /// - /// On failure, returns why and the item. - pub fn try_send(&self, item: T) -> Result<(), TrySendError> { - self.inner.try_send(item).map_err(|e| match e { - async_channel::TrySendError::Full(t) => TrySendError::Full(t), - async_channel::TrySendError::Closed(t) => TrySendError::Closed(t), - }) - } -} - -pin_project_lite::pin_project! { - /// Async and Sync MPMP Receiver. - #[derive(Clone)] - pub struct Receiver { - #[pin] - inner: async_channel::Receiver, - } -} - -impl Receiver { - /// Asynchronously receives an item - /// - /// If this returns None, the Sender was closed - pub async fn recv(&self) -> Option - where - T: Send, - { - self.inner.recv().await.ok() - } - - /// Close the receiver - pub fn close(&self) -> bool { - self.inner.close() - } - - /// Synchronously receives an item - /// - /// If this returns None, the Sender was closed - pub fn try_recv(&self) -> Option { - self.inner.try_recv().ok() - } -} - -impl futures_lite::Stream for Receiver { - type Item = T; - fn poll_next(self: Pin<&mut Self>, ctx: &mut Context<'_>) -> Poll> { - let this = self.project(); - this.inner.poll_next(ctx) - } -} - -/// Create a bounded channel -pub fn bounded(cap: usize) -> (Sender, Receiver) { - let (tx, rx) = async_channel::bounded(cap); - (Sender { inner: tx }, Receiver { inner: rx }) -} - -/// Create an unbounded channel -pub fn unbounded() -> (Sender, Receiver) { - let (tx, rx) = async_channel::unbounded(); - (Sender { inner: tx }, Receiver { inner: rx }) -} diff --git a/src/connector.rs b/src/connector.rs deleted file mode 100644 index 95cd8c5..0000000 --- a/src/connector.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! This module lets you choose which runtime you want to use. -//! -//! By default, TLS is disabled to make building the crate on various platforms easier. -//! -//! To use.. -//! -//! | Read/Write provider | Features | -//! | --- | --- | -//! | [`async_io`](https://docs.rs/async-io/latest/async_io/) |`async-io` | -//! | [`smol`](https://docs.rs/smol/latest/smol/) |`smol` | -//! | [`async_std`](https://docs.rs/async-std/latest/async_std/) |`async-std` | -//! | [`tokio`](https://docs.rs/tokio/0.2/tokio/) |`tokio` and `tokio-util` | -//! -//! ## TLS -//! -//! If you want TLS supports, enable the above runtime and also enable the cooresponding features: -//! -//! | Read/Write provider | Runtime | Features | TLS backend | -//! | ---------------------------------------------------------- | ----------- | ---------------------------------------------------- | -------------------------- | -//! | [`async_io`](https://docs.rs/async-io/latest/async_io/) | `async_io` | `"async-tls"` | [`rustls`][rustls] | -//! | [`smol`](https://docs.rs/smol/latest/smol/) | `smol` | `"async-tls"` | [`rustls`][rustls] | -//! | [`async_std`](https://docs.rs/async-std/latest/async_std/) | `async_std` | `"async-tls"` | [`rustls`][rustls] | -//! | [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-rustls"`, `"webpki-roots"` | [`rustls`][rustls] | -//! | [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-native-tls"`, `"native-tls"` | [`native-tls`][native-tls] | -//! | [`tokio`](https://docs.rs/tokio/0.2/tokio/) | `tokio` | `"tokio-util"`, `"tokio-openssl"`, `"openssl"` | [`openssl`][openssl] | -//! -//! [rustls]: https://docs.rs/rustls/0.18.1/rustls/ -//! [native-tls]: https://docs.rs/native-tls/0.2.4/native_tls/ -//! [openssl]: https://docs.rs/openssl/0.10/openssl/ -//! -use futures_lite::{AsyncRead, AsyncWrite}; -use std::{future::Future, io::Result as IoResult, net::SocketAddr}; - -#[allow(unused_macros)] -macro_rules! connector_ctor { - (non_tls: $(#[$meta:meta])*) => { - #[doc = "Create a new"] - $(#[$meta])* - #[doc = "non-TLS connector that connects to the ***default Twitch*** address."] - pub fn twitch() -> ::std::io::Result { - Self::custom($crate::TWITCH_IRC_ADDRESS) - } - - #[doc = "Create a new"] - $(#[$meta])* - #[doc = "non-TLS connector with a custom address."] - pub fn custom(addrs: A) -> ::std::io::Result - where - A: ::std::net::ToSocketAddrs, - { - addrs.to_socket_addrs().map(|addrs| Self { - addrs: addrs.collect(), - }) - } - }; - - (tls: $(#[$meta:meta])*) => { - #[doc = "Create a new"] - $(#[$meta])* - #[doc = "TLS connector that connects to the ***default Twitch*** address."] - pub fn twitch() -> ::std::io::Result { - Self::custom($crate::TWITCH_IRC_ADDRESS_TLS, $crate::TWITCH_TLS_DOMAIN) - } - - - #[doc = "Create a new"] - $(#[$meta])* - #[doc = "TLS connector with a custom address and TLS domain."] - pub fn custom(addrs: A, domain: D) -> ::std::io::Result - where - A: ::std::net::ToSocketAddrs, - D: Into<::std::string::String>, - { - let tls_domain = domain.into(); - addrs.to_socket_addrs().map(|addrs| Self { - addrs: addrs.collect(), - tls_domain, - }) - } - }; -} - -#[cfg(feature = "async-io")] -/// Connector for using an [`async_io`](https://docs.rs/async-io/latest/async_io/) wrapper over [`std::net::TcpStream`](https://doc.rust-lang.org/std/net/struct.TcpStream.html) -pub mod async_io; - -#[cfg(feature = "async-io")] -#[doc(inline)] -pub use self::async_io::Connector as AsyncIoConnector; - -#[cfg(all(feature = "async-io", feature = "async-tls"))] -#[doc(inline)] -pub use self::async_io::ConnectorTls as AsyncIoConnectorTls; - -#[cfg(feature = "async-std")] -/// Connector for using an [`async_std::net::TcpStream`](https://docs.rs/async-std/latest/async_std/net/struct.TcpStream.html) -pub mod async_std; - -#[cfg(feature = "async-std")] -#[doc(inline)] -pub use self::async_std::Connector as AsyncStdConnector; - -#[cfg(all(feature = "async-std", feature = "async-tls"))] -#[doc(inline)] -pub use self::async_std::ConnectorTls as AsyncStdConnectorTls; - -#[cfg(feature = "smol")] -/// Connector for using a [`smol::Async`](https://docs.rs/smol/latest/smol/struct.Async.html) wrapper over [`std::net::TcpStream`](https://doc.rust-lang.org/std/net/struct.TcpStream.html) -pub mod smol; - -#[cfg(feature = "smol")] -#[doc(inline)] -pub use self::smol::Connector as SmolConnector; - -#[cfg(all(feature = "smol", feature = "async-tls"))] -#[doc(inline)] -pub use self::smol::ConnectorTls as SmolConnectorTls; - -#[cfg(all(feature = "tokio", feature = "tokio-util"))] -/// Connector for using a [`tokio::net::TcpStream`](https://docs.rs/tokio/0.2/tokio/net/struct.TcpStream.html) -pub mod tokio; - -#[cfg(all(feature = "tokio", feature = "tokio-util"))] -#[doc(inline)] -pub use self::tokio::Connector as TokioConnector; - -#[cfg(all( - feature = "tokio", - feature = "tokio-util", - feature = "tokio-rustls", - feature = "webpki-roots" -))] -#[doc(inline)] -pub use self::tokio::ConnectorRustTls as TokioConnectorRustTls; - -#[cfg(all( - feature = "tokio", - feature = "tokio-util", - feature = "tokio-native-tls", - feature = "native-tls" -))] -#[doc(inline)] -pub use self::tokio::ConnectorNativeTls as TokioConnectorNativeTls; - -#[cfg(all( - feature = "tokio", - feature = "tokio-util", - feature = "tokio-openssl", - feature = "openssl" -))] -#[doc(inline)] -pub use self::tokio::ConnectorOpenSsl as TokioConnectorOpenSsl; - -/// The connector trait. This is used to abstract out runtimes. -/// -/// You can implement this on your own type to provide a custom connection behavior. -pub trait Connector: Send + Sync + Clone { - /// Output IO type returned by calling `connect` - /// - /// This type must implement `futures::io::AsyncRead` and `futures::io::AsyncWrite` - type Output: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static; - /// The `connect` method. This should return a boxed future of a `std::io::Result` of the `Output` type. - /// - /// e.g. `Box::pin(async move { std::net::TcpStream::connect("someaddr") })` - fn connect(&mut self) -> crate::BoxedFuture>; -} - -// This is used because smol/async_io uses an indv. SocketAddr for their connect -// instead of the normal ToSocketAddrs trait -// -// thus this will be dead if those features aren't enabled. -#[allow(dead_code)] -async fn try_connect(addrs: &[SocketAddr], connect: F) -> IoResult -where - F: Fn(SocketAddr) -> R + Send, - R: Future> + Send, - T: Send, -{ - let mut last = None; - for addr in addrs { - let fut = connect(*addr); - match fut.await { - Ok(socket) => return Ok(socket), - Err(err) => last.replace(err), - }; - } - - match last { - Some(last) => Err(last), - None => Err(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - "cannot connect with any provided address", - )), - } -} - -mod required { - #[cfg(all( - feature = "async-tls", - not(any(feature = "async-io", feature = "async-std", feature = "smol")) - ))] - compile_error! { - "'async-io' or 'async-std' or 'smol' must be enabled when 'async-tls' is enabled" - } - - #[cfg(all(feature = "tokio", not(feature = "tokio-util")))] - compile_error! { - "'tokio-util' must be enabled when 'tokio' is enabled" - } - - #[cfg(all( - feature = "tokio-native-tls", - not(all(feature = "tokio", feature = "tokio-util", feature = "native-tls")) - ))] - compile_error! { - "'tokio', 'tokio-util' and 'native-tls' must be enabled when 'tokio-native-tls' is enabled" - } - - #[cfg(all( - feature = "tokio-rustls", - not(all(feature = "tokio", feature = "tokio-util", feature = "webpki-roots")) - ))] - compile_error! { - "'tokio', 'tokio-util' and 'webpki-roots' must be enabled when 'tokio-rustls' is enabled" - } - - #[cfg(all( - feature = "tokio-openssl", - not(all(feature = "tokio", feature = "tokio-util", feature = "openssl")) - ))] - compile_error! { - "'tokio', 'tokio-util' and 'openssl' must be enabled when 'tokio-openssl' is enabled" - } -} - -#[cfg(test)] -#[allow(dead_code)] -mod testing { - use crate::connector::Connector as ConnectorTrait; - use futures_lite::{AsyncRead, AsyncWrite}; - - pub fn assert_connector() {} - pub fn assert_type_is_read_write() {} - pub fn assert_obj_is_sane(_obj: T) - where - T: ConnectorTrait, - T::Output: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static, - for<'a> &'a T::Output: AsyncRead + AsyncWrite + Send + Sync + Unpin, - { - } -} diff --git a/src/connector/async_io/mod.rs b/src/connector/async_io/mod.rs deleted file mode 100644 index bf2d13f..0000000 --- a/src/connector/async_io/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::connector::try_connect; -use crate::BoxedFuture; - -type TcpStream = async_io::Async; - -mod non_tls; -pub use non_tls::*; - -#[cfg(feature = "async-tls")] -mod tls; - -#[cfg(feature = "async-tls")] -pub use tls::*; diff --git a/src/connector/async_io/non_tls.rs b/src/connector/async_io/non_tls.rs deleted file mode 100644 index f13aa1a..0000000 --- a/src/connector/async_io/non_tls.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; - -/// A `async_io` connector. This does not use TLS -#[derive(Debug, Clone, PartialEq)] -pub struct Connector { - addrs: Vec, -} - -impl Connector { - connector_ctor!(non_tls: - /// [`async_io`](https://docs.rs/async-io/latest/async_io/) - ); -} - -impl crate::connector::Connector for Connector { - type Output = TcpStream; - - fn connect(&mut self) -> BoxedFuture> { - let addrs = self.addrs.clone(); - let fut = async move { try_connect(&*addrs, TcpStream::connect).await }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(Connector::twitch().unwrap()); - } -} diff --git a/src/connector/async_io/tls.rs b/src/connector/async_io/tls.rs deleted file mode 100644 index 56e7435..0000000 --- a/src/connector/async_io/tls.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::*; -use std::io::Result; - -/// A `async_io` connector that uses `async-tls` (a `rustls` wrapper). This uses TLS. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorTls { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorTls { - connector_ctor!(tls: - /// [`async_io`](https://docs.rs/async-io/latest/async_io/) - ); -} - -impl crate::connector::Connector for ConnectorTls { - type Output = async_dup::Mutex>; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - let fut = async move { - let stream = try_connect(&*this.addrs, TcpStream::connect).await?; - async_tls::TlsConnector::new() - .connect(this.tls_domain, stream) - .await - .map(async_dup::Mutex::new) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorTls::twitch().unwrap()); - } -} diff --git a/src/connector/async_std/mod.rs b/src/connector/async_std/mod.rs deleted file mode 100644 index d21eb3a..0000000 --- a/src/connector/async_std/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::BoxedFuture; - -mod non_tls; -pub use non_tls::*; - -#[cfg(feature = "async-tls")] -mod tls; - -#[cfg(feature = "async-tls")] -pub use tls::*; diff --git a/src/connector/async_std/non_tls.rs b/src/connector/async_std/non_tls.rs deleted file mode 100644 index bfb8d58..0000000 --- a/src/connector/async_std/non_tls.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; - -/// A `async_std` connector. This does not use TLS -#[derive(Debug, Clone, PartialEq)] -pub struct Connector { - addrs: Vec, -} - -impl Connector { - connector_ctor!(non_tls: - /// [`async-std`](https://docs.rs/async-std/latest/async_std/) - ); -} - -impl crate::connector::Connector for Connector { - type Output = async_std::net::TcpStream; - - fn connect(&mut self) -> BoxedFuture> { - let addrs = self.addrs.clone(); - let fut = async move { async_std::net::TcpStream::connect(&*addrs).await }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(Connector::twitch().unwrap()); - } -} diff --git a/src/connector/async_std/tls.rs b/src/connector/async_std/tls.rs deleted file mode 100644 index 70532bb..0000000 --- a/src/connector/async_std/tls.rs +++ /dev/null @@ -1,49 +0,0 @@ -use super::*; - -/// A `async_std` connector that uses `async-tls` (a `rustls` wrapper). This uses TLS. -/// -/// To use this type, ensure you set up the 'TLS Domain' in the configuration. -/// -/// The crate provides the 'TLS domain' for Twitch in the root of this crate. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorTls { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorTls { - connector_ctor!(tls: - /// [`async-std`](https://docs.rs/async-std/latest/async_std/) - ); -} - -impl crate::connector::Connector for ConnectorTls { - type Output = async_dup::Mutex>; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - let fut = async move { - let stream = async_std::net::TcpStream::connect(&*this.addrs).await?; - async_tls::TlsConnector::new() - .connect(this.tls_domain, stream) - .await - .map(async_dup::Mutex::new) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorTls::twitch().unwrap()); - } -} diff --git a/src/connector/smol/mod.rs b/src/connector/smol/mod.rs deleted file mode 100644 index c0e5ddd..0000000 --- a/src/connector/smol/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::connector::try_connect; -use crate::BoxedFuture; - -type TcpStream = smol::Async; - -mod non_tls; -pub use non_tls::*; - -#[cfg(feature = "async-tls")] -mod tls; - -#[cfg(feature = "async-tls")] -pub use tls::*; diff --git a/src/connector/smol/non_tls.rs b/src/connector/smol/non_tls.rs deleted file mode 100644 index 9b66047..0000000 --- a/src/connector/smol/non_tls.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; - -/// A `smol` connector. This does not use TLS -#[derive(Debug, Clone, PartialEq)] -pub struct Connector { - addrs: Vec, -} - -impl Connector { - connector_ctor!(non_tls: - /// [`smol`](https://docs.rs/smol/latest/smol/) - ); -} - -impl crate::connector::Connector for Connector { - type Output = TcpStream; - - fn connect(&mut self) -> BoxedFuture> { - let addrs = self.addrs.clone(); - let fut = async move { try_connect(&*addrs, TcpStream::connect).await }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(Connector::twitch().unwrap()); - } -} diff --git a/src/connector/smol/tls.rs b/src/connector/smol/tls.rs deleted file mode 100644 index bb3271e..0000000 --- a/src/connector/smol/tls.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::*; - -/// A `smol` connector that uses `async-tls` (a `rustls` wrapper). This uses TLS. -/// -/// To use this type, ensure you set up the 'TLS Domain' in the -/// configuration. The crate provides the 'TLS domain' for Twitch in the root of this crate. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorTls { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorTls { - connector_ctor!(tls: - /// [`smol`](https://docs.rs/smol/latest/smol/) - ); -} - -impl crate::connector::Connector for ConnectorTls { - type Output = async_dup::Mutex>; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - let fut = async move { - let stream = try_connect(&*this.addrs, TcpStream::connect).await?; - async_tls::TlsConnector::new() - .connect(this.tls_domain, stream) - .await - .map(async_dup::Mutex::new) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorTls::twitch().unwrap()); - } -} diff --git a/src/connector/tokio/mod.rs b/src/connector/tokio/mod.rs deleted file mode 100644 index f805974..0000000 --- a/src/connector/tokio/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::BoxedFuture; - -mod non_tls; -pub use non_tls::*; - -#[cfg(all(feature = "tokio-native-tls", feature = "native-tls"))] -mod native_tls; - -#[cfg(all(feature = "tokio-native-tls", feature = "native-tls"))] -pub use self::native_tls::*; - -#[cfg(all(feature = "tokio-rustls", feature = "webpki-roots"))] -mod rustls; - -#[cfg(all(feature = "tokio-rustls", feature = "webpki-roots"))] -pub use rustls::*; - -#[cfg(all(feature = "tokio-openssl", feature = "openssl"))] -mod openssl; - -#[cfg(all(feature = "tokio-openssl", feature = "openssl"))] -pub use self::openssl::*; diff --git a/src/connector/tokio/native_tls.rs b/src/connector/tokio/native_tls.rs deleted file mode 100644 index aa3e86a..0000000 --- a/src/connector/tokio/native_tls.rs +++ /dev/null @@ -1,61 +0,0 @@ -use super::*; - -/// A `tokio` connector that uses `tokio-native-tls` (a `native-tls` wrapper). This uses TLS. -/// -/// To use this type, ensure you set up the 'TLS Domain' in the configuration. -/// -/// The crate provides the 'TLS domain' for Twitch in the root of this crate. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorNativeTls { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorNativeTls { - connector_ctor!(tls: - /// [`tokio`](https://docs.rs/tokio/0.2/tokio/) (using [`tokio-native-tls`](https://docs.rs/tokio-native-tls/latest/tokio_native_tls/)) - ); -} - -type CloneStream = async_dup::Mutex>; -type Stream = tokio_native_tls::TlsStream; - -impl crate::connector::Connector for ConnectorNativeTls { - type Output = CloneStream; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - - let fut = async move { - use tokio_util::compat::Tokio02AsyncReadCompatExt as _; - - let connector: tokio_native_tls::TlsConnector = ::native_tls::TlsConnector::new() - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))? - .into(); - - let stream = tokio::net::TcpStream::connect(&*this.addrs).await?; - let stream = connector - .connect(&this.tls_domain, stream) - .await - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; - - Ok(async_dup::Mutex::new(stream.compat())) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorNativeTls::twitch().unwrap()); - } -} diff --git a/src/connector/tokio/non_tls.rs b/src/connector/tokio/non_tls.rs deleted file mode 100644 index d59e885..0000000 --- a/src/connector/tokio/non_tls.rs +++ /dev/null @@ -1,42 +0,0 @@ -use super::*; - -/// A `tokio` connector. This does not use TLS -#[derive(Debug, Clone, PartialEq)] -pub struct Connector { - addrs: Vec, -} - -impl Connector { - connector_ctor!(non_tls: - /// [`tokio`](https://docs.rs/tokio/0.2/tokio/) - ); -} - -impl crate::connector::Connector for Connector { - type Output = async_dup::Mutex>; - - fn connect(&mut self) -> BoxedFuture> { - let addrs = self.addrs.clone(); - let fut = async move { - use tokio_util::compat::Tokio02AsyncReadCompatExt as _; - let stream = tokio::net::TcpStream::connect(&*addrs).await?; - Ok(async_dup::Mutex::new(stream.compat())) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(Connector::twitch().unwrap()); - } -} diff --git a/src/connector/tokio/openssl.rs b/src/connector/tokio/openssl.rs deleted file mode 100644 index ce090ab..0000000 --- a/src/connector/tokio/openssl.rs +++ /dev/null @@ -1,62 +0,0 @@ -use super::*; - -use std::io::{Error, ErrorKind}; - -/// A `tokio` connector that uses `tokio-openssl` (an `openssl` wrapper). This uses TLS. -/// -/// To use this type, ensure you set up the 'TLS Domain' in the configuration. -/// -/// The crate provides the 'TLS domain' for Twitch in the root of this crate. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorOpenSsl { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorOpenSsl { - connector_ctor!(tls: - /// [`tokio`](https://docs.rs/tokio/0.2/tokio/) (using [`tokio-openssl`](https://docs.rs/tokio_openssl/latest/tokio_openssl/)) - ); -} - -type CloneStream = async_dup::Mutex>; -type Stream = tokio_openssl::SslStream; - -impl crate::connector::Connector for ConnectorOpenSsl { - type Output = CloneStream; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - - let fut = async move { - use tokio_util::compat::Tokio02AsyncReadCompatExt as _; - - let config = ::openssl::ssl::SslConnector::builder(::openssl::ssl::SslMethod::tls()) - .and_then(|c| c.build().configure()) - .map_err(|err| Error::new(ErrorKind::Other, err))?; - - let stream = tokio::net::TcpStream::connect(&*this.addrs).await?; - let stream = tokio_openssl::connect(config, &this.tls_domain, stream) - .await - .map_err(|err| Error::new(ErrorKind::Other, err))?; - - Ok(async_dup::Mutex::new(stream.compat())) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorOpenSsl::twitch().unwrap()); - } -} diff --git a/src/connector/tokio/rustls.rs b/src/connector/tokio/rustls.rs deleted file mode 100644 index 85fb7ff..0000000 --- a/src/connector/tokio/rustls.rs +++ /dev/null @@ -1,61 +0,0 @@ -use super::*; - -/// A `tokio` connector that uses `tokio-rustls` (a `rustls` wrapper). This uses TLS. -/// -/// To use this type, ensure you set up the 'TLS Domain' in the configuration. -/// -/// The crate provides the 'TLS domain' for Twitch in the root of this crate. -#[derive(Debug, Clone, PartialEq)] -pub struct ConnectorRustTls { - addrs: Vec, - tls_domain: String, -} - -impl ConnectorRustTls { - connector_ctor!(tls: - /// [`tokio`](https://docs.rs/tokio/0.2/tokio/) (using [`tokio-rustls`](https://docs.rs/tokio-rustls/latest/tokio_rustls/)) - ); -} - -impl crate::connector::Connector for ConnectorRustTls { - type Output = async_dup::Mutex< - tokio_util::compat::Compat>, - >; - - fn connect(&mut self) -> BoxedFuture> { - let this = self.clone(); - let fut = async move { - use tokio_util::compat::Tokio02AsyncReadCompatExt as _; - let domain = tokio_rustls::webpki::DNSNameRef::try_from_ascii_str(&this.tls_domain) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; - - let connector: tokio_rustls::TlsConnector = std::sync::Arc::new({ - let mut c = tokio_rustls::rustls::ClientConfig::new(); - c.root_store - .add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS); - c - }) - .into(); - - let stream = tokio::net::TcpStream::connect(&*this.addrs).await?; - let stream = connector.connect(domain, stream).await?; - Ok(async_dup::Mutex::new(stream.compat())) - }; - Box::pin(fut) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assert_connector_trait_is_fulfilled() { - use crate::connector::testing::*; - use crate::connector::Connector as C; - - assert_connector::(); - assert_type_is_read_write::<::Output>(); - assert_obj_is_sane(ConnectorRustTls::twitch().unwrap()); - } -} diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs deleted file mode 100644 index dc33407..0000000 --- a/src/decoder/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Decoding utilities. -//! -//! A decoder lets you decode messages from an [std::io::Read] (or [futures_lite::AsyncRead] for async) in either an iterative fashion, or one-by-one. -//! -//! When not using the Iterator (or Stream), you'll get a borrowed message from the reader that is valid until the next read. -//! -//! With the Iterator (or Stream) interface, it'll return an owned messages. -//! -//! This crate provides both 'Sync' (Iterator based) and 'Async' (Stream based) decoding. -//! * sync: [Decoder] -//! * async: [AsyncDecoder] -//! -//! # Borrowed messages -//! ``` -//! let input = "@key1=val;key2=true :user!user@user PRIVMSG #some_channel :\x01ACTION hello world\x01\r\n"; -//! let mut reader = std::io::Cursor::new(input.as_bytes()); -//! -//! // you can either &mut borrow the reader, or let the Decoder take ownership. -//! // ff it takes ownership you can retrieve the inner reader later. -//! let mut decoder = twitchchat::Decoder::new(&mut reader); -//! -//! // returns whether the message was valid -//! // this'll block until it can read a 'full' message (e.g. one delimited by `\r\n`). -//! let msg = decoder.read_message().unwrap(); -//! -//! // msg is borrowed until the next `read_message()` -//! // you can turn a borrowed message into an owned message by using the twitchchat::IntoOwned trait. -//! use twitchchat::IntoOwned as _; -//! let owned: twitchchat::IrcMessage<'static> = msg.into_owned(); -//! ``` -//! -//! # Owned messages -//! ``` -//! let input = "@key1=val;key2=true :user!user@user PRIVMSG #some_channel :\x01ACTION hello world\x01\r\n"; -//! let mut reader = std::io::Cursor::new(input.as_bytes()); -//! -//! // you can either &mut borrow the reader, or let the Decoder take ownership. -//! // ff it takes ownership you can retrieve the inner reader later. -//! for msg in twitchchat::Decoder::new(&mut reader) { -//! // this yields whether the message was valid or not -//! // this'll block until it can read a 'full' message (e.g. one delimited by `\r\n`). -//! -//! // notice its already owned here (denoted by the 'static lifetime) -//! let msg: twitchchat::IrcMessage<'static> = msg.unwrap(); -//! } -//! ``` - -cfg_async! { - mod r#async; - pub use r#async::*; -} - -mod sync; -pub use sync::*; diff --git a/src/decoder/sync.rs b/src/decoder/sync.rs deleted file mode 100644 index 4619aaf..0000000 --- a/src/decoder/sync.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::{IntoOwned as _, IrcMessage, MessageError}; -use std::io::{BufRead, BufReader, Read}; - -/// An error produced by a Decoder. -#[derive(Debug)] -#[non_exhaustive] -pub enum DecodeError { - /// An I/O error occurred - Io(std::io::Error), - /// Invalid UTf-8 was read. - InvalidUtf8(std::str::Utf8Error), - /// Could not parse the IRC message - ParseError(MessageError), - /// EOF was reached - Eof, -} - -impl std::fmt::Display for DecodeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(err) => write!(f, "io error: {}", err), - Self::InvalidUtf8(err) => write!(f, "invalid utf8: {}", err), - Self::ParseError(err) => write!(f, "parse error: {}", err), - Self::Eof => f.write_str("end of file reached"), - } - } -} - -impl std::error::Error for DecodeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - Self::InvalidUtf8(err) => Some(err), - Self::ParseError(err) => Some(err), - _ => None, - } - } -} - -/// A decoder over [std::io::Read] that produces [IrcMessage]s -/// -/// This will return an [DecodeError::Eof] when reading manually. -/// -/// When reading it as a iterator, `Eof` will signal the end of the iterator (e.g. `None`) -pub struct Decoder { - reader: BufReader, - buf: Vec, -} - -impl std::fmt::Debug for Decoder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Decoder").finish() - } -} - -impl Decoder -where - R: Read, -{ - /// Create a new Decoder from this [std::io::Read] instance - pub fn new(reader: R) -> Self { - Self { - reader: BufReader::new(reader), - buf: Vec::with_capacity(1024), - } - } - - /// Read the next message. - /// - /// This returns a borrowed [IrcMessage] which is valid until the next Decoder call is made. - /// - /// If you just want an owned one, use the [Decoder] as an iterator. e.g. dec.next(). - pub fn read_message(&mut self) -> Result, DecodeError> { - self.buf.clear(); - let n = self - .reader - .read_until(b'\n', &mut self.buf) - .map_err(DecodeError::Io)?; - if n == 0 { - return Err(DecodeError::Eof); - } - - let str = std::str::from_utf8(&self.buf[..n]).map_err(DecodeError::InvalidUtf8)?; - - // this should only ever parse 1 message - crate::irc::parse_one(str) - .map_err(DecodeError::ParseError) - .map(|(_, msg)| msg) - } - - /// Returns an iterator over messages. - /// - /// This will produce Results of Messages until an EOF is received - pub fn iter(&mut self) -> &mut Self { - self - } - - /// Consume the decoder returning the inner Reader - pub fn into_inner(self) -> R { - self.reader.into_inner() - } -} - -/// This will produce `Result, DecodeError>` until an `Eof` is received -impl Iterator for Decoder { - type Item = Result, DecodeError>; - - fn next(&mut self) -> Option { - match self.read_message() { - Err(DecodeError::Eof) => None, - Ok(msg) => Some(Ok(msg.into_owned())), - Err(err) => Some(Err(err)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn read_sync() { - let data = b"hello\r\nworld\r\ntesting this\r\nand another thing\r\n".to_vec(); - let mut reader = std::io::Cursor::new(data); - - // reading from the iterator won't produce the EOF - let v = Decoder::new(&mut reader) - .iter() - .collect::, _>>() - .unwrap(); - // no EOF - assert_eq!(v.len(), 4); - - reader.set_position(0); - // manually reading should produce an EOF - let mut dec = Decoder::new(reader); - for _ in 0..4 { - dec.read_message().unwrap(); - } - assert!(matches!(dec.read_message().unwrap_err(), DecodeError::Eof)) - } -} diff --git a/src/ext.rs b/src/ext.rs deleted file mode 100644 index 3fd03b2..0000000 --- a/src/ext.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::{messages::Privmsg, Encodable}; -use std::io::Write; - -/// Extensions to the `Privmsg` message type -pub trait PrivmsgExt { - /// Reply to this message with `data` - fn reply(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()>; - - /// Send a message back to the channel this Privmsg came from - fn say(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()>; -} - -impl<'a, W: Write + ?Sized> PrivmsgExt for W { - fn reply(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { - let cmd = crate::commands::reply( - msg.channel(), - msg.tags().get("id").ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "you must have `TAGS` enabled", - ) - })?, - data, - ); - cmd.encode(self)?; - self.flush() - } - - fn say(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { - let cmd = crate::commands::privmsg(msg.channel(), data); - cmd.encode(self)?; - self.flush() - } -} diff --git a/src/irc/tag_indices.rs b/src/irc/tag_indices.rs deleted file mode 100644 index ac55c17..0000000 --- a/src/irc/tag_indices.rs +++ /dev/null @@ -1,100 +0,0 @@ -use crate::maybe_owned::MaybeOwned; -use crate::{IntoOwned, MaybeOwnedIndex}; - -/// Pre-computed tag indices -/// -/// This type is only exposed for those wanting to extend/make custom types. -#[derive(Default, Clone, PartialEq)] -pub struct TagIndices { - pub(super) map: Box<[(MaybeOwnedIndex, MaybeOwnedIndex)]>, -} - -impl std::fmt::Debug for TagIndices { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_map() - .entries(self.map.iter().map(|(k, v)| (k, v))) - .finish() - } -} - -impl TagIndices { - /// Build indices from this tags fragment - /// - /// The fragment should be in the form of `'@k1=v2;k2=v2'` - pub fn build_indices(input: &str) -> Self { - if !input.starts_with('@') { - return Self::default(); - } - - enum Mode { - Head, - Tail, - } - - let mut map = Vec::with_capacity(input.chars().filter(|&c| c == ';').count() + 1); - let (mut key, mut value) = (MaybeOwnedIndex::new(1), MaybeOwnedIndex::new(1)); - - let mut mode = Mode::Head; - - for (i, ch) in input.char_indices().skip(1) { - let i = i + 1; - match ch { - ';' => { - mode = Mode::Head; - map.push((key.replace(i), value.replace(i))); - } - '=' => { - mode = Mode::Tail; - value.replace(i); - } - _ => { - match mode { - Mode::Head => &mut key, - Mode::Tail => &mut value, - } - .bump_tail(); - } - } - } - - // we should allow empty values - // but not empty keys - if !key.is_empty() { - map.push((key, value)); - } - - Self { - map: map.into_boxed_slice(), - } - } - - /// Get the number of parsed tags - pub fn len(&self) -> usize { - self.map.len() - } - - /// Checks whether any tags were parsed - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - // NOTE: this isn't public because they don't verify 'data' is the same as the built-indices data - pub(crate) fn get_unescaped<'a>(&'a self, key: &str, data: &'a str) -> Option> { - self.get(key, data).map(crate::test::unescape_str) - } - - // NOTE: this isn't public because they don't verify 'data' is the same as the built-indices data - pub(crate) fn get<'a>(&'a self, key: &str, data: &'a str) -> Option<&'a str> { - let key = crate::test::escape_str(key); - self.map - .iter() - .find_map(|(k, v)| if key == data[k] { Some(&data[v]) } else { None }) - } -} - -impl IntoOwned<'static> for TagIndices { - type Output = Self; - fn into_owned(self) -> Self::Output { - self - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index be46bea..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,155 +0,0 @@ -#![allow( - clippy::missing_const_for_fn, - clippy::redundant_pub_crate, - clippy::use_self -)] -#![deny( - deprecated_in_future, - exported_private_dependencies, - future_incompatible, - missing_copy_implementations, - missing_crate_level_docs, - missing_debug_implementations, - missing_docs, - private_in_public, - rust_2018_compatibility, - // rust_2018_idioms, // this complains about elided lifetimes. - trivial_casts, - trivial_numeric_casts, - unsafe_code, - unstable_features, - unused_import_braces, - unused_qualifications -)] -#![cfg_attr(docsrs, feature(doc_cfg))] -#![cfg_attr(docsrs, feature(doc_alias))] -#![cfg_attr(docsrs, feature(broken_intra_doc_links))] -/*! - -This crate provides a way to interface with [Twitch](https://dev.twitch.tv/docs/irc)'s chat (via IRC). - -Along with the messages as Rust types, it provides methods for sending messages. ---- - -By default, this crate depends on zero external crates -- but it makes it rather limited in scope. - -This allows parsing, and decoding/encoding to standard trait types (`std::io::{Read, Write}`). - -To use the [AsyncRunner] (an async-event loop) and related helpers, you must able the `async` feature. - -***NOTE*** This is a breaking change from `0.12` which had the async stuff enabled by default. - -```toml -twitchchat = { version = "0.14", features = ["async"] } -``` ---- - -For twitch types: -* [twitch] -* [messages] -* [commands] ---- -For the 'irc' types underneath it all: -* [irc] ---- -For an event loop: -* [runner] ---- -For just decoding messages: -* [decoder] ---- -For just encoding messages: -* [encoder] - -*/ - -macro_rules! cfg_async { - ($($item:item)*) => { - $( - #[cfg(feature = "async")] - #[cfg_attr(docsrs, doc(cfg(feature = "async")))] - $item - )* - }; -} - -/// The Twitch IRC address for non-TLS connections -pub const TWITCH_IRC_ADDRESS: &str = "irc.chat.twitch.tv:6667"; - -/// The Twitch IRC address for TLS connections -pub const TWITCH_IRC_ADDRESS_TLS: &str = "irc.chat.twitch.tv:6697"; - -/// The Twitch WebSocket address for non-TLS connections -pub const TWITCH_WS_ADDRESS: &str = "ws://irc-ws.chat.twitch.tv:80"; - -/// The Twitch WebSocket address for TLS connections -pub const TWITCH_WS_ADDRESS_TLS: &str = "wss://irc-ws.chat.twitch.tv:443"; - -/// A TLS domain for Twitch -pub const TWITCH_TLS_DOMAIN: &str = "irc.chat.twitch.tv"; - -/// An anonymous login. -pub const ANONYMOUS_LOGIN: (&str, &str) = (JUSTINFAN1234, JUSTINFAN1234); -pub(crate) const JUSTINFAN1234: &str = "justinfan1234"; - -#[macro_use] -#[allow(unused_macros)] -mod macros; - -pub mod decoder; -pub use decoder::{DecodeError, Decoder}; -cfg_async! { pub use decoder::AsyncDecoder; } - -pub mod encoder; -pub use encoder::Encoder; -cfg_async! { pub use encoder::AsyncEncoder; } - -/// A boxed `Future` that is `Send + Sync` -pub type BoxedFuture = std::pin::Pin + Send + Sync>>; - -cfg_async! { - /// An AsyncWriter over an MpscWriter - pub type Writer = crate::writer::AsyncWriter; -} - -cfg_async! { pub mod connector; } -cfg_async! { pub mod writer; } -cfg_async! { pub mod channel; } - -pub mod runner; -pub use runner::{Error as RunnerError, Status}; -cfg_async! { pub use runner::AsyncRunner; } - -pub mod rate_limit; - -pub mod commands; -pub mod messages; - -pub mod irc; -pub use irc::{IrcMessage, MessageError}; - -/// Helpful testing utilities -pub mod test; - -#[doc(inline)] -pub use irc::{FromIrcMessage, IntoIrcMessage}; - -pub mod twitch; -pub use twitch::UserConfig; - -mod encodable; -pub use encodable::Encodable; - -pub mod maybe_owned; -pub use maybe_owned::IntoOwned; -use maybe_owned::{MaybeOwned, MaybeOwnedIndex}; - -mod validator; -pub use validator::Validator; - -mod ext; -#[cfg(feature = "serde")] -mod serde; -mod util; - -pub use ext::PrivmsgExt; diff --git a/src/rate_limit.rs b/src/rate_limit.rs deleted file mode 100644 index a20c3d1..0000000 --- a/src/rate_limit.rs +++ /dev/null @@ -1,205 +0,0 @@ -// TODO actually write tests for this -#![allow(dead_code)] -/*! -A simple leaky-bucket style token-based rate limiter -*/ - -use std::time::{Duration, Instant}; - -/// A preset number of tokens as described by Twitch -#[non_exhaustive] -#[derive(Copy, Clone, Debug)] -pub enum RateClass { - /// `20` per `30` seconds - Regular, - /// `100` per `30` seconds - Moderator, - /// `50` per `30` seconds - Known, - /// `7500` per `30` seconds - Verified, -} - -impl Default for RateClass { - fn default() -> Self { - Self::Regular - } -} - -impl RateClass { - /// Number of tickets available for this class - pub fn tickets(self) -> u64 { - match self { - Self::Regular => 20, - Self::Moderator => 100, - Self::Known => 50, - Self::Verified => 7500, - } - } - - /// Period specified by Twitch - pub const fn period() -> Duration { - Duration::from_secs(30) - } -} - -/// A leaky-bucket style token-based rate limiter -#[derive(Debug, Clone)] -pub struct RateLimit { - cap: u64, - bucket: Bucket, -} - -impl Default for RateLimit { - fn default() -> Self { - Self::from_class(<_>::default()) - } -} - -impl RateLimit { - /// Overwrite the current capacity with this value - pub fn set_cap(&mut self, cap: u64) { - self.cap = cap - } - - /// Overwrite the current period with this value - pub fn set_period(&mut self, period: Duration) { - self.bucket.period = period; - } - - /// Get the current capacity with this value - pub fn get_cap(&self) -> u64 { - self.cap - } - - /// Get the current period with this value - pub fn get_period(&self) -> Duration { - self.bucket.period - } - - /// Create a rate limit from a RateClass - pub fn from_class(rate_class: RateClass) -> Self { - Self::full(rate_class.tickets(), RateClass::period()) - } - - /// Create a new rate limiter of `capacity` with an `initial` number of - /// token and the `period` between refills - pub fn new(cap: u64, initial: u64, period: Duration) -> Self { - Self { - cap, - bucket: Bucket::new(cap, initial, period), - } - } - - /// Create a new rate limiter that is pre-filled - /// - /// `cap` is the number of total tokens available - /// - /// `period` is how long it'll take to refill all of the tokens - pub fn full(cap: u64, period: Duration) -> Self { - Self { - cap, - bucket: Bucket::new(cap, cap, period), - } - } - - /// Create am empty rate limiter - /// - /// `cap` is the number of total tokens available - /// - /// `period` is how long it'll take to refill all of the tokens - /// - /// This will block, at first, atleast one `period` until its filled - pub fn empty(cap: u64, period: Duration) -> Self { - Self { - cap, - bucket: Bucket::new(cap, 0, period), - } - } - - /// Get the current available tokens - pub fn get_available_tokens(&self) -> u64 { - self.bucket.tokens - } - - /// Tries to get the current RateClass. - pub fn get_current_rate_class(&self) -> Option { - const DUR: Duration = Duration::from_secs(30); - - let class = match (self.get_cap(), self.get_period()) { - (20, DUR) => RateClass::Regular, - (50, DUR) => RateClass::Known, - (100, DUR) => RateClass::Moderator, - (7500, DUR) => RateClass::Verified, - _ => return None, - }; - Some(class) - } - - /// Consume a specific ammount of tokens - /// - /// # Returns - /// * Successful consumption (e.g. not blocking) will return how many tokens - /// are left - /// * Failure to consume (e.g. out of tokens) will return a Duration of when - /// the bucket will be refilled - pub fn consume(&mut self, tokens: u64) -> Result { - let Self { bucket, .. } = self; - - let now = Instant::now(); - if let Some(n) = bucket.refill(now) { - bucket.tokens = std::cmp::min(bucket.tokens + n, self.cap); - } - - if tokens <= bucket.tokens { - bucket.tokens -= tokens; - bucket.backoff = 0; - return Ok(bucket.tokens); - } - - let prev = bucket.tokens; - Err(bucket.estimate(tokens - prev, now)) - } -} - -#[derive(Debug, Clone)] -struct Bucket { - tokens: u64, - backoff: u32, - next: Instant, - last: Instant, - quantum: u64, - period: Duration, -} - -impl Bucket { - fn new(tokens: u64, initial: u64, period: Duration) -> Self { - let now = Instant::now(); - Self { - tokens: initial, - backoff: 0, - next: now + period, - last: now, - quantum: tokens, - period, - } - } - - fn refill(&mut self, now: Instant) -> Option { - if now < self.next { - return None; - } - - let last = now.duration_since(self.last); - let periods = last.as_nanos().checked_div(self.period.as_nanos())? as u64; - self.last += self.period * (periods as u32); - self.next = self.last + self.period; - (periods * self.quantum).into() - } - - fn estimate(&mut self, tokens: u64, now: Instant) -> Duration { - let until = self.next.duration_since(now); - let periods = (tokens.checked_add(self.quantum).unwrap() - 1) / self.quantum; - until + self.period * (periods as u32 - 1) - } -} diff --git a/src/runner/async_runner.rs b/src/runner/async_runner.rs deleted file mode 100644 index 311e4a0..0000000 --- a/src/runner/async_runner.rs +++ /dev/null @@ -1,681 +0,0 @@ -cfg_async! { -use crate::{ - channel::Receiver, - commands, - connector::Connector, - encoder::AsyncEncoder, - messages::{Capability, Commands, MessageId}, - rate_limit::{RateClass, RateLimit}, - twitch::UserConfig, - util::{Notify, NotifyHandle}, - writer::{AsyncWriter, MpscWriter}, - AsyncDecoder, DecodeError, Encodable, FromIrcMessage, IrcMessage, -}; - -use super::{ - channel::Channels, - timeout::{TimeoutState, RATE_LIMIT_WINDOW, TIMEOUT, WINDOW}, - Capabilities, Channel, Error, Identity, Status, StepResult, -}; - -use futures_lite::{AsyncRead, AsyncWrite, AsyncWriteExt, Stream}; -use std::{ - collections::{HashSet, VecDeque}, - pin::Pin, - task::{Context, Poll}, -}; - -/// An asynchronous runner -pub struct AsyncRunner { - /// You identity that Twitch gives when you connected - pub identity: Identity, - - channels: Channels, - - activity_rx: Receiver<()>, - writer_rx: Receiver>, - - notify: Notify, - // why don't we use this? - notify_handle: NotifyHandle, - - timeout_state: TimeoutState, - - decoder: AsyncDecoder>, - encoder: AsyncEncoder>, - - writer: AsyncWriter, - global_rate_limit: RateLimit, - - missed_messages: VecDeque>, -} - -impl std::fmt::Debug for AsyncRunner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AsyncRunner { .. }").finish() - } -} - -impl AsyncRunner { - /// Connect with the provided connector and the provided UserConfig - /// - /// This returns the Runner with your identity set. - pub async fn connect(connector: C, user_config: &UserConfig) -> Result - where - C: Connector, - for<'a> &'a C::Output: AsyncRead + AsyncWrite + Send + Sync + Unpin, - { - log::debug!("connecting"); - let mut stream = { connector }.connect().await?; - log::debug!("connection established"); - - log::debug!("registering"); - let mut buf = vec![]; - commands::register(user_config).encode(&mut buf)?; - stream.write_all(&buf).await?; - log::debug!("registered"); - - let read = async_dup::Arc::new(stream); - let write = read.clone(); - - let read: Box = Box::new(read); - let write: Box = Box::new(write); - - let mut decoder = AsyncDecoder::new(read); - let mut encoder = AsyncEncoder::new(write); - - log::debug!("waiting for the connection to be ready"); - let mut missed_messages = VecDeque::new(); - let identity = Self::wait_for_ready( - &mut decoder, - &mut encoder, - user_config, - &mut missed_messages, - ) - .await?; - log::debug!("connection is ready: {:?}", identity); - - let (writer_tx, writer_rx) = crate::channel::unbounded(); - let (notify, notify_handle) = Notify::new(); - let (activity_tx, activity_rx) = crate::channel::bounded(32); - - let writer = AsyncWriter::new(MpscWriter::new(writer_tx), activity_tx); - - let timeout_state = TimeoutState::Start; - let channels = Channels::default(); - - let global_rate_limit = RateLimit::from_class(RateClass::Regular); - - Ok(Self { - identity, - channels, - - activity_rx, - writer_rx, - - notify, - notify_handle, - - timeout_state, - - decoder, - encoder, - - writer, - global_rate_limit, - - missed_messages, - }) - } - - /// Check whether you're on this channel - pub fn is_on_channel(&self, channel: &str) -> bool { - self.channels.is_on(channel) - } - - /// Get a specific channel. - /// - /// This is useful for changing the rate limit/state manually. - pub fn get_channel_mut(&mut self, channel: &str) -> Option<&mut Channel> { - self.channels.get_mut(channel) - } - - /// Get a clonable writer you can use - pub fn writer(&self) -> AsyncWriter { - self.writer.clone() - } - - /// Get a handle that you can trigger a normal 'quit'. - /// - /// You can also do `AsyncWriter::quit`. - pub fn quit_handle(&self) -> NotifyHandle { - self.notify_handle.clone() - } - - /// Join `channel` and wait for it to complete - pub async fn join(&mut self, channel: &str) -> Result<(), Error> { - if self.is_on_channel(channel) { - return Err(Error::AlreadyOnChannel { - channel: channel.to_string(), - }); - } - - log::debug!("joining '{}'", channel); - self.encoder.encode(commands::join(channel)).await?; - - let channel = crate::commands::Channel::new(channel).to_string(); - log::debug!("waiting for a response"); - - let mut queue = VecDeque::new(); - - let status = self - .wait_for(&mut queue, |msg, this| match msg { - // check to see if it was us that joined the channel - Commands::Join(msg) => { - Ok(msg.channel() == channel && msg.name() == this.identity.username()) - } - - // check to see if we were banned - Commands::Notice(msg) if matches!(msg.msg_id(), Some(MessageId::MsgBanned)) => { - Err(Error::BannedFromChannel { - channel: msg.channel().to_string(), - }) - } - - _ => Ok(false), - }) - .await?; - - if let Some(status) = status { - match status { - Status::Quit | Status::Eof => return Err(Error::UnexpectedEof), - _ => unimplemented!(), - } - } - - self.missed_messages.extend(queue); - - log::debug!("joined '{}'", channel); - - Ok(()) - } - - /// Part `channel` and wait for it to complete - pub async fn part(&mut self, channel: &str) -> Result<(), Error> { - if !self.is_on_channel(channel) { - return Err(Error::NotOnChannel { - channel: channel.to_string(), - }); - } - - log::debug!("leaving '{}'", channel); - self.encoder.encode(commands::part(channel)).await?; - - let channel = crate::commands::Channel::new(channel).to_string(); - log::debug!("waiting for a response"); - - let mut queue = VecDeque::new(); - - let status = self - .wait_for(&mut queue, |msg, this| match msg { - // check to see if it was us that left the channel - Commands::Part(msg) => { - Ok(msg.channel() == channel && msg.name() == this.identity.username()) - } - _ => Ok(false), - }) - .await?; - - if let Some(status) = status { - match status { - Status::Quit | Status::Eof => return Err(Error::UnexpectedEof), - _ => unimplemented!(), - } - } - log::debug!("left '{}'", channel); - - self.missed_messages.extend(queue); - - Ok(()) - } - - /// Get the next message. You'll usually want to call this in a loop - pub async fn next_message(&mut self) -> Result, Error> { - use crate::util::{Either::*, FutExt as _}; - - loop { - match self.step().await? { - StepResult::Nothing => continue, - StepResult::Status(Status::Quit) => { - if let Left(_notified) = self.notify.wait().now_or_never().await { - // close everything - self.writer_rx.close(); - self.activity_rx.close(); - - // and then drain any remaining items - while self.available_queued_messages() > 0 { - self.drain_queued_messages().await?; - futures_lite::future::yield_now().await; - } - - // and finally send the quit - self.encoder.encode(commands::raw("QUIT\r\n")).await?; - - // and signal that we've quit - break Ok(Status::Quit); - } - } - StepResult::Status(status) => break Ok(status), - } - } - } - - /// Single step the loop. This is useful for testing. - pub async fn step(&mut self) -> Result, Error> { - use crate::util::*; - use crate::IntoOwned as _; - - if let Some(msg) = self.missed_messages.pop_front() { - return Ok(StepResult::Status(Status::Message(msg))); - } - - let select = self - .decoder - .read_message() - .either(self.activity_rx.recv()) - .either(self.writer_rx.recv()) - .either(self.notify.wait()) - .either(super::timeout::next_delay()) - .await; - - match select { - Left(Left(Left(Left(msg)))) => { - let msg = match msg { - Err(DecodeError::Eof) => { - log::info!("got an EOF, exiting main loop"); - return Ok(StepResult::Status(Status::Eof)); - } - Err(err) => { - log::warn!("read an error: {}", err); - return Err(err.into()); - } - Ok(msg) => msg, - }; - - self.timeout_state = TimeoutState::activity(); - - let all = Commands::from_irc(msg) // - .expect("msg identity conversion should be upheld") - .into_owned(); - - self.check_messages(&all).await?; - - return Ok(StepResult::Status(Status::Message(all))); - } - - Left(Left(Left(Right(Some(_activity))))) => { - self.timeout_state = TimeoutState::activity(); - } - - Left(Left(Right(Some(write_data)))) => { - // TODO provide a 'bytes' flavored parser - let msg = std::str::from_utf8(&*write_data).map_err(Error::InvalidUtf8)?; - let res = crate::irc::parse_one(msg) // - .expect("encoder should produce valid IRC messages"); - let msg = res.1; - - if let crate::irc::IrcMessage::PRIVMSG = msg.get_command() { - if let Some(ch) = msg.nth_arg(0) { - if !self.channels.is_on(ch) { - self.channels.add(ch) - } - - let ch = self.channels.get_mut(ch).unwrap(); - if ch.rated_limited_at.map(|s| s.elapsed()) > Some(RATE_LIMIT_WINDOW) { - ch.reset_rate_limit(); - } - - ch.rate_limited.enqueue(write_data) - } - } - } - - Left(Right(_notified)) => return Ok(StepResult::Status(Status::Quit)), - - Right(_timeout) => { - log::info!("idle connection detected, sending a ping"); - let ts = timestamp().to_string(); - self.encoder.encode(commands::ping(&ts)).await?; - self.timeout_state = TimeoutState::waiting_for_pong(); - } - - _ => { - return Ok(StepResult::Status(Status::Eof)); - } - } - - match self.timeout_state { - TimeoutState::WaitingForPong(dt) => { - if dt.elapsed() > TIMEOUT { - log::warn!("PING timeout detected, exiting"); - return Err(Error::TimedOut); - } - } - TimeoutState::Activity(dt) => { - if dt.elapsed() > WINDOW { - log::warn!("idle connectiond detected, sending a PING"); - let ts = timestamp().to_string(); - self.encoder.encode(crate::commands::ping(&ts)).await?; - self.timeout_state = TimeoutState::waiting_for_pong(); - } - } - TimeoutState::Start => {} - } - - log::trace!("draining messages"); - self.drain_queued_messages().await?; - - Ok(StepResult::Nothing) - } - - async fn check_messages(&mut self, all: &Commands<'static>) -> Result<(), Error> { - use {Commands::*, TimeoutState::*}; - - log::trace!("< {}", all.raw().escape_debug()); - - match &all { - Ping(msg) => { - let token = msg.token(); - log::debug!( - "got a ping from the server. responding with token '{}'", - token - ); - self.encoder.encode(commands::pong(token)).await?; - self.timeout_state = TimeoutState::activity(); - } - - Pong(..) if matches!(self.timeout_state, WaitingForPong {..}) => { - self.timeout_state = TimeoutState::activity() - } - - Join(msg) if msg.name() == self.identity.username() => { - log::debug!("starting tracking channel for '{}'", msg.channel()); - self.channels.add(msg.channel()); - } - - Part(msg) if msg.name() == self.identity.username() => { - log::debug!("stopping tracking of channel '{}'", msg.channel()); - self.channels.remove(msg.channel()); - } - - RoomState(msg) => { - if let Some(dur) = msg.is_slow_mode() { - if let Some(ch) = self.channels.get_mut(msg.channel()) { - ch.enable_slow_mode(dur) - } - } - } - - Notice(msg) => { - let ch = self.channels.get_mut(msg.channel()); - match (msg.msg_id(), ch) { - // we should enable slow mode - (Some(MessageId::SlowOn), Some(ch)) => ch.enable_slow_mode(30), - // we should disable slow mode - (Some(MessageId::SlowOff), Some(ch)) => ch.disable_slow_mode(), - // we've been rate limited on the channel - (Some(MessageId::MsgRatelimit), Some(ch)) => ch.set_rate_limited(), - // we cannot join/send to the channel because we're banned - (Some(MessageId::MsgBanned), ..) => self.channels.remove(msg.channel()), - _ => {} - } - } - - Reconnect(_) => return Err(Error::ShouldReconnect), - - _ => {} - } - - Ok(()) - } -} - -impl AsyncRunner { - async fn wait_for( - &mut self, - missed: &mut VecDeque>, - func: F, - ) -> Result>, Error> - where - F: Fn(&Commands<'static>, &Self) -> Result + Send + Sync, - { - loop { - match self.step().await? { - StepResult::Status(Status::Message(msg)) => { - if func(&msg, self)? { - break Ok(None); - } - missed.push_back(msg); - } - StepResult::Status(d) => return Ok(Some(d)), - StepResult::Nothing => continue, - } - } - } - - fn available_queued_messages(&self) -> usize { - self.channels - .map - .values() - .map(|s| s.rate_limited.queue.len()) - .sum() - } - - async fn drain_queued_messages(&mut self) -> std::io::Result<()> { - let enc = &mut self.encoder; - let limit = &mut self.global_rate_limit.get_available_tokens(); - - let start = *limit; - - // for each channel, try to take up to 'limit' tokens - for channel in self.channels.map.values_mut() { - if channel.rated_limited_at.map(|s| s.elapsed()) > Some(RATE_LIMIT_WINDOW) { - channel.reset_rate_limit(); - } - - // drain until we're out of messages, or tokens - channel - .rate_limited - .drain_until_blocked(&channel.name, limit, enc) - .await?; - - let left = std::cmp::max(start, *limit); - let right = std::cmp::min(start, *limit); - - let diff = left - right; - - if *limit == 0 { - log::warn!(target: "twitchchat::rate_limit", "global rate limit hit while draining '{}'", &channel.name); - break; - } - - // and throttle the global one - match self.global_rate_limit.consume(diff) { - // use the new remaining amount of tokens - Ok(rem) => *limit = rem, - - // we're globally rate limited, so just return - Err(..) => { - log::warn!(target: "twitchchat::rate_limit", "global rate limit hit while draining '{}'", &channel.name); - break; - } - } - } - - Ok(()) - } - - async fn wait_for_ready( - decoder: &mut AsyncDecoder, - encoder: &mut AsyncEncoder, - user_config: &UserConfig, - missed_messages: &mut VecDeque>, - ) -> Result - where - R: AsyncRead + Send + Sync + Unpin, - W: AsyncWrite + Send + Sync + Unpin, - { - use crate::IntoOwned as _; - - let is_anonymous = user_config.is_anonymous(); - - let mut looking_for: HashSet<_> = user_config.capabilities.iter().collect(); - let mut caps = Capabilities::default(); - let mut our_name = None; - - use crate::twitch::Capability as TwitchCap; - // Twitch says we'll be getting a GlobalUserState if we just send the - // Tags capability - // - // This is false. Twitch will only send GlobalUserState if we've sent - // the Commands capability and atleast 1 other capability. - // - // That other capability doesn't have to be Tags, interestingly enough. - // So a combination of both 'Commands' and 'Membership' will produce an - // empty GlobalUserState - // - // We'll check for both Tags and Commands - // - let will_be_getting_global_user_state_hopefully = - user_config.capabilities.contains(&TwitchCap::Tags) && - user_config.capabilities.contains(&TwitchCap::Commands); - - let identity = loop { - let msg: IrcMessage<'_> = decoder.read_message().await?; - - // this should always be infallible. its not marked infallible - // because of the 'non-exhaustive' attribute - use Commands::*; - let commands = Commands::from_irc(msg)?; - - // this is the simpliest way. and this'll only clone like 9 messages - missed_messages.push_back(commands.clone().into_owned()); - - match commands { - Ready(msg) => { - our_name.replace(msg.username().to_string()); - - // if we aren't going to be receiving tags, then we - // won't be looking for any more messages - - // if we're anonymous, we won't get GLOBALUSERSTATE even - // if we do send Tags - if is_anonymous { - break Identity::Anonymous { caps }; - } - - // if we're not looking for any more caps and we won't be - // getting a GlobalUserState just give them the basic - // Identity - if looking_for.is_empty() && !will_be_getting_global_user_state_hopefully { - break Identity::Basic { - name: our_name.take().unwrap(), - caps, - }; - } - } - - Cap(msg) => match msg.capability() { - Capability::Acknowledged(name) => { - use crate::twitch::Capability as Cap; - - let cap = match Cap::maybe_from_str(name) { - Some(cap) => cap, - // Twitch sent us an unknown capability - None => { - caps.unknown.insert(name.to_string()); - continue; - } - }; - - *match cap { - Cap::Tags => &mut caps.tags, - Cap::Membership => &mut caps.membership, - Cap::Commands => &mut caps.commands, - } = true; - - looking_for.remove(&cap); - } - - Capability::NotAcknowledged(name) => { - return Err(Error::InvalidCap { - cap: name.to_string(), - }) - } - }, - - // NOTE: This will only be sent when there's both Commands and atleast one other CAP requested - GlobalUserState(msg) => { - // TODO: this is so shitty. - let id = match msg.user_id { - Some(id) => id.parse().unwrap(), - // XXX: we can get this message without any tags - None => { - break Identity::Basic { - name: our_name.take().unwrap(), - caps, - }; - } - }; - - break Identity::Full { - // these unwraps should be safe because we'll have all of the TAGs here - name: our_name.unwrap(), - user_id: id, - display_name: msg.display_name.map(|s| s.to_string()), - color: msg.color, - caps, - }; - - } - - // Reply to any PINGs while waiting. Although Twitch doesn't - // currently send a PING for spoof detection on initial - // handshake, one day they may. Most IRC servers do this - // already - Ping(msg) => encoder.encode(commands::pong(msg.token())).await?, - - _ => { - // we have our name, but we won't be getting GlobalUserState and we've got all of our Caps - if our_name.is_some() && !will_be_getting_global_user_state_hopefully && looking_for.is_empty() { - break Identity::Basic { - name: our_name.take().unwrap(), - caps, - }; - } - } - }; - }; - - Ok(identity) - } -} - -impl Stream for AsyncRunner { - type Item = Commands<'static>; - - fn poll_next(self: Pin<&mut Self>, ctx: &mut Context<'_>) -> Poll> { - use std::future::Future; - let fut = self.get_mut().next_message(); - futures_lite::pin!(fut); - - match futures_lite::ready!(fut.poll(ctx)) { - Ok(status) => match status { - Status::Message(msg) => Poll::Ready(Some(msg)), - Status::Quit | Status::Eof => Poll::Ready(None), - }, - Err(..) => Poll::Ready(None), - } - } -} -} diff --git a/src/runner/capabilities.rs b/src/runner/capabilities.rs deleted file mode 100644 index 5a29e9b..0000000 --- a/src/runner/capabilities.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::collections::HashSet; - -/// Capabiltiies Twitch acknowledged. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Capabilities { - /// You have the [membership](https://dev.twitch.tv/docs/irc/membership) capability - pub membership: bool, - /// You have the [commands](https://dev.twitch.tv/docs/irc/commands) capability - pub commands: bool, - /// You have the [tags](https://dev.twitch.tv/docs/irc/tags) capability - pub tags: bool, - /// A set of unknown capabilities Twitch sent to use - pub unknown: HashSet, -} diff --git a/src/runner/channel.rs b/src/runner/channel.rs deleted file mode 100644 index 202be44..0000000 --- a/src/runner/channel.rs +++ /dev/null @@ -1,113 +0,0 @@ -cfg_async! { -use super::rate_limit::{PreviousRate, RateLimitedEncoder}; -use crate::rate_limit::{RateClass, RateLimit}; -use std::{ - collections::{HashMap, VecDeque}, - time::Duration, -}; - -/// A channel that you are on. -/// -/// This is exposed for 'advanced' users who want to modify the rate limiter. -/// -/// # Warning -/// You shouldn't need to touch this unless you have a good reason to do so. -/// -/// Improperly using this could result in Twitch disconnecting you, at best and -/// a ban at worst. -pub struct Channel { - pub(crate) name: String, - pub(crate) rate_limited: RateLimitedEncoder, - pub(crate) previous: Option, - pub(crate) rated_limited_at: Option, -} - -impl std::fmt::Debug for Channel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Channel").field("name", &self.name).finish() - } -} - -impl Channel { - pub(crate) fn new(name: String) -> Self { - let rate_limit = RateLimit::from_class(RateClass::Regular); - let rate_limited = RateLimitedEncoder { - rate_limit, - queue: VecDeque::new(), - }; - Self { - name, - rate_limited, - previous: None, - rated_limited_at: None, - } - } - - /// Set the [RateClass] for this channel - pub fn set_rate_class(&mut self, rate_class: RateClass) { - self.rate_limited.rate_limit = RateLimit::from_class(rate_class); - self.rated_limited_at.take(); - } - - /// Mark this channel as being under slow mode for `duration` - pub fn enable_slow_mode(&mut self, duration: u64) { - let rate = &mut self.rate_limited.rate_limit; - self.previous.replace(PreviousRate { - cap: rate.get_cap(), - period: rate.get_period(), - }); - - rate.set_period(Duration::from_secs(duration)) - } - - /// Mark this channel as not being in slow mode - pub fn disable_slow_mode(&mut self) { - let PreviousRate { cap, period } = self.previous.take().unwrap_or_default(); - let rate = &mut self.rate_limited.rate_limit; - rate.set_cap(cap); - rate.set_period(period); - } - - /// Mark that you've been rate limited on this channel - pub fn set_rate_limited(&mut self) { - self.rate_limited.rate_limit.set_cap(1); - self.rated_limited_at.replace(std::time::Instant::now()); - } - - /// Reset to the default rate class - pub fn reset_rate_limit(&mut self) { - let PreviousRate { cap, period } = self.previous.take().unwrap_or_default(); - self.rate_limited.rate_limit = RateLimit::full(cap, period); - self.rated_limited_at.take(); - } -} - -#[derive(Debug, Default)] -pub struct Channels { - pub map: HashMap, -} - -impl Channels { - pub fn is_on(&self, name: &str) -> bool { - self.map.contains_key(name) - } - - pub fn get_mut(&mut self, name: &str) -> Option<&mut Channel> { - self.map.get_mut(name) - } - - pub fn add(&mut self, name: &str) { - // we already have this channel (there was a sync issue) - if self.map.contains_key(name) { - return; - } - - let channel = Channel::new(name.to_string()); - self.map.insert(name.to_string(), channel); - } - - pub fn remove(&mut self, name: &str) { - self.map.remove(name); - } -} -} diff --git a/src/runner/error.rs b/src/runner/error.rs deleted file mode 100644 index f103a17..0000000 --- a/src/runner/error.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::{DecodeError, MessageError}; - -/// An error returned by a Runner -#[derive(Debug)] -pub enum Error { - /// An I/O error occured - Io(std::io::Error), - /// Invalid utf-8 was parsed (either you sent invalid utf-8, or Twitch did and we read it). - InvalidUtf8(std::str::Utf8Error), - /// We could not parse a message -- this should never happen - ParsingFailure(MessageError), - /// You requested a capability and Twitch rejected it - InvalidCap { - /// The capability name - cap: String, - }, - /// You're already on that channel - AlreadyOnChannel { - /// The channel name - channel: String, - }, - /// You weren't on that channel - NotOnChannel { - /// The channel name - channel: String, - }, - /// You could not join this channel, you were banned prior. - BannedFromChannel { - /// The channel name - channel: String, - }, - /// Your connection timed out. - TimedOut, - /// Twitch restarted the server, you should reconnect. - ShouldReconnect, - /// An unexpected EOF was found -- this means the connectionc losed abnormally. - UnexpectedEof, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(err) => write!(f, "io error: {}", err), - Self::InvalidUtf8(err) => write!(f, "invalid utf-8 while parsing: {}", err), - Self::ParsingFailure(err) => write!(f, "could not parse message: {}", err), - Self::InvalidCap { cap } => { - write!(f, "request capability '{}' was not acknowledged", cap) - } - Self::AlreadyOnChannel { channel } => write!(f, "already on channel '{}'", channel), - Self::NotOnChannel { channel } => write!(f, "not on channel '{}'", channel), - Self::BannedFromChannel { channel } => write!(f, "banned from channel '{}'", channel), - Self::TimedOut => write!(f, "your connection timed out"), - Self::ShouldReconnect => write!(f, "you should reconnect. Twitch restarted the server"), - Self::UnexpectedEof => write!(f, "reached an unexpected EOF"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - Self::InvalidUtf8(err) => Some(err), - Self::ParsingFailure(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(err: DecodeError) -> Self { - match err { - DecodeError::Io(err) => Self::Io(err), - DecodeError::InvalidUtf8(err) => Self::InvalidUtf8(err), - DecodeError::ParseError(err) => Self::ParsingFailure(err), - DecodeError::Eof => Self::UnexpectedEof, - } - } -} - -impl From for Error { - fn from(err: std::io::Error) -> Self { - Self::Io(err) - } -} - -impl From for Error { - fn from(err: MessageError) -> Self { - Self::ParsingFailure(err) - } -} diff --git a/src/runner/mod.rs b/src/runner/mod.rs deleted file mode 100644 index 3fb631e..0000000 --- a/src/runner/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! A set of runners for managing a `event loop` -//! -//! To use the [AsyncRunner]: -//! 1. choose [Connector](crate::connector::Connector) from [connectors](crate::connector). -//! 1. create a [UserConfig](crate::UserConfig). -//! 1. create and connect the [AsyncRunner] via its [AsyncRunner::connect()] method -//! 1. now you're connected to Twitch, so next things you can do. -//! 1. join a channel with: [AsyncRunner::join()], -//! 1. write messages with the [AsyncWriter](crate::writer::AsyncWriter) provided by [AsyncRunner::writer()]. -//! 1. signal you want to quit with the [AsyncRunner::quit_handle()] -//! - -mod status; -pub use status::{Status, StepResult}; - -mod capabilities; -pub use capabilities::Capabilities; - -mod identity; -pub use identity::Identity; - -mod error; -pub use error::Error; - -#[allow(dead_code)] -mod timeout; - -cfg_async! { - mod rate_limit; -} - -cfg_async! { - mod channel; - pub use channel::Channel; -} - -cfg_async! { - mod async_runner; - pub use async_runner::AsyncRunner; -} - -cfg_async! { - #[doc(inline)] - pub use crate::util::NotifyHandle; -} diff --git a/src/runner/rate_limit.rs b/src/runner/rate_limit.rs deleted file mode 100644 index af39248..0000000 --- a/src/runner/rate_limit.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::rate_limit::{RateClass, RateLimit}; -use futures_lite::{AsyncWrite, AsyncWriteExt}; -use std::{collections::VecDeque, time::Duration}; - -pub struct RateLimitedEncoder { - pub(crate) rate_limit: RateLimit, - pub(crate) queue: VecDeque>, -} - -impl RateLimitedEncoder { - pub async fn drain_until_blocked( - &mut self, - name: &str, - limit: &mut u64, - sink: &mut W, - ) -> std::io::Result<()> - where - W: AsyncWrite + Send + Sync + Unpin + ?Sized, - { - while let Some(data) = self.queue.pop_front() { - match self.rate_limit.consume(1) { - Ok(..) => { - *limit = limit.saturating_sub(1); - log::trace!( - target: "twitchchat::encoder", - "> {}", - std::str::from_utf8(&*data).unwrap().escape_debug() - ); - sink.write_all(&*data).await?; - } - Err(..) => { - log::warn!( - target: "twitchchat::rate_limit", - "local rate limit for '{}' hit", - name - ); - break; - } - } - if *limit == 0 { - break; - } - } - - Ok(()) - } - - pub fn enqueue(&mut self, msg: Box<[u8]>) { - self.queue.push_back(msg); - } -} - -pub struct PreviousRate { - pub cap: u64, - pub period: Duration, -} - -impl Default for PreviousRate { - fn default() -> Self { - Self { - cap: RateClass::Regular.tickets(), - period: RateClass::period(), - } - } -} diff --git a/src/runner/status.rs b/src/runner/status.rs deleted file mode 100644 index d87ae8f..0000000 --- a/src/runner/status.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::messages::Commands; - -/// Result of a single step of the loop -#[derive(Debug)] -pub enum StepResult<'a> { - /// A status was produced - Status(Status<'a>), - /// Nothing was produced, try again - Nothing, -} - -/// Status produced by the loop -#[derive(Debug)] -pub enum Status<'a> { - /// A message was produced - Message(Commands<'a>), - /// The user quit the loop - Quit, - /// Loop run to completion - Eof, -} diff --git a/src/runner/timeout.rs b/src/runner/timeout.rs deleted file mode 100644 index a732b1b..0000000 --- a/src/runner/timeout.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::time::{Duration, Instant}; - -#[derive(Copy, Clone, Debug)] -pub enum TimeoutState { - WaitingForPong(Instant), - Activity(Instant), - Start, -} - -impl TimeoutState { - pub fn activity() -> Self { - Self::Activity(Instant::now()) - } - - pub fn waiting_for_pong() -> Self { - Self::WaitingForPong(Instant::now()) - } -} - -pub const WINDOW: Duration = Duration::from_secs(45); -pub const TIMEOUT: Duration = Duration::from_secs(10); -pub const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(30); - -cfg_async! { - pub async fn next_delay() { - futures_timer::Delay::new(WINDOW).await - } -} diff --git a/src/test/conn.rs b/src/test/conn.rs deleted file mode 100644 index 80a4dd0..0000000 --- a/src/test/conn.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::{ - future::Future, - io::{Error, ErrorKind, Result}, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; - -use async_mutex::Mutex; -use futures_lite::io::*; - -use crate::connector::Connector; - -/// A test connection that you can use to insert into and read messages from. -#[derive(Default, Debug, Clone)] -pub struct TestConn { - read: Arc>>>, - write: Arc>>>, -} - -fn take_cursor(cursor: &mut Cursor) -> T { - let out = std::mem::take(cursor.get_mut()); - cursor.set_position(0); - out -} - -impl TestConn { - /// Create a new TestConn - pub fn new() -> Self { - Self::default() - } - - /// Reset the instance and returning a clone - pub fn reset(&self) -> Self { - futures_lite::future::block_on(async { - take_cursor(&mut *self.read.lock().await); - take_cursor(&mut *self.write.lock().await); - }); - - self.clone() - } - - /// Write `data` to the underlying buffers. - /// - /// Whatever uses `AsyncRead` on this type will read from this buffer - pub async fn write_data(&self, data: impl AsRef<[u8]>) { - let mut read = self.read.lock().await; - let p = read.position(); - read.write_all(data.as_ref()).await.unwrap(); - read.set_position(p); - } - - /// Read all of the lines written via `AsyncWrite` - pub async fn read_all_lines(&self) -> Result> { - let data = take_cursor(&mut *self.write.lock().await); - Ok(String::from_utf8(data) - .map_err(|err| Error::new(ErrorKind::Other, err))? - .lines() - .map(|s| format!("{}\r\n", s)) - .collect()) - } - - /// Read the first line written via an `AsyncWrite` - pub async fn read_line(&self) -> Result { - let mut write = self.write.lock().await; - - write.set_position(0); - let mut line = Vec::new(); - let mut buf = [0_u8; 1]; // speed doesn't matter. - - while !line.ends_with(b"\r\n") { - write.read_exact(&mut buf).await?; - line.extend_from_slice(&buf); - } - - String::from_utf8(line).map_err(|err| Error::new(ErrorKind::Other, err)) - } -} - -macro_rules! impls { - ($($ty:ty)*) => { - $( - impl AsyncRead for $ty { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut [u8], - ) -> Poll> { - let this = self.get_mut(); - - let fut = this.read.lock(); - futures_lite::pin!(fut); - - let mut guard = futures_lite::ready!(fut.poll(cx)); - let guard = &mut *guard; - futures_lite::pin!(guard); - guard.poll_read(cx, buf) - } - } - - impl AsyncWrite for $ty { - fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - let this = self.get_mut(); - - let fut = this.write.lock(); - futures_lite::pin!(fut); - - let mut guard = futures_lite::ready!(fut.poll(cx)); - guard.get_mut().extend_from_slice(buf); - - let fut = guard.seek(std::io::SeekFrom::Current(buf.len() as _)); - futures_lite::pin!(fut); - if let Err(err) = futures_lite::ready!(fut.poll(cx)) { - return Poll::Ready(Err(err)) - } - - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - } - )* - }; -} - -impls! { - &TestConn - TestConn -} - -/// A [Connector] that uses the [TestConn] -/// -/// Generally you'll pre-fill the 'read' buffers via -/// [connector.conn.write_data()](TestConn::write_data()) and then clone the [TestConnector] and give a -/// copy to the [AsyncRunner](crate::AsyncRunner) -/// -/// Once the [AsyncRunner](crate::AsyncRunner) has written to the [TestConn]. You can read what was written via the [TestConn::read_all_lines] method. -#[derive(Default, Debug, Clone)] -pub struct TestConnector { - /// The [TestConn]. You can read/write to this while the [AsyncRunner](crate::AsyncRunner) has the connector - pub conn: TestConn, -} - -impl Connector for TestConnector { - type Output = TestConn; - - fn connect(&mut self) -> crate::BoxedFuture> { - let conn = self.conn.clone(); - Box::pin(async move { Ok(conn) }) - } -} diff --git a/src/test/mod.rs b/src/test/mod.rs deleted file mode 100644 index cf2dae4..0000000 --- a/src/test/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod str; -pub use self::str::*; - -mod tags_builder; -pub use tags_builder::{BuilderError, TagsBuilder, UserTags}; - -#[cfg(feature = "testing")] -#[cfg_attr(docsrs, doc(cfg(feature = "testing")))] -mod conn; - -#[cfg(feature = "testing")] -#[cfg_attr(docsrs, doc(cfg(feature = "testing")))] -pub use conn::{TestConn, TestConnector}; diff --git a/src/test/str.rs b/src/test/str.rs deleted file mode 100644 index 48934b2..0000000 --- a/src/test/str.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[doc(inline)] -pub use crate::irc::tags::{escape_str, unescape_str}; diff --git a/src/twitch/badge.rs b/src/twitch/badge.rs deleted file mode 100644 index 41e49ff..0000000 --- a/src/twitch/badge.rs +++ /dev/null @@ -1,80 +0,0 @@ -/// The kind of the [badges] that are associated with messages. -/// -/// Any unknown (e.g. custom badges/sub events, etc) are placed into the [Unknown] variant. -/// -/// [badges]: Badge -/// [Unknown]: BadgeKind::Unknown -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub enum BadgeKind<'a> { - /// Admin badge - Admin, - /// Bits badge - Bits, - /// Broadcaster badge - Broadcaster, - /// GlobalMod badge - GlobalMod, - /// Moderator badge - Moderator, - /// Subscriber badge - Subscriber, - /// Staff badge - Staff, - /// Turbo badge - Turbo, - /// Premium badge - Premium, - /// VIP badge - VIP, - /// Partner badge - Partner, - /// Unknown badge. Likely a custom badge - Unknown(&'a str), -} - -/// Badges attached to a message -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct Badge<'a> { - /// The kind of the Badge - pub kind: BadgeKind<'a>, - /// Any associated data with the badge - /// - /// May be: - /// - version - /// - number of bits - /// - number of months needed for sub badge - /// - etc - pub data: &'a str, -} - -impl<'a> Badge<'a> { - /// Tries to parse a badge from this message part - pub fn parse(input: &'a str) -> Option> { - use BadgeKind::*; - let mut iter = input.split('/'); - let kind = match iter.next()? { - "admin" => Admin, - "bits" => Bits, - "broadcaster" => Broadcaster, - "global_mod" => GlobalMod, - "moderator" => Moderator, - "subscriber" => Subscriber, - "staff" => Staff, - "turbo" => Turbo, - "premium" => Premium, - "vip" => VIP, - "partner" => Partner, - badge => Unknown(badge), - }; - - iter.next().map(|data| Badge { kind, data }) - } -} - -/// Metadata to the chat badges -pub type BadgeInfo<'a> = Badge<'a>; - -// TODO tests diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 52f9021..0000000 --- a/src/util.rs +++ /dev/null @@ -1,183 +0,0 @@ -#[allow(dead_code)] -pub fn timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() -} - -cfg_async! { -use futures_lite::future::{ready, Ready}; -use std::{ - future::Future, - pin::Pin, - task::{Context, Poll}, -}; - -// TODO if we make this clonable it can be used in the Writers -pub struct Notify { - rx: crate::channel::Receiver<()>, - triggered: bool, -} - -impl std::fmt::Debug for Notify { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Notify").finish() - } -} - -impl Notify { - pub fn new() -> (Self, NotifyHandle) { - let (tx, rx) = crate::channel::bounded(1); - let this = Self { - rx, - triggered: false, - }; - (this, NotifyHandle { tx }) - } - - pub async fn wait(&mut self) { - // cache the wait - if self.triggered { - return; - } - - use futures_lite::StreamExt as _; - let _ = self.rx.next().await; - self.triggered = true; - } -} - -/// A notify handle for sending a single-shot signal to the 'other side' -#[derive(Clone)] -pub struct NotifyHandle { - tx: crate::channel::Sender<()>, -} - -impl std::fmt::Debug for NotifyHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NotifyHandle").finish() - } -} - -impl NotifyHandle { - /// Consumes the handle, notifying the other side. - /// - /// Returns false if the other side wasn't around any more - pub async fn notify(self) -> bool { - self.tx.send(()).await.is_ok() - } -} - -pub enum Either { - Left(L), - Right(R), -} - -pub use Either::{Left, Right}; - -pub trait FutExt -where - Self: Future + Send + Sync + Sized, - Self::Output: Send + Sync, -{ - fn either(self, other: F) -> Or - where - F: Future + Send + Sync, - F::Output: Send + Sync; - - fn first(self, other: F) -> Or - where - F: Future + Send + Sync, - F::Output: Send + Sync; - - fn now_or_never(self) -> Or>; -} - -impl FutExt for T -where - T: Future + Send + Sync, - T::Output: Send + Sync, -{ - fn either(self, right: F) -> Or - where - F: Future + Send + Sync, - F::Output: Send + Sync, - { - let left = self; - Or { - left, - right, - biased: false, - } - } - - fn first(self, right: F) -> Or - where - F: Future + Send + Sync, - F::Output: Send + Sync, - { - let left = self; - Or { - left, - right, - biased: true, - } - } - - fn now_or_never(self) -> Or> { - self.first(ready(())) - } -} - -pin_project_lite::pin_project! { - pub struct Or { - #[pin] - left: A, - - #[pin] - right: B, - - biased: bool, - } -} - -impl Future for Or -where - A: Future + Send + Sync, - A::Output: Send + Sync, - - B: Future + Send + Sync, - A::Output: Send + Sync, -{ - type Output = Either; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); - - macro_rules! poll { - ($expr:ident => $map:expr) => { - if let Poll::Ready(t) = this.$expr.poll(cx).map($map) { - return Poll::Ready(t); - } - }; - } - - if *this.biased { - poll!(left => Left); - poll!(right => Right); - return Poll::Pending; - } - - if fastrand::bool() { - poll!(left => Left); - poll!(right => Right); - } else { - poll!(right => Right); - poll!(left => Left); - } - - Poll::Pending - } -} -} diff --git a/src/writer/async_writer.rs b/src/writer/async_writer.rs deleted file mode 100644 index 9a0f8e5..0000000 --- a/src/writer/async_writer.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::channel::Sender; -use crate::encoder::AsyncEncoder; -use crate::Encodable; - -use futures_lite::AsyncWrite; -use io::Write; -use std::io::{self}; - -/// An asynchronous writer. -#[derive(Clone)] -pub struct AsyncWriter { - inner: AsyncEncoder, - activity_tx: Sender<()>, -} - -impl std::fmt::Debug for AsyncWriter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AsyncWriter").finish() - } -} - -impl AsyncWriter -where - W: Write + Send + Sync, -{ - /// If the wrapped writer is synchronous, you can use this method to encode the message to it. - pub fn encode_sync(&mut self, msg: M) -> io::Result<()> - where - M: Encodable + Send + Sync, - { - self.inner.encode_sync(msg) - } -} - -impl Write for AsyncWriter -where - W: Write + Send + Sync, -{ - fn write(&mut self, buf: &[u8]) -> io::Result { - self.inner.write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.inner.flush() - } -} - -impl AsyncWriter -where - W: AsyncWrite + Unpin + Send + Sync, -{ - pub(crate) fn new(inner: W, activity_tx: Sender<()>) -> Self { - Self { - inner: AsyncEncoder::new(inner), - activity_tx, - } - } - - /// Encode this [Encodable] message to the writer. - pub async fn encode(&mut self, msg: M) -> io::Result<()> - where - M: Encodable + Send + Sync, - { - self.inner.encode(msg).await?; - if self.activity_tx.send(()).await.is_err() { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "Runner has closed its receiver", - )); - } - Ok(()) - } - - /// Encode a slice of [Encodable] messages to the writer. - pub async fn encode_many<'a, I, M>(&mut self, msgs: I) -> io::Result<()> - where - I: IntoIterator + Send + Sync + 'a, - I::IntoIter: Send + Sync, - M: Encodable + Send + Sync + 'a, - { - for msg in msgs { - self.encode(msg).await?; - } - Ok(()) - } -} diff --git a/src/writer/mod.rs b/src/writer/mod.rs deleted file mode 100644 index 2a52486..0000000 --- a/src/writer/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! A set of writers - -mod async_writer; -pub use async_writer::AsyncWriter; - -mod mpsc_writer; -pub use mpsc_writer::MpscWriter; diff --git a/src/writer/mpsc_writer.rs b/src/writer/mpsc_writer.rs deleted file mode 100644 index ea2c6a8..0000000 --- a/src/writer/mpsc_writer.rs +++ /dev/null @@ -1,146 +0,0 @@ -use crate::Encodable; - -use futures_lite::AsyncWrite; -use std::{ - io::{self, Write}, - pin::Pin, - task::{Context, Poll}, -}; - -/// A mpsc-based writer. -/// -/// This can be used both a [std::io::Write] instance and an [AsyncWrite][async-write] instance. -/// -/// [async-write]: futures_lite::AsyncWrite -pub struct MpscWriter { - buf: Vec, - channel: crate::channel::Sender>, -} - -impl std::fmt::Debug for MpscWriter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MpscWriter").finish() - } -} - -impl Clone for MpscWriter { - fn clone(&self) -> MpscWriter { - Self { - buf: Vec::new(), - channel: self.channel.clone(), - } - } -} - -impl MpscWriter { - /// Create a new Writer with this Sender - pub const fn new(channel: crate::channel::Sender>) -> Self { - Self { - buf: Vec::new(), - channel, - } - } - - /// Encode this message to the inner channel - pub fn encode(&mut self, msg: M) -> io::Result<()> - where - M: Encodable + Send, - { - msg.encode(&mut self.buf)?; - self.flush() - } - - fn split_buf(&mut self) -> Option> { - let end = match self.buf.iter().position(|&c| c == b'\n') { - Some(p) if self.buf.get(p - 1) == Some(&b'\r') => p, - _ => return None, - }; - - // include the \n - let mut tail = self.buf.split_off(end + 1); - std::mem::swap(&mut self.buf, &mut tail); - Some(tail.into_boxed_slice()) - } - - fn inner_flush(&mut self) -> std::io::Result<()> { - use crate::channel::TrySendError; - - let tail = match self.split_buf() { - Some(tail) => tail, - None => { - log::warn!("cannot flush an incomplete buffer"); - return Ok(()); - } - }; - - match self.channel.try_send(tail) { - Ok(..) => Ok(()), - Err(TrySendError::Closed(..)) => Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "writer was closed", - )), - Err(TrySendError::Full(..)) => unreachable!(), - } - } -} - -impl Write for MpscWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.buf.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - self.inner_flush() - } -} - -impl AsyncWrite for MpscWriter { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let mut this = self.as_mut(); - this.buf.extend_from_slice(buf); - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - let mut this = self.as_mut(); - Poll::Ready(this.inner_flush()) - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.poll_flush(cx) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn mpsc_empty_flush() { - let (tx, rx) = crate::channel::bounded(1); - let mut m = MpscWriter::new(tx); - assert!(m.flush().is_ok()); - assert!(rx.try_recv().is_none()); - - let _ = m.write(b"asdf").unwrap(); - assert!(m.flush().is_ok()); - assert!(rx.try_recv().is_none()); - - let _ = m.write(b"\r\n").unwrap(); - assert!(m.flush().is_ok()); - assert_eq!(&*rx.try_recv().unwrap(), b"asdf\r\n"); - - assert!(m.flush().is_ok()); - assert!(rx.try_recv().is_none()); - - assert!(m.buf.is_empty()); - - let _ = m.write(b"\r\n").unwrap(); - assert!(m.flush().is_ok()); - assert_eq!(&*rx.try_recv().unwrap(), b"\r\n"); - } -} diff --git a/twitchchat/Cargo.toml b/twitchchat/Cargo.toml new file mode 100644 index 0000000..d3e8ae7 --- /dev/null +++ b/twitchchat/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "twitchchat" +edition = "2018" +version = "0.15.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous", "tokio"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "interface to the irc-side of twitch's chat system" +documentation = "https://docs.rs/twitchchat/latest/twitchchat/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming", "parser-implementations"] + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + +[features] +default = [] +writer = ["flume"] +async = ["flume/async", "futures-lite"] +sink_stream = ["async", "futures"] + +[dependencies] +# logging support +log = { version = "0.4.11", features = ["std"] } + +# just the futures traits +futures-lite = { version = "1.11.2", optional = true } + +# for async stuff that require more +futures = { version = "0.3.8", default-features = false, features = ["std"], optional = true } + +# for optional serialization and deserialization +serde = { version = "1.0.117", features = ["derive"], optional = true } + +# for message passing -- we'll use std channels for non-async code +# TODO don't expose this in the public api +flume = { version = "0.9.2", package = "flume", default-features = false, optional = true } + +[dev-dependencies] +async-executor = { version = "1.4.0", default-features = false } +futures = { version = "0.3.8", default-features = false } +rmp-serde = "0.14.4" +serde_json = "1.0.59" diff --git a/twitchchat/LICENSE-APACHE b/twitchchat/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat/LICENSE-MIT b/twitchchat/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat/src/asynchronous.rs b/twitchchat/src/asynchronous.rs new file mode 100644 index 0000000..3d3e706 --- /dev/null +++ b/twitchchat/src/asynchronous.rs @@ -0,0 +1,151 @@ +//! AsyncRead/AsynWrite ([`futures::AsyncRead`] + [`futures::AsyncWrite`]) types +//! +//! TODO write a demo here, and more of a description +//! +use crate::timeout::timeout_detection_inner; + +/// A boxed [`futures::AsyncRead`] trait object +pub type BoxedRead = Box; + +/// A boxed [`futures::AsyncWrite`] trait object +pub type BoxedWrite = Box; + +// re-exports +pub use crate::{ + decoder::{AsyncDecoder as Decoder, DecodeError}, + encoder::AsyncEncoder as Encoder, +}; + +pub use crate::handshake::{asynchronous::Handshake, HandshakeError}; +pub use crate::timeout::{ + Activity, ActivityReceiver, ActivitySender, TimedOutError, TIMEOUT, WINDOW, +}; + +pub use crate::identity::{Identity, YourCapabilities}; + +/// A helper function that loops over the 'token' Receiver and responds with `PONGs` +/// +/// This blocks the current future. +/// +/// This is only available when you have `features = ["writer"]` enabled +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn respond_to_idle_events( + writer: crate::writer::MpscWriter, + tokens: flume::Receiver, +) { + while let Ok(token) = tokens.recv_async().await { + if writer.send(crate::commands::pong(&token)).is_err() { + break; + } + } +} + +/// An asynchronous idle detection loop -- this will block until a timeout is detected, or you send a Quit signal +/// +/// This allows you keep a connection alive with an out-of-band loop. +/// +/// Normally, Twitch will send a `PING` every so often and you must reply with a `PONG` or you'll be disconnected. +/// +/// But sometimes you want to detect an idle connection and force the 'heartbeat' to happen sooner. This function gives you that ability. +/// +/// Usage: +/// * create [`ActivitySender`] and [`ActivityReceiver`] with the [`Activity::pair()`] function. +/// * create a [`std::sync::mpsc::sync_channel`] that is used for its responses (the **PING tokens**). +/// * start the loop (you'll probably want to spawn a task so you don't block the current future) +/// * see [`sync::idle_detection_loop()`][idle_detection_loop] for an sync version +/// * when you want to delay the timeout (e.g. you wrote something), send a [`Activity::Tick`] via [`ActivitySender::message()`]. +/// * when you read a message, you should give a copy of it to the loop, send a [`Activity::Message`] via [`ActivitySender::message()`]. +/// * when you want to signal a shutdown, send a [`Activity::Quit`] via [`ActivitySender::message()`]. +/// * you'll periodically get a message via the channel you passed to it. +/// * you should encode this string via [`twitchchat::commands::ping()`][ping] to your Encoder. +/// +/// When [`WINDOW`][window] has passed without any activity from you (`Quit`, `Message`), it will produce a **token** via the channel. +/// +/// This is the type you should encode as `ping`. +/// +/// If the server does not reply within [`TIMEOUT`][timeout] then the loop will exit, producing an [`TimedOutError::TimedOut`] +/// +/// # Example +/// +/// ```no_run +/// # use twitchchat::{asynchronous::*, *, irc::*}; +/// # use futures_lite::StreamExt as _; +/// # let stream = std::io::Cursor::new(Vec::new()); +/// # let decoder = twitchchat::sync::Decoder::new(stream); +/// # let executor = async_executor::LocalExecutor::new(); +/// // create an activity pair -- this lets you reschedule the timeout +/// let (sender, receiver) = Activity::pair(); +/// +/// // interacting with the loop: +/// // this is a generic event to push the timeout forward +/// // you'll want to do this anytime you write +/// sender.message(Activity::Tick); +/// +/// # let msg: IrcMessage = irc::parse(":PING 123456789\r\n").next().unwrap().unwrap(); +/// // when you read a message, you should send a copy of it to the timeout +/// // detector +/// sender.message(Activity::Message(msg)); +/// +/// // you can also signal for it to quit +/// // when you send this message, the loop will end and return `Ok(())` +/// sender.message(Activity::Quit); +/// +/// // you can otherwise shut it down by dropping the `ActivitySender` the loop +/// // will end and return `Ok(())` +/// +/// // reading from suggested responses the loop: +/// // you'll need a channel that the detector will respond with. +/// // it'll send you a 'token' that you should send to the connection via +/// // `commands::ping(&token).encode(&mut writer)`; +/// let (tx, rx) = flume::bounded(1); +/// +/// // the loop will block the current future +/// let fut = executor.spawn(idle_detection_loop(receiver, tx)); +/// +/// // you can either spawn the receiver loop off, or use a method like +/// // `Receiver::try_recv` for non-blocking receives +/// # let mut writer = Encoder::new(vec![]); +/// executor.spawn(async move { +/// // this receiver return None when the loop exits +/// // this happens on success (Quit, you drop the ActivitySender) +/// // or a timeout error occurs +/// let mut stream = rx.into_stream(); +/// while let Some(token) = stream.next().await { +/// // send a ping to the server, wtih this token. +/// commands::ping(&token).encode(&mut writer).unwrap() +/// // if the server does not reply within the `TIMEOUT` an error +/// // will be produced on the other end +/// } +/// }); +/// +/// // block the future until the loop returns +/// let res = futures_lite::future::block_on(executor.run(fut)); +/// +/// // when the future ends, it should return a Result, if a timeout occured, +/// // it'll return an `Error::Timeout` +/// if let Err(TimedOutError::TimedOut) = res { +/// // the connection timed out +/// } +/// ``` +/// +/// [window]: WINDOW +/// [timeout]: TIMEOUT +/// [ping]: crate::commands::ping +/// [idle_detection_loop]: crate::sync::idle_detection_loop +/// +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn idle_detection_loop( + input: ActivityReceiver, + output: flume::Sender, +) -> Result<(), TimedOutError> { + let (tx, rx) = flume::bounded(1); + std::thread::spawn(move || { + let res = timeout_detection_inner(input, output); + let _ = tx.send(res); + }); + rx.into_recv_async() + .await + .map_err(|_| TimedOutError::TimedOut)? +} diff --git a/src/commands.rs b/twitchchat/src/commands.rs similarity index 96% rename from src/commands.rs rename to twitchchat/src/commands.rs index 4280349..7e63511 100644 --- a/src/commands.rs +++ b/twitchchat/src/commands.rs @@ -4,12 +4,9 @@ //! //! The [functions][functions] in this module produce borrowed types in the [`types`][types] module. You can store the [`types`][types] for multiple encodings. //! -//! ### Some provided encoders: -//! * [`Encoder`](../struct.Encoder.html) -//! //! [functions]: ./index.html#functions //! [types]: ./types/index.html -//! [encodable]: ./trait.Encodable.html +//! [encodable]: crate::encodable::Encodable pub(crate) use super::Encodable; macro_rules! write_cmd { @@ -104,9 +101,6 @@ export_commands! { } macro_rules! serde_for_commands { - (@one $($x:tt)*) => { () }; - (@len $($e:expr),*) => { <[()]>::len(&[$(serde_for_commands!(@one $e)),*]); }; - ($($ty:ident { $($field:ident),* $(,)?});* $(;)?) => { $( #[cfg(feature = "serde")] @@ -122,8 +116,7 @@ macro_rules! serde_for_commands { self.encode(&mut data).map_err(Error::custom)?; let raw = std::str::from_utf8(&data).map_err(Error::custom)?; - let len = serde_for_commands!(@len $($field),*); - + let len = count_it!($($field)*); let mut s = serializer.serialize_struct(stringify!($ty), std::cmp::max(len, 1))?; s.serialize_field("raw", raw)?; $( s.serialize_field(stringify!($field), &self.$field)?; )* diff --git a/src/commands/ban.rs b/twitchchat/src/commands/ban.rs similarity index 95% rename from src/commands/ban.rs rename to twitchchat/src/commands/ban.rs index 09ccdf7..dc3a59a 100644 --- a/src/commands/ban.rs +++ b/twitchchat/src/commands/ban.rs @@ -28,10 +28,7 @@ pub fn ban<'a>(channel: &'a str, username: &'a str, reason: impl Into Encodable for Ban<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/ban {}{}", self.username, MaybeEmpty(self.reason) diff --git a/src/commands/clear.rs b/twitchchat/src/commands/clear.rs similarity index 92% rename from src/commands/clear.rs rename to twitchchat/src/commands/clear.rs index 76994b4..43f8cde 100644 --- a/src/commands/clear.rs +++ b/twitchchat/src/commands/clear.rs @@ -16,10 +16,7 @@ pub const fn clear(channel: &str) -> Clear<'_> { } impl<'a> Encodable for Clear<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/clear") } } diff --git a/src/commands/color.rs b/twitchchat/src/commands/color.rs similarity index 94% rename from src/commands/color.rs rename to twitchchat/src/commands/color.rs index 6748513..4ae75bf 100644 --- a/src/commands/color.rs +++ b/twitchchat/src/commands/color.rs @@ -26,10 +26,7 @@ where } impl<'a> Encodable for Color<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_jtv_cmd!(buf, "/color {}", &self.color.to_string()) } } diff --git a/src/commands/command.rs b/twitchchat/src/commands/command.rs similarity index 94% rename from src/commands/command.rs rename to twitchchat/src/commands/command.rs index e841571..1769733 100644 --- a/src/commands/command.rs +++ b/twitchchat/src/commands/command.rs @@ -17,10 +17,7 @@ pub fn command<'a>(channel: &'a str, data: &'a str) -> Command<'a> { } impl<'a> Encodable for Command<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => &self.data) } } diff --git a/src/commands/commercial.rs b/twitchchat/src/commands/commercial.rs similarity index 96% rename from src/commands/commercial.rs rename to twitchchat/src/commands/commercial.rs index ee37122..cc10fb9 100644 --- a/src/commands/commercial.rs +++ b/twitchchat/src/commands/commercial.rs @@ -22,9 +22,7 @@ pub fn commercial(channel: &str, length: impl Into>) -> Commercial } impl<'a> Encodable for Commercial<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { let length = self.length.map(|s| s.to_string()); write_cmd!(buf, Channel(self.channel) => "/commercial{}", MaybeEmpty(length.as_deref())) diff --git a/src/commands/disconnect.rs b/twitchchat/src/commands/disconnect.rs similarity index 91% rename from src/commands/disconnect.rs rename to twitchchat/src/commands/disconnect.rs index b2fe02e..007669a 100644 --- a/src/commands/disconnect.rs +++ b/twitchchat/src/commands/disconnect.rs @@ -19,10 +19,7 @@ pub const fn disconnect() -> Disconnect<'static> { } impl<'a> Encodable for Disconnect<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_jtv_cmd!(buf, "/disconnect") } } diff --git a/src/commands/emote_only.rs b/twitchchat/src/commands/emote_only.rs similarity index 93% rename from src/commands/emote_only.rs rename to twitchchat/src/commands/emote_only.rs index 5707030..33f00c5 100644 --- a/src/commands/emote_only.rs +++ b/twitchchat/src/commands/emote_only.rs @@ -20,10 +20,7 @@ pub const fn emote_only(channel: &str) -> EmoteOnly<'_> { } impl<'a> Encodable for EmoteOnly<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/emoteonly") } } diff --git a/src/commands/emote_only_off.rs b/twitchchat/src/commands/emote_only_off.rs similarity index 94% rename from src/commands/emote_only_off.rs rename to twitchchat/src/commands/emote_only_off.rs index 8962c67..8af5e0b 100644 --- a/src/commands/emote_only_off.rs +++ b/twitchchat/src/commands/emote_only_off.rs @@ -16,9 +16,7 @@ pub const fn emote_only_off(channel: &str) -> EmoteOnlyOff<'_> { } impl<'a> Encodable for EmoteOnlyOff<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/emoteonlyoff") } diff --git a/src/commands/followers.rs b/twitchchat/src/commands/followers.rs similarity index 95% rename from src/commands/followers.rs rename to twitchchat/src/commands/followers.rs index f4e3a57..434f217 100644 --- a/src/commands/followers.rs +++ b/twitchchat/src/commands/followers.rs @@ -25,9 +25,7 @@ pub const fn followers<'a>(channel: &'a str, duration: &'a str) -> Followers<'a> } impl<'a> Encodable for Followers<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/followers {}", self.duration) } diff --git a/src/commands/followers_off.rs b/twitchchat/src/commands/followers_off.rs similarity index 95% rename from src/commands/followers_off.rs rename to twitchchat/src/commands/followers_off.rs index 27e9ff8..a2586ee 100644 --- a/src/commands/followers_off.rs +++ b/twitchchat/src/commands/followers_off.rs @@ -16,7 +16,7 @@ pub const fn followers_off(channel: &str) -> FollowersOff<'_> { } impl<'a> Encodable for FollowersOff<'a> { - fn encode(&self, buf: &mut W) -> Result<()> { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/followersoff") } } diff --git a/src/commands/give_mod.rs b/twitchchat/src/commands/give_mod.rs similarity index 94% rename from src/commands/give_mod.rs rename to twitchchat/src/commands/give_mod.rs index b35692f..490587e 100644 --- a/src/commands/give_mod.rs +++ b/twitchchat/src/commands/give_mod.rs @@ -21,10 +21,7 @@ pub const fn give_mod<'a>(channel: &'a str, username: &'a str) -> GiveMod<'a> { } impl<'a> Encodable for GiveMod<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/mod {}", self.username) } } diff --git a/src/commands/help.rs b/twitchchat/src/commands/help.rs similarity index 92% rename from src/commands/help.rs rename to twitchchat/src/commands/help.rs index 43dafee..f9fb434 100644 --- a/src/commands/help.rs +++ b/twitchchat/src/commands/help.rs @@ -16,10 +16,7 @@ pub const fn help(channel: &str) -> Help<'_> { } impl<'a> Encodable for Help<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/help") } } diff --git a/src/commands/host.rs b/twitchchat/src/commands/host.rs similarity index 95% rename from src/commands/host.rs rename to twitchchat/src/commands/host.rs index 80c0c0a..706549c 100644 --- a/src/commands/host.rs +++ b/twitchchat/src/commands/host.rs @@ -21,10 +21,7 @@ pub const fn host<'a>(source: &'a str, target: &'a str) -> Host<'a> { } impl<'a> Encodable for Host<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.source) => "/host {}", Channel(self.target)) } } diff --git a/src/commands/join.rs b/twitchchat/src/commands/join.rs similarity index 94% rename from src/commands/join.rs rename to twitchchat/src/commands/join.rs index c4f9bfb..2b5819f 100644 --- a/src/commands/join.rs +++ b/twitchchat/src/commands/join.rs @@ -16,7 +16,7 @@ pub const fn join(channel: &str) -> Join<'_> { } impl<'a> Encodable for Join<'a> { - fn encode(&self, buf: &mut W) -> Result<()> { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!(buf, "JOIN {}", super::Channel(self.channel)) } } diff --git a/src/commands/jtv_command.rs b/twitchchat/src/commands/jtv_command.rs similarity index 91% rename from src/commands/jtv_command.rs rename to twitchchat/src/commands/jtv_command.rs index c481ada..4baba42 100644 --- a/src/commands/jtv_command.rs +++ b/twitchchat/src/commands/jtv_command.rs @@ -16,10 +16,7 @@ pub const fn jtv_command(data: &str) -> JtvCommand<'_> { } impl<'a> Encodable for JtvCommand<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_jtv_cmd!(buf, self.data) } } diff --git a/src/commands/marker.rs b/twitchchat/src/commands/marker.rs similarity index 98% rename from src/commands/marker.rs rename to twitchchat/src/commands/marker.rs index ab976de..2b46708 100644 --- a/src/commands/marker.rs +++ b/twitchchat/src/commands/marker.rs @@ -24,7 +24,7 @@ pub fn marker<'a>(channel: &'a str, comment: impl Into>) -> Mark } impl<'a> Encodable for Marker<'a> { - fn encode(&self, buf: &mut W) -> Result<()> { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { fn truncate(s: &str) -> &str { const MAX: usize = 140; if s.len() <= MAX { diff --git a/src/commands/me.rs b/twitchchat/src/commands/me.rs similarity index 94% rename from src/commands/me.rs rename to twitchchat/src/commands/me.rs index e52a471..1108e53 100644 --- a/src/commands/me.rs +++ b/twitchchat/src/commands/me.rs @@ -17,10 +17,7 @@ pub const fn me<'a>(channel: &'a str, msg: &'a str) -> Me<'a> { } impl<'a> Encodable for Me<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/me {}", self.msg) } } diff --git a/src/commands/mods.rs b/twitchchat/src/commands/mods.rs similarity index 92% rename from src/commands/mods.rs rename to twitchchat/src/commands/mods.rs index 7288c58..3260d7a 100644 --- a/src/commands/mods.rs +++ b/twitchchat/src/commands/mods.rs @@ -16,10 +16,7 @@ pub const fn mods(channel: &str) -> Mods<'_> { } impl<'a> Encodable for Mods<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/mods") } } diff --git a/src/commands/part.rs b/twitchchat/src/commands/part.rs similarity index 95% rename from src/commands/part.rs rename to twitchchat/src/commands/part.rs index c1ff766..0ca1485 100644 --- a/src/commands/part.rs +++ b/twitchchat/src/commands/part.rs @@ -16,7 +16,7 @@ pub const fn part(channel: &str) -> Part<'_> { } impl<'a> Encodable for Part<'a> { - fn encode(&self, buf: &mut W) -> Result<()> { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write!(buf, "PART {}\r\n", super::Channel(self.channel)) } } diff --git a/src/commands/ping.rs b/twitchchat/src/commands/ping.rs similarity index 90% rename from src/commands/ping.rs rename to twitchchat/src/commands/ping.rs index 474393e..2a34b4f 100644 --- a/src/commands/ping.rs +++ b/twitchchat/src/commands/ping.rs @@ -16,10 +16,7 @@ pub const fn ping(token: &str) -> Ping<'_> { } impl<'a> Encodable for Ping<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!(buf, "PING {}", self.token) } } diff --git a/src/commands/pong.rs b/twitchchat/src/commands/pong.rs similarity index 90% rename from src/commands/pong.rs rename to twitchchat/src/commands/pong.rs index 3f3836b..2c090cc 100644 --- a/src/commands/pong.rs +++ b/twitchchat/src/commands/pong.rs @@ -16,10 +16,7 @@ pub const fn pong(token: &str) -> Pong<'_> { } impl<'a> Encodable for Pong<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!(buf, "PONG :{}", self.token) } } diff --git a/src/commands/privmsg.rs b/twitchchat/src/commands/privmsg.rs similarity index 95% rename from src/commands/privmsg.rs rename to twitchchat/src/commands/privmsg.rs index 5682401..38dad31 100644 --- a/src/commands/privmsg.rs +++ b/twitchchat/src/commands/privmsg.rs @@ -17,10 +17,7 @@ pub const fn privmsg<'a>(channel: &'a str, msg: &'a str) -> Privmsg<'a> { } impl<'a> Encodable for Privmsg<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!(buf, "PRIVMSG {} :{}", Channel(self.channel), self.msg) } } diff --git a/src/commands/r9k_beta.rs b/twitchchat/src/commands/r9k_beta.rs similarity index 93% rename from src/commands/r9k_beta.rs rename to twitchchat/src/commands/r9k_beta.rs index 0c57c30..57165f6 100644 --- a/src/commands/r9k_beta.rs +++ b/twitchchat/src/commands/r9k_beta.rs @@ -21,10 +21,7 @@ pub const fn r9k_beta(channel: &str) -> R9kBeta<'_> { } impl<'a> Encodable for R9kBeta<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/r9kbeta") } } diff --git a/src/commands/r9k_beta_off.rs b/twitchchat/src/commands/r9k_beta_off.rs similarity index 93% rename from src/commands/r9k_beta_off.rs rename to twitchchat/src/commands/r9k_beta_off.rs index a32bd4a..c01e3a5 100644 --- a/src/commands/r9k_beta_off.rs +++ b/twitchchat/src/commands/r9k_beta_off.rs @@ -17,10 +17,7 @@ pub const fn r9k_beta_off(channel: &str) -> R9kBetaOff<'_> { } impl<'a> Encodable for R9kBetaOff<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/r9kbetaoff") } } diff --git a/src/commands/raid.rs b/twitchchat/src/commands/raid.rs similarity index 95% rename from src/commands/raid.rs rename to twitchchat/src/commands/raid.rs index 5b0b6e5..eac81d3 100644 --- a/src/commands/raid.rs +++ b/twitchchat/src/commands/raid.rs @@ -21,10 +21,7 @@ pub const fn raid<'a>(source: &'a str, target: &'a str) -> Raid<'a> { } impl<'a> Encodable for Raid<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.source) => "/raid {}", Channel(self.target)) } } diff --git a/src/commands/raw.rs b/twitchchat/src/commands/raw.rs similarity index 91% rename from src/commands/raw.rs rename to twitchchat/src/commands/raw.rs index d8ae898..b76178d 100644 --- a/src/commands/raw.rs +++ b/twitchchat/src/commands/raw.rs @@ -16,10 +16,7 @@ pub const fn raw(data: &str) -> Raw<'_> { } impl<'a> Encodable for Raw<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!(buf, "{}", self.data) } } diff --git a/src/commands/register.rs b/twitchchat/src/commands/register.rs similarity index 96% rename from src/commands/register.rs rename to twitchchat/src/commands/register.rs index 1163a95..fa33c31 100644 --- a/src/commands/register.rs +++ b/twitchchat/src/commands/register.rs @@ -27,7 +27,7 @@ pub fn register(user_config: &UserConfig) -> Register<'_> { } impl<'a> Encodable for Register<'a> { - fn encode(&self, buf: &mut W) -> std::io::Result<()> { + fn encode(&self, buf: &mut dyn Write) -> std::io::Result<()> { let UserConfig { name, token, diff --git a/src/commands/reply.rs b/twitchchat/src/commands/reply.rs similarity index 97% rename from src/commands/reply.rs rename to twitchchat/src/commands/reply.rs index 7bd8ed7..2c6d055 100644 --- a/src/commands/reply.rs +++ b/twitchchat/src/commands/reply.rs @@ -22,7 +22,7 @@ pub const fn reply<'a>(channel: &'a str, msg_id: &'a str, msg: &'a str) -> Reply } impl<'a> Encodable for Reply<'a> { - fn encode(&self, buf: &mut W) -> Result<()> { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_nl!( buf, "@reply-parent-msg-id={} PRIVMSG {} :{}", diff --git a/src/commands/slow.rs b/twitchchat/src/commands/slow.rs similarity index 95% rename from src/commands/slow.rs rename to twitchchat/src/commands/slow.rs index 56d807a..c7337b8 100644 --- a/src/commands/slow.rs +++ b/twitchchat/src/commands/slow.rs @@ -26,10 +26,7 @@ pub fn slow(channel: &str, duration: impl Into>) -> Slow<'_> { } impl<'a> Encodable for Slow<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!( buf, Channel(self.channel) => diff --git a/src/commands/slow_off.rs b/twitchchat/src/commands/slow_off.rs similarity index 92% rename from src/commands/slow_off.rs rename to twitchchat/src/commands/slow_off.rs index 65cb825..548d0a7 100644 --- a/src/commands/slow_off.rs +++ b/twitchchat/src/commands/slow_off.rs @@ -16,10 +16,7 @@ pub const fn slow_off(channel: &str) -> SlowOff<'_> { } impl<'a> Encodable for SlowOff<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/slowoff") } } diff --git a/src/commands/subscribers.rs b/twitchchat/src/commands/subscribers.rs similarity index 94% rename from src/commands/subscribers.rs rename to twitchchat/src/commands/subscribers.rs index 0bbd7e4..1aaacef 100644 --- a/src/commands/subscribers.rs +++ b/twitchchat/src/commands/subscribers.rs @@ -20,9 +20,7 @@ pub const fn subscribers(channel: &str) -> Subscribers<'_> { } impl<'a> Encodable for Subscribers<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/subscribers") } diff --git a/src/commands/subscribers_off.rs b/twitchchat/src/commands/subscribers_off.rs similarity index 93% rename from src/commands/subscribers_off.rs rename to twitchchat/src/commands/subscribers_off.rs index 721a616..5fb3cb2 100644 --- a/src/commands/subscribers_off.rs +++ b/twitchchat/src/commands/subscribers_off.rs @@ -16,10 +16,7 @@ pub const fn subscribers_off(channel: &str) -> SubscribersOff<'_> { } impl<'a> Encodable for SubscribersOff<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/subscribersoff") } } diff --git a/src/commands/timeout.rs b/twitchchat/src/commands/timeout.rs similarity index 97% rename from src/commands/timeout.rs rename to twitchchat/src/commands/timeout.rs index 29e1623..9fda512 100644 --- a/src/commands/timeout.rs +++ b/twitchchat/src/commands/timeout.rs @@ -46,10 +46,7 @@ pub fn timeout<'a>( } impl<'a> Encodable for Timeout<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel)=> "/timeout {}{}{}", self.username, diff --git a/src/commands/unban.rs b/twitchchat/src/commands/unban.rs similarity index 93% rename from src/commands/unban.rs rename to twitchchat/src/commands/unban.rs index 2ca3e30..e07d051 100644 --- a/src/commands/unban.rs +++ b/twitchchat/src/commands/unban.rs @@ -17,10 +17,7 @@ pub const fn unban<'a>(channel: &'a str, username: &'a str) -> Unban<'a> { } impl<'a> Encodable for Unban<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/unban {}", self.username) } } diff --git a/src/commands/unhost.rs b/twitchchat/src/commands/unhost.rs similarity index 93% rename from src/commands/unhost.rs rename to twitchchat/src/commands/unhost.rs index c18115d..5416dad 100644 --- a/src/commands/unhost.rs +++ b/twitchchat/src/commands/unhost.rs @@ -16,9 +16,7 @@ pub const fn unhost(channel: &str) -> Unhost<'_> { } impl<'a> Encodable for Unhost<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/unhost") } diff --git a/src/commands/unmod.rs b/twitchchat/src/commands/unmod.rs similarity index 94% rename from src/commands/unmod.rs rename to twitchchat/src/commands/unmod.rs index b9bcd48..ff7c9ba 100644 --- a/src/commands/unmod.rs +++ b/twitchchat/src/commands/unmod.rs @@ -21,9 +21,7 @@ pub const fn unmod<'a>(channel: &'a str, username: &'a str) -> Unmod<'a> { } impl<'a> Encodable for Unmod<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, +fn encode(&self, buf:&mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/unmod {}", self.username) } diff --git a/src/commands/unraid.rs b/twitchchat/src/commands/unraid.rs similarity index 92% rename from src/commands/unraid.rs rename to twitchchat/src/commands/unraid.rs index 3ba90eb..02b18b9 100644 --- a/src/commands/unraid.rs +++ b/twitchchat/src/commands/unraid.rs @@ -16,10 +16,7 @@ pub const fn unraid(channel: &str) -> Unraid<'_> { } impl<'a> Encodable for Unraid<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/unraid") } } diff --git a/src/commands/untimeout.rs b/twitchchat/src/commands/untimeout.rs similarity index 94% rename from src/commands/untimeout.rs rename to twitchchat/src/commands/untimeout.rs index 3c858d3..eff7c54 100644 --- a/src/commands/untimeout.rs +++ b/twitchchat/src/commands/untimeout.rs @@ -17,10 +17,7 @@ pub const fn untimeout<'a>(channel: &'a str, username: &'a str) -> Untimeout<'a> } impl<'a> Encodable for Untimeout<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/untimeout {}", self.username) } } diff --git a/src/commands/unvip.rs b/twitchchat/src/commands/unvip.rs similarity index 94% rename from src/commands/unvip.rs rename to twitchchat/src/commands/unvip.rs index 8d5c397..dad63c4 100644 --- a/src/commands/unvip.rs +++ b/twitchchat/src/commands/unvip.rs @@ -21,10 +21,7 @@ pub const fn unvip<'a>(channel: &'a str, username: &'a str) -> Unvip<'a> { } impl<'a> Encodable for Unvip<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/unvip {}", self.username) } } diff --git a/src/commands/vip.rs b/twitchchat/src/commands/vip.rs similarity index 93% rename from src/commands/vip.rs rename to twitchchat/src/commands/vip.rs index 5c48cbc..1205a44 100644 --- a/src/commands/vip.rs +++ b/twitchchat/src/commands/vip.rs @@ -21,10 +21,7 @@ pub const fn vip<'a>(channel: &'a str, username: &'a str) -> Vip<'a> { } impl<'a> Encodable for Vip<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/vip {}", self.username) } } diff --git a/src/commands/vips.rs b/twitchchat/src/commands/vips.rs similarity index 92% rename from src/commands/vips.rs rename to twitchchat/src/commands/vips.rs index 546003a..9596f13 100644 --- a/src/commands/vips.rs +++ b/twitchchat/src/commands/vips.rs @@ -16,10 +16,7 @@ pub const fn vips(channel: &str) -> Vips<'_> { } impl<'a> Encodable for Vips<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_cmd!(buf, Channel(self.channel) => "/vips") } } diff --git a/src/commands/whisper.rs b/twitchchat/src/commands/whisper.rs similarity index 92% rename from src/commands/whisper.rs rename to twitchchat/src/commands/whisper.rs index 9efef65..8db786c 100644 --- a/src/commands/whisper.rs +++ b/twitchchat/src/commands/whisper.rs @@ -17,10 +17,7 @@ pub const fn whisper<'a>(username: &'a str, message: &'a str) -> Whisper<'a> { } impl<'a> Encodable for Whisper<'a> { - fn encode(&self, buf: &mut W) -> Result<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> Result<()> { write_jtv_cmd!(buf, "/w {} {}", self.username, self.message) } } diff --git a/src/decoder/async.rs b/twitchchat/src/decoder/async_decoder.rs similarity index 54% rename from src/decoder/async.rs rename to twitchchat/src/decoder/async_decoder.rs index c710d72..fa2b766 100644 --- a/src/decoder/async.rs +++ b/twitchchat/src/decoder/async_decoder.rs @@ -1,45 +1,55 @@ -cfg_async! { -use crate::{irc::IrcMessage, IntoOwned,DecodeError}; +use super::DecodeError; +use crate::{ + irc::IrcMessage, + wait_for::{wait_inner, Event, State}, + IntoOwned as _, +}; use std::{ + collections::VecDeque, future::Future, pin::Pin, task::{Context, Poll}, }; -use futures_lite::{io::BufReader as AsyncBufReader, AsyncBufReadExt, AsyncRead, Stream}; +use futures_lite::{ + io::BufReader as AsyncBufReader, AsyncBufReadExt, AsyncRead, AsyncWrite, Stream, +}; -/// A decoder over [futures_lite::AsyncRead] that produces [IrcMessage]s +/// A decoder over [`futures::AsyncRead`] that produces [`IrcMessage`]s /// -/// This will return an [DecodeError::Eof] when its done reading manually. +/// This will return an [`DecodeError::Eof`] when its done reading manually. /// /// When reading it as a stream, `Eof` will signal the end of the stream (e.g. `None`) pub struct AsyncDecoder { reader: AsyncBufReader, buf: Vec, + back_queue: VecDeque>, } -impl std::fmt::Debug for AsyncDecoder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AsyncDecoder").finish() - } -} - -impl AsyncDecoder { - /// Create a new AsyncDecoder from this [futures_lite::AsyncRead] instance +impl AsyncDecoder +where + R: AsyncRead + Send + Unpin, +{ + /// Create a new [`AsyncDecoder`] from this [`futures::AsyncRead`] instance pub fn new(reader: R) -> Self { Self { reader: AsyncBufReader::new(reader), buf: Vec::with_capacity(1024), + back_queue: VecDeque::new(), } } /// Read the next message. /// - /// This returns a borrowed [IrcMessage] which is valid until the next AsyncDecoder call is made. + /// This returns a borrowed [`IrcMessage`] which is valid until the next [`AsyncDecoder`] call is made. /// - /// If you just want an owned one, use the [AsyncDecoder] as an stream. e.g. dec.next(). + /// If you just want an owned one, use the [`AsyncDecoder`] as an stream. e.g. dec.next(). pub async fn read_message(&mut self) -> Result, DecodeError> { + if let Some(msg) = self.back_queue.pop_front() { + return Ok(msg); + } + self.buf.clear(); let n = self .reader @@ -59,16 +69,56 @@ impl AsyncDecoder { .map(|(_, msg)| msg) } + // TODO this should respond to PINGs + /// Wait for a specific event. + /// + /// This returns the specific matched event and any missed messages read before this returns. + /// + /// You can use [Decoder::extend][extend] to feed these messages back into the decoder. + /// + /// [extend]: AsyncDecoder::extend() + pub async fn wait_for( + &mut self, + event: Event, + ) -> Result<(IrcMessage<'static>, Vec>), DecodeError> + where + R: AsyncWrite, + { + let mut missed = vec![]; + loop { + match wait_inner(self.read_message().await, event)? { + State::Done(msg) => break Ok((msg.into_owned(), missed)), + State::Requeue(msg) => missed.push(msg.into_owned()), + State::Yield => futures_lite::future::yield_now().await, + } + } + } + /// Consume the decoder returning the inner Reader pub fn into_inner(self) -> R { self.reader.into_inner() } } +impl Extend> for AsyncDecoder { + fn extend(&mut self, iter: T) + where + T: IntoIterator>, + { + self.back_queue.extend(iter) + } +} + +impl std::fmt::Debug for AsyncDecoder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AsyncDecoder").finish() + } +} + /// This will produce `Result, DecodeError>` until an `Eof` is received impl Stream for AsyncDecoder where - R: AsyncRead + Send + Sync + Unpin, + R: AsyncRead + Send + Unpin, { type Item = Result, DecodeError>; @@ -78,11 +128,17 @@ where let fut = this.read_message(); futures_lite::pin!(fut); - match futures_lite::ready!(fut.poll(cx)) { - Err(DecodeError::Eof) => Poll::Ready(None), - Ok(msg) => Poll::Ready(Some(Ok(msg.into_owned()))), - Err(err) => Poll::Ready(Some(Err(err))), - } + let res = match futures_lite::ready!(fut.poll(cx)) { + Err(DecodeError::Eof) => None, + Err(DecodeError::Io(err)) if crate::util::is_blocking_error(&err) => { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Ok(msg) => Some(Ok(msg.into_owned())), + Err(err) => Some(Err(err)), + }; + + Poll::Ready(res) } } @@ -122,4 +178,3 @@ mod tests { futures_lite::future::block_on(fut); } } -} diff --git a/twitchchat/src/decoder/decoder.rs b/twitchchat/src/decoder/decoder.rs new file mode 100644 index 0000000..af384f0 --- /dev/null +++ b/twitchchat/src/decoder/decoder.rs @@ -0,0 +1,161 @@ +use super::DecodeError; + +use crate::{ + irc::IrcMessage, + wait_for::{wait_inner, Event, State}, + IntoOwned as _, +}; + +use std::{ + collections::VecDeque, + io::{BufRead, BufReader, Read}, +}; + +/// A decoder over [`std::io::Read`] that produces [`IrcMessage`]s +/// +/// This will return an [`DecodeError::Eof`] when reading manually. +/// +/// When reading it as an iterator, `Eof` will signal the end of the iterator (e.g. `None`) +pub struct Decoder { + // TODO don't use this. it'll be bad with a non-blocking stream + reader: BufReader, + buf: Vec, + back_queue: VecDeque>, +} + +impl Decoder +where + R: Read, +{ + /// Create a new Decoder from this [`std::io::Read`] instance + pub fn new(reader: R) -> Self { + Self { + reader: BufReader::new(reader), + buf: Vec::with_capacity(1024), + back_queue: VecDeque::new(), + } + } + + /// Read the next message. + /// + /// This returns a borrowed [`IrcMessage`] which is valid until the next Decoder call is made. + /// + /// If you just want an owned one, use the [`Decoder`] as an iterator. e.g. dec.next(). + pub fn read_message(&mut self) -> Result, DecodeError> { + if let Some(msg) = self.back_queue.pop_front() { + return Ok(msg); + } + + self.buf.clear(); + let n = self + .reader + .read_until(b'\n', &mut self.buf) + .map_err(DecodeError::Io)?; + if n == 0 { + return Err(DecodeError::Eof); + } + + let str = std::str::from_utf8(&self.buf[..n]).map_err(DecodeError::InvalidUtf8)?; + + // this should only ever parse 1 message + crate::irc::parse_one(str) + .map_err(DecodeError::ParseError) + .map(|(_, msg)| msg) + } + + // TODO this should respond to PINGs + /// Wait for a specific event. + /// + /// This returns the specific matched event and any missed messages read before this returns. + /// + /// You can use [Decoder::extend][extend] to feed these messages back into the decoder. + /// + /// [extend]: Decoder::extend() + pub fn wait_for( + &mut self, + event: Event, + ) -> Result<(IrcMessage<'static>, Vec>), DecodeError> { + let mut missed = vec![]; + loop { + match wait_inner(self.read_message(), event)? { + State::Done(msg) => break Ok((msg.into_owned(), missed)), + State::Requeue(msg) => missed.push(msg.into_owned()), + // TODO this should actually do parking not yielding + State::Yield => std::thread::yield_now(), + } + } + } + + /// Returns an iterator over messages. + /// + /// This will produce `Result`s of `IrcMessages` until an EOF is received + pub fn iter(&mut self) -> &mut Self { + self + } + + /// Consume the decoder returning the inner Reader + pub fn into_inner(self) -> R { + self.reader.into_inner() + } +} + +/// This will produce `Result, DecodeError>` until an `Eof` is received +impl Iterator for Decoder { + type Item = Result, DecodeError>; + + fn next(&mut self) -> Option { + loop { + break match self.read_message() { + Ok(msg) => Some(Ok(msg.into_owned())), + Err(DecodeError::Eof) => None, + + // block until we get a message + Err(DecodeError::Io(err)) if crate::util::is_blocking_error(&err) => continue, + + Err(err) => Some(Err(err)), + }; + } + } +} + +impl Extend> for Decoder { + fn extend(&mut self, iter: T) + where + T: IntoIterator>, + { + self.back_queue.extend(iter) + } +} + +impl std::fmt::Debug for Decoder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Decoder").finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_sync() { + let data = b"hello\r\nworld\r\ntesting this\r\nand another thing\r\n".to_vec(); + let mut reader = std::io::Cursor::new(data); + + // reading from the iterator won't produce the EOF + let v = Decoder::new(&mut reader) + .iter() + .collect::, _>>() + .unwrap(); + // no EOF + assert_eq!(v.len(), 4); + + reader.set_position(0); + // manually reading should produce an EOF + let mut dec = Decoder::new(reader); + for _ in 0..4 { + dec.read_message().unwrap(); + } + assert!(matches!(dec.read_message().unwrap_err(), DecodeError::Eof)) + } +} diff --git a/twitchchat/src/decoder/error.rs b/twitchchat/src/decoder/error.rs new file mode 100644 index 0000000..36668e3 --- /dev/null +++ b/twitchchat/src/decoder/error.rs @@ -0,0 +1,49 @@ +use crate::irc::MessageError; + +/// An error produced by a Decoder. +#[derive(Debug)] +#[non_exhaustive] +pub enum DecodeError { + /// An I/O error occurred + Io(std::io::Error), + /// Invalid UTF-8 was read. + InvalidUtf8(std::str::Utf8Error), + /// Could not parse the IRC message + ParseError(MessageError), + /// EOF was reached + Eof, +} + +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "io error: {}", err), + Self::InvalidUtf8(err) => write!(f, "invalid utf8: {}", err), + Self::ParseError(err) => write!(f, "parse error: {}", err), + Self::Eof => f.write_str("end of file reached"), + } + } +} + +impl std::error::Error for DecodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::InvalidUtf8(err) => Some(err), + Self::ParseError(err) => Some(err), + _ => None, + } + } +} + +impl From for DecodeError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for DecodeError { + fn from(err: std::str::Utf8Error) -> Self { + Self::InvalidUtf8(err) + } +} diff --git a/twitchchat/src/decoder/mod.rs b/twitchchat/src/decoder/mod.rs new file mode 100644 index 0000000..4359a62 --- /dev/null +++ b/twitchchat/src/decoder/mod.rs @@ -0,0 +1,70 @@ +//! Decoding types and functions. +//! +//! A decoder lets you decode messages from an [`std::io::Read`], [`futures::AsyncRead`], for a [`futures::Stream`] in either an iterative fashion, or one-by-one. +//! +//! When not using the [`std::iter::Iterator`] (or [`futures::Stream`]), you'll get a borrowed message from the reader that is valid until the next read. +//! +//! With the `std::iter::Iterator` (or `futures::Stream`) interface, it'll return an owned messages. +//! +//! This crate provides both ***sync*** (`std::iter::Iterator` based) and ***async*** (`futures::Stream` based) decoding. +//! * sync: [Decoder] +//! * async: [AsyncDecoder] +//! * stream: [StreamDecoder] +//! +//! # Borrowed messages +//! ``` +//! let input = "@key1=val;key2=true :user!user@user PRIVMSG #some_channel :\x01ACTION hello world\x01\r\n"; +//! let mut reader = std::io::Cursor::new(input.as_bytes()); +//! +//! // you can either &mut borrow the reader, or let the Decoder take ownership. +//! // ff it takes ownership you can retrieve the inner reader later. +//! let mut decoder = twitchchat::sync::Decoder::new(&mut reader); +//! +//! // returns whether the message was valid +//! // this'll block until it can read a 'full' message (e.g. one delimited by `\r\n`). +//! let msg = decoder.read_message().unwrap(); +//! +//! // msg is borrowed until the next `read_message()` +//! // you can turn a borrowed message into an owned message by using the twitchchat::IntoOwned trait. +//! use twitchchat::IntoOwned as _; +//! let owned: twitchchat::irc::IrcMessage<'static> = msg.into_owned(); +//! ``` +//! +//! # Owned messages +//! ``` +//! let input = "@key1=val;key2=true :user!user@user PRIVMSG #some_channel :\x01ACTION hello world\x01\r\n"; +//! let mut reader = std::io::Cursor::new(input.as_bytes()); +//! +//! // you can either &mut borrow the reader, or let the Decoder take ownership. +//! // ff it takes ownership you can retrieve the inner reader later. +//! for msg in twitchchat::sync::Decoder::new(&mut reader) { +//! // this yields whether the message was valid or not +//! // this'll block until it can read a 'full' message (e.g. one delimited by `\r\n`). +//! +//! // notice its already owned here (denoted by the 'static lifetime) +//! let msg: twitchchat::irc::IrcMessage<'static> = msg.unwrap(); +//! } +//! ``` + +mod error; +pub use error::DecodeError; + +#[allow(clippy::module_inception)] +mod decoder; +pub use decoder::Decoder; + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +mod async_decoder; + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub use async_decoder::AsyncDecoder; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +mod stream_decoder; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub use stream_decoder::{ReadMessage, StreamDecoder}; diff --git a/twitchchat/src/decoder/stream_decoder.rs b/twitchchat/src/decoder/stream_decoder.rs new file mode 100644 index 0000000..0d94e28 --- /dev/null +++ b/twitchchat/src/decoder/stream_decoder.rs @@ -0,0 +1,167 @@ +use futures::{Stream, StreamExt}; +use std::{ + collections::VecDeque, + pin::Pin, + task::{Context, Poll}, +}; + +use super::DecodeError; +use crate::{irc::IrcMessage, wait_for::Event, IntoOwned as _}; + +/// This trait is used to read a `String` from a `Stream` item +pub trait ReadMessage: Sized { + /// Read a string from this item + fn read_string(self) -> Result; +} + +impl ReadMessage for Option +where + T: ReadMessage, +{ + fn read_string(self) -> Result { + self.map(ReadMessage::read_string) + .transpose()? + .ok_or_else(|| DecodeError::Eof) + } +} + +impl ReadMessage for Result +where + T: ReadMessage, + E: Into, +{ + fn read_string(self) -> Result { + self.map_err(Into::into).and_then(ReadMessage::read_string) + } +} + +/// A `Decoder` provides `read_message()` from a [`futures::Stream`] +pub struct StreamDecoder { + stream: IO, + buf: String, + pos: usize, + back_queue: VecDeque>, +} + +impl StreamDecoder +where + IO: Stream + Unpin, + ::Item: ReadMessage + Send + Sync, +{ + /// Create a `StreamDecoder` from a [`futures::Stream`]. + pub fn new(stream: IO) -> Self { + Self { + stream, + buf: String::new(), + pos: 0, + back_queue: VecDeque::new(), + } + } + + /// Read the next message. + /// + /// This returns a owned [`IrcMessage`]. + pub async fn read_message(&mut self) -> Result, DecodeError> { + if let Some(msg) = self.back_queue.pop_front() { + return Ok(msg); + } + if self.pos != 0 { + return self.parse_message(); + } + + self.buf.clear(); + + match self.stream.next().await { + Some(res) => { + let data = res.read_string()?; + self.buf.extend(Some(data)); + self.parse_message() + } + None => Err(DecodeError::Eof), + } + } + + // TODO this should respond to PINGs + /// Wait for a specific event. + /// + /// This returns the specific matched event and any missed messages read before this returns. + /// + /// You can use [Decoder::extend][extend] to feed these messages back into the decoder. + /// + /// [extend]: StreamDecoder::extend() + pub async fn wait_for( + &mut self, + event: Event, + ) -> Result<(IrcMessage<'static>, Vec>), DecodeError> { + let mut missed = vec![]; + loop { + let msg = self.read_message().await?; + if Event::from_raw(msg.get_command()) == event { + break Ok((msg.into_owned(), missed)); + } else { + missed.push(msg.into_owned()) + } + } + } + + fn parse_message(&mut self) -> Result, DecodeError> { + match self.buf[self.pos..].as_bytes() { + [.., b'\r', b'\n'] => {} + [.., b'\n'] => { + self.buf.pop(); + self.buf.push_str("\r\n"); + } + [..] => self.buf.push_str("\r\n"), + }; + + let (p, msg) = crate::irc::parse_one(&self.buf[self.pos..]) // + .map_err(DecodeError::ParseError)?; + + self.pos = if p > 0 { self.pos + p } else { 0 }; + + Ok(msg.into_owned()) + } +} + +impl Extend> for StreamDecoder { + fn extend(&mut self, iter: T) + where + T: IntoIterator>, + { + self.back_queue.extend(iter) + } +} + +impl std::fmt::Debug for StreamDecoder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamDecoder").finish() + } +} + +impl Stream for StreamDecoder +where + IO: Stream + Unpin, + ::Item: ReadMessage + Send + Sync, +{ + type Item = Result, DecodeError>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + use std::future::Future as _; + + let this = self.get_mut(); + let fut = this.read_message(); + futures_lite::pin!(fut); + + let res = match futures_lite::ready!(fut.poll(cx)) { + Err(DecodeError::Eof) => None, + Err(DecodeError::Io(err)) if crate::util::is_blocking_error(&err) => { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Ok(msg) => Some(Ok(msg.into_owned())), + Err(err) => Some(Err(err)), + }; + + Poll::Ready(res) + } +} diff --git a/src/encodable.rs b/twitchchat/src/encodable.rs similarity index 50% rename from src/encodable.rs rename to twitchchat/src/encodable.rs index c1b6514..f593c97 100644 --- a/src/encodable.rs +++ b/twitchchat/src/encodable.rs @@ -4,40 +4,29 @@ use std::{ sync::Arc, }; -/// A trait to allow writing messags to any [std::io::Write] implementation +/// A trait to allow writing messags to any [`std::io::Write`] implementation pub trait Encodable { - /// Encode this message to the provided [std::io::Write] implementation - fn encode(&self, buf: &mut W) -> IoResult<()> - where - W: Write + ?Sized; + /// Encode this message to the provided [`std::io::Write`] implementation + fn encode(&self, buf: &mut dyn Write) -> IoResult<()>; } impl Encodable for &T where T: Encodable + ?Sized, { - fn encode(&self, buf: &mut W) -> IoResult<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> IoResult<()> { <_ as Encodable>::encode(*self, buf) } } impl Encodable for str { - fn encode(&self, buf: &mut W) -> IoResult<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> IoResult<()> { buf.write_all(self.as_bytes()) } } impl Encodable for String { - fn encode(&self, buf: &mut W) -> IoResult<()> - where - W: Write + ?Sized, - { + fn encode(&self, buf: &mut dyn Write) -> IoResult<()> { buf.write_all(self.as_bytes()) } } @@ -45,7 +34,7 @@ impl Encodable for String { macro_rules! encodable_byte_slice { ($($ty:ty)*) => { $(impl Encodable for $ty { - fn encode(&self, buf: &mut W) -> IoResult<()> { + fn encode(&self, buf: &mut dyn Write) -> IoResult<()> { buf.write_all(self) } })* diff --git a/src/encoder/async.rs b/twitchchat/src/encoder/async.rs similarity index 80% rename from src/encoder/async.rs rename to twitchchat/src/encoder/async.rs index b50cc5c..bafba3c 100644 --- a/src/encoder/async.rs +++ b/twitchchat/src/encoder/async.rs @@ -1,4 +1,3 @@ -cfg_async! { use std::{ io::{Result as IoResult, Write}, pin::Pin, @@ -7,6 +6,8 @@ use std::{ use futures_lite::{AsyncWrite, AsyncWriteExt}; +use crate::Encodable; + /// An asynchronous encoder. pub struct AsyncEncoder { pub(crate) writer: W, @@ -35,7 +36,7 @@ where impl Write for AsyncEncoder where - W: Write + Send + Sync, + W: Write + Send, { fn write(&mut self, buf: &[u8]) -> IoResult { self.writer.write(buf) @@ -48,12 +49,12 @@ where impl AsyncEncoder where - W: Write + Send + Sync, + W: Write + Send, { /// If the wrapped writer is synchronous, you can use this method to encode the message to it. pub fn encode_sync(&mut self, msg: M) -> IoResult<()> where - M: crate::Encodable + Send + Sync, + M: Encodable + Send, { msg.encode(&mut self.data)?; let data = &self.data[self.pos..]; @@ -69,9 +70,9 @@ where impl AsyncEncoder where - W: AsyncWrite + Send + Sync + Unpin, + W: AsyncWrite + Send + Unpin, { - /// Create a new Encoder over this [futures_lite::AsyncWrite] instance + /// Create a new Encoder over this [`futures::AsyncWrite`] instance pub fn new(writer: W) -> Self { Self { writer, @@ -80,7 +81,7 @@ where } } - /// Get the inner [futures_lite::AsyncWrite] instance out + /// Get the inner [`futures::AsyncWrite`] instance out /// /// This writes and flushes any buffered data before it consumes self. pub async fn into_inner(mut self) -> IoResult { @@ -94,12 +95,12 @@ where Ok(self.writer) } - /// Encode this [Encodable](crate::Encodable) message to the writer. + /// Encode this [`Encodable`] message to the writer. /// /// This flushes the data before returning pub async fn encode(&mut self, msg: M) -> IoResult<()> where - M: crate::Encodable + Send + Sync, + M: Encodable + Send, W: Unpin, { msg.encode(&mut self.data)?; @@ -112,11 +113,28 @@ where self.pos = 0; Ok(()) } + + // TODO make this stateful + + /// Join a `channel` + pub async fn join(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::join(channel)).await + } + + /// Leave a `channel` + pub async fn part(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::part(channel)).await + } + + /// Send a message to a channel + pub async fn privmsg(&mut self, channel: &str, data: &str) -> IoResult<()> { + self.encode(crate::commands::privmsg(channel, data)).await + } } impl AsyncWrite for AsyncEncoder where - W: AsyncWrite + Unpin + Send + Sync, + W: AsyncWrite + Unpin + Send, { fn poll_write( mut self: Pin<&mut Self>, @@ -166,4 +184,3 @@ mod tests { futures_lite::future::block_on(fut); } } -} diff --git a/src/encoder/mod.rs b/twitchchat/src/encoder/mod.rs similarity index 60% rename from src/encoder/mod.rs rename to twitchchat/src/encoder/mod.rs index 1bdf5ed..1fd7fee 100644 --- a/src/encoder/mod.rs +++ b/twitchchat/src/encoder/mod.rs @@ -1,4 +1,4 @@ -//! # Encoding utilies +//! # Encoding types and functions. //! //! ## Using the [Encodable](crate::Encodable) trait //! Many [commands](crate::commands) are provided that can be encoded to a writer. @@ -19,14 +19,14 @@ //! ``` //! //! ## Using an Encoder -//! This crate provides composable types (Writers/Encoders) which can be used with the [Encodable](crate::Encodable) trait. -//! The types come in both `Sync` and `Async` styles. +//! This crate provides composable types which can be used with the [Encodable](crate::Encodable) trait. +//! The types come in both ***sync*** and ***async*** styles. //! //! ``` //! use twitchchat::commands; //! //! let mut buf = vec![]; -//! let mut enc = twitchchat::Encoder::new(&mut buf); +//! let mut enc = twitchchat::sync::Encoder::new(&mut buf); //! enc.encode(commands::join("museun")).unwrap(); //! //! use std::io::Write as _; @@ -37,10 +37,21 @@ //! assert_eq!(string, "JOIN #museun\r\nits also a writer\r\n"); //! ``` -cfg_async! { - mod r#async; - pub use r#async::*; -} +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +mod r#async; + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub use r#async::AsyncEncoder; mod sync; -pub use sync::*; +pub use sync::Encoder; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +mod sink; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub use sink::SinkEncoder; diff --git a/twitchchat/src/encoder/sink.rs b/twitchchat/src/encoder/sink.rs new file mode 100644 index 0000000..96d761c --- /dev/null +++ b/twitchchat/src/encoder/sink.rs @@ -0,0 +1,88 @@ +use std::io::Result as IoResult; + +use futures::{Sink, SinkExt as _}; + +use crate::Encodable; + +/// A `Encoder` wraps a [`futures::Sink`] and provides a way to use [`Encodable`] with it. +/// +pub struct SinkEncoder { + sink: IO, + buf: Vec, + _marker: std::marker::PhantomData, +} + +impl SinkEncoder +where + IO: Sink + Unpin, + IO::Error: std::error::Error + Send + Sync + 'static, + M: From, +{ + /// Create a new `SinkEncoder` from an existing [`Sink`] + pub fn new(sink: IO) -> Self { + Self { + sink, + buf: Vec::new(), + _marker: std::marker::PhantomData, + } + } + + /// Encode this [`Encodable`] message to the writer. + pub async fn encode(&mut self, msg: impl Encodable) -> IoResult<()> { + use std::io::{Error, ErrorKind}; + fn read_str(d: &[u8]) -> IoResult<&str> { + std::str::from_utf8(d).map_err(|err| Error::new(ErrorKind::InvalidData, err)) + } + + self.buf.clear(); + msg.encode(&mut self.buf)?; + + macro_rules! send_it { + ($msg:expr) => { + self.sink + .send($msg.to_string().into()) + .await + .map_err(|err| Error::new(ErrorKind::Other, err)) + }; + } + + if !self.buf.ends_with(b"\n") { + send_it!(read_str(&self.buf)?)?; + return send_it!("\n"); + } + + let mut msg = &*self.buf; + while let Some(p) = msg + .iter() + .position(|&c| c == b'\n') + .filter(|&c| c < msg.len() && c != 0) + { + let (left, right) = msg.split_at(p + 1); + msg = right; + send_it!(read_str(left)?)?; + } + + Ok(()) + } + + /// Join a `channel` + pub async fn join(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::join(channel)).await + } + + /// Leave a `channel` + pub async fn part(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::part(channel)).await + } + + /// Send a message to a channel + pub async fn privmsg(&mut self, channel: &str, data: &str) -> IoResult<()> { + self.encode(crate::commands::privmsg(channel, data)).await + } +} + +impl std::fmt::Debug for SinkEncoder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SinkEncoder").finish() + } +} diff --git a/src/encoder/sync.rs b/twitchchat/src/encoder/sync.rs similarity index 75% rename from src/encoder/sync.rs rename to twitchchat/src/encoder/sync.rs index 53301a5..1775f5a 100644 --- a/src/encoder/sync.rs +++ b/twitchchat/src/encoder/sync.rs @@ -16,17 +16,17 @@ impl Encoder where W: Write, { - /// Create a new Encoder over this [std::io::Write] instance + /// Create a new Encoder over this [`std::io::Write`] instance pub fn new(writer: W) -> Self { Self { writer } } - /// Get the inner [std::io::Write] instance out + /// Get the inner [`std::io::Write`] instance out pub fn into_inner(self) -> W { self.writer } - /// Encode this [Encodable] message to the writer and flushes it. + /// Encode this [`Encodable`] message to the writer and flushes it. pub fn encode(&mut self, msg: M) -> IoResult<()> where M: Encodable, @@ -34,6 +34,21 @@ where msg.encode(&mut self.writer)?; self.writer.flush() } + + /// Join a `channel` + pub fn join(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::join(channel)) + } + + /// Leave a `channel` + pub fn part(&mut self, channel: &str) -> IoResult<()> { + self.encode(crate::commands::part(channel)) + } + + /// Send a message to a channel + pub fn privmsg(&mut self, channel: &str, data: &str) -> IoResult<()> { + self.encode(crate::commands::privmsg(channel, data)) + } } impl Clone for Encoder diff --git a/twitchchat/src/ext.rs b/twitchchat/src/ext.rs new file mode 100644 index 0000000..1608a3f --- /dev/null +++ b/twitchchat/src/ext.rs @@ -0,0 +1,53 @@ +use crate::{ + commands::{self, types::Reply}, + messages::Privmsg, + Encodable, +}; + +use std::io::{Error, ErrorKind, Write}; + +/// Extensions to the `Privmsg` message type +pub trait PrivmsgExt { + /// Reply to this message with `data` + fn reply(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()>; + + /// Send a message back to the channel this Privmsg came from + fn say(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()>; +} + +impl PrivmsgExt for W +where + W: Write + Sized, +{ + fn reply(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { + make_reply(msg, data)?.encode(self)?; + self.flush() + } + + fn say(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { + commands::privmsg(msg.channel(), data).encode(self)?; + self.flush() + } +} + +fn make_reply<'a>(msg: &'a Privmsg<'_>, data: &'a str) -> std::io::Result> { + Ok(commands::reply( + msg.channel(), + msg.tags().get("id").ok_or_else(|| { + Error::new(ErrorKind::PermissionDenied, "you must have `TAGS` enabled") + })?, + data, + )) +} + +#[cfg(feature = "writer")] +#[cfg_attr(docsrs, doc(cfg(feature = "writer")))] +impl PrivmsgExt for crate::writer::MpscWriter { + fn reply(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { + self.send(make_reply(msg, data)?) + } + + fn say(&mut self, msg: &Privmsg<'_>, data: &str) -> std::io::Result<()> { + self.send(commands::privmsg(msg.channel(), data)) + } +} diff --git a/twitchchat/src/handshake/asynchronous.rs b/twitchchat/src/handshake/asynchronous.rs new file mode 100644 index 0000000..c0961db --- /dev/null +++ b/twitchchat/src/handshake/asynchronous.rs @@ -0,0 +1,141 @@ +use crate::{ + asynchronous::{ + BoxedRead, BoxedWrite, DecodeError, Decoder, Encoder, HandshakeError, Identity, + }, + commands, + irc::IrcMessage, + twitch::UserConfig, + util::is_blocking_error, + IntoOwned as _, +}; + +use futures_lite::{ + io::{ReadHalf, WriteHalf}, + AsyncRead, AsyncWrite, +}; + +/// The initial connection handshake. +/// +/// ### Required features +/// This is available with `features = ["async"]` +/// +/// ### Usage +/// +/// * Open/create your IO object (usually your tcp/ws connection (or in-memory buffer), etc.). +/// * Make this type from the `AsyncRead + AsyncWrite` representation for that. +/// * If you only have an `Stream + Sink` look into this [`Handshake`][crate::stream::Handshake] instead. +/// * If you only have a `Read + Write` look into this [`Handshake`][crate::sync::Handshake] instead. +/// * When ready, call [`Handshake::wait_until_ready()`] +/// * When it returns `Ok`, use one of: +/// * [`Handshake::into_inner()`] +/// * [`Handshake::split()`] +/// * [`Handshake::split_boxed()`] +/// +pub struct Handshake { + inner: IO, +} + +impl Handshake +where + IO: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + /// Create a new Handshake from the provided `AsyncRead + AsyncWrite` + pub fn new(io: IO) -> Self { + Self { inner: io } + } + + /// This ***registers*** the provided user configuration with the connection. + /// + /// This blocks until Twitch is ready or an error is produced. + /// + /// Once ready, it returns a tuple of your determined [`Identity`] and any messages that were 'consumed' up until the identity was determined. + /// + /// Now, you can do one of three things: + /// * get the inner `AsyncRead+AsyncWrite` out with [`Handshake::into_inner()`] + /// * get a `Decoder`/`Encoder` pair with [`Handshake::split()`] + /// * get a typed-erased `Decoder`/`Encoder` pair with [`Handshake::split_boxed()`] + /// + /// You can use [`Decoder::extend()`][extend] with the _missed messages_ if you want to receive them after this call, but generally you can ignore them. + /// + /// # Errors + /// * [`HandshakeError::BadPass`] + /// * occurs when you provide an invalid OAuth token + /// * [`HandshakeError::ShouldReconnect`] + /// * occurs when the server restarted while you were connecting + /// * [`HandshakeError::InvalidCapability`] + /// * occurs when you provide an invalid capability + /// * [`HandshakeError::Encode`] + /// * occurs when one of the required commands could not be encoded to the provided writer + /// * [`HandshakeError::Decode`] + /// * occurs when a message could not be parsed (or read) from the provided reader + /// + /// [extend]: crate::asynchronous::Decoder::extend() + pub async fn wait_until_ready( + &mut self, + user_config: &UserConfig, + ) -> Result<(Identity, Vec>), HandshakeError> { + use crate::wait_for; + + let (read, write) = futures_lite::io::split(&mut self.inner); + let (mut read, mut write) = (Decoder::new(read), Encoder::new(write)); + + write.encode(commands::register(user_config)).await?; + + let mut missed_messages = Vec::new(); + let mut state = wait_for::ReadyState::new(user_config); + loop { + let msg = match read.read_message().await { + Err(DecodeError::Io(err)) if is_blocking_error(&err) => { + futures_lite::future::yield_now().await; + continue; + } + Err(err) => Err(err), + Ok(ok) => Ok(ok), + }?; + + use wait_for::StepState::*; + match wait_for::check_message(&msg, &mut state) + .map_err(HandshakeError::from_check_err)? + { + Skip => continue, + Continue => { + missed_messages.push(msg.into_owned()); + continue; + } + ShouldPong(token) => write.encode(commands::pong(&token)).await?, + Identity(identity) => return Ok((identity, missed_messages)), + } + } + } + + /// Consume the Handshake returning a `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split(self) -> (Decoder>, Encoder>) { + let (read, write) = futures_lite::io::split(self.inner); + (Decoder::new(read), Encoder::new(write)) + } + + /// Consume the Handshake returning a type erased `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split_boxed(self) -> (Decoder, Encoder) { + let (read, write) = futures_lite::io::split(self.inner); + let read: BoxedRead = Box::new(read); + let write: BoxedWrite = Box::new(write); + (Decoder::new(read), Encoder::new(write)) + } + + /// Consume the Handshake returning the inner IO + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn into_inner(self) -> IO { + self.inner + } +} + +impl std::fmt::Debug for Handshake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AsyncHandshake").finish() + } +} diff --git a/twitchchat/src/handshake/error.rs b/twitchchat/src/handshake/error.rs new file mode 100644 index 0000000..89b14c1 --- /dev/null +++ b/twitchchat/src/handshake/error.rs @@ -0,0 +1,70 @@ +use crate::{decoder::DecodeError, wait_for::CheckError}; + +/// An error returned by one of the various `wait_until_ready()` methods +#[derive(Debug)] +#[non_exhaustive] +pub enum HandshakeError { + /// An invalid OAuth token was provided + BadPass, + /// The server restarted, you should reconnect + ShouldReconnect, + /// You provided an invalid capability + InvalidCapability(String), + /// Could not encode the required messages to the writer + Encode(std::io::Error), + /// Could not decode messages from the reader + Decode(DecodeError), +} + +impl HandshakeError { + pub(super) fn from_check_err(err: CheckError) -> Self { + match err { + CheckError::InvalidCap(cap) => Self::InvalidCapability(cap), + CheckError::BadPass => Self::BadPass, + CheckError::ShouldReconnect => Self::ShouldReconnect, + } + } +} + +impl From for HandshakeError { + fn from(err: std::io::Error) -> Self { + Self::Encode(err) + } +} + +impl From for HandshakeError { + fn from(err: DecodeError) -> Self { + Self::Decode(err) + } +} + +impl std::fmt::Display for HandshakeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BadPass => f.write_str("an invalid oauth token was provided"), + + Self::ShouldReconnect => { + f.write_str("you should reconnect. Twitch restarted the server") + } + + Self::InvalidCapability(cap) => write!( + f, + "an invalid capability was provided '{}'", + cap.escape_debug() + ), + + Self::Encode(err) => err.fmt(f), + Self::Decode(err) => err.fmt(f), + } + } +} + +impl std::error::Error for HandshakeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(err) => Some(err), + Self::Encode(err) => Some(err), + _ => None, + } + } +} diff --git a/twitchchat/src/handshake/mod.rs b/twitchchat/src/handshake/mod.rs new file mode 100644 index 0000000..2dfb6d5 --- /dev/null +++ b/twitchchat/src/handshake/mod.rs @@ -0,0 +1,12 @@ +mod error; +pub use error::HandshakeError; + +pub mod sync; + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub mod asynchronous; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub mod stream; diff --git a/twitchchat/src/handshake/stream.rs b/twitchchat/src/handshake/stream.rs new file mode 100644 index 0000000..094c695 --- /dev/null +++ b/twitchchat/src/handshake/stream.rs @@ -0,0 +1,161 @@ +use crate::{ + commands, + irc::IrcMessage, + stream::{self, BoxedSink, BoxedStream, HandshakeError, Identity, ReadMessage}, + twitch::UserConfig, + IntoOwned as _, +}; + +use futures::{Sink, Stream, StreamExt as _}; + +/// Type alias for a _split_ [`StreamDecoder`](stream::StreamDecoder) +pub type Decoder = stream::StreamDecoder>; +/// Type alias for a _split_ [`SinkEncoder`](stream::SinkEncoder) +pub type Encoder = stream::SinkEncoder, M>; + +/// Boxed, _split_ [`StreamDecoder`](stream::StreamDecoder) -- this erases some of the more tedious types +pub type BoxedDecoder = stream::StreamDecoder::Item>>; +/// Boxed, _split_ [`SinkEncoder`](stream::SinkEncoder) -- this erases some of the more tedious types +pub type BoxedEncoder = stream::SinkEncoder>::Error>, M>; + +/// The initial connection handshake. +/// +/// ### Required features +/// This is available with `features = ["sink_stream"]` +/// +/// ### Usage +/// +/// * Open/create your IO object (usually your tcp/ws connection (or in-memory buffer), etc.). +/// * Make this type from the `Stream + Sink` representation for that. +/// * If you only have an `AsyncRead + AsyncWrite` look into this [`Handshake`][crate::asynchronous::Handshake] instead. +/// * If you only have a `Read + Write` look into this [`Handshake`][crate::sync::Handshake] instead. +/// * When ready, call [`Handshake::wait_until_ready()`] +/// * When it returns `Ok`, use one of: +/// * [`Handshake::into_inner()`] +/// * [`Handshake::split()`] +/// * [`Handshake::split_boxed()`] +/// +pub struct Handshake { + inner: IO, + _marker: std::marker::PhantomData, +} + +impl Handshake +where + IO: Stream + Sink + Send + Unpin + 'static, + ::Item: ReadMessage + Send + Sync + 'static, + >::Error: std::error::Error + Send + Sync + 'static, + M: From + Send + Sync + 'static, +{ + /// Create a new Handshake from the provided `Sink + Stream` + /// + /// --- + /// + /// The type soup above looks a bit complex but to simply explain it: + /// * the `IO` type must implement both `Sink` and `Stream` + /// * the Stream's `Item` must implement the [`ReadMessage`] trait. generally you can do: + /// * [`StreamExt::map`][map] to produce a `String` to satisfy this. + /// * [`TryStream::map_ok`][map_ok] to produce a `std::io::Result` to satisfy this. + /// * the Sink's `Error` must implement the `std::error::Error` convention + /// * the Sink's generic parameter has to be convertable to a `String` via `Into` + /// + /// [map]: https://docs.rs/futures/0.3.7/futures/stream/trait.StreamExt.html#method.map + /// [map_ok]: https://docs.rs/futures/0.3.7/futures/stream/trait.TryStreamExt.html#method.map_ok + pub fn new(io: IO) -> Self { + Self { + inner: io, + _marker: std::marker::PhantomData, + } + } + + /// This ***registers*** the provided user configuration with the connection. + /// + /// This blocks until Twitch is ready or an error is produced. + /// + /// Once ready, it returns a tuple of your determined [`Identity`] and any messages that were 'consumed' up until the identity was determined. + /// + /// Now, you can do one of three things: + /// * get the inner `Sink+Stream` out with [`Handshake::into_inner()`] + /// * get a `Decoder`/`Encoder` pair with [`Handshake::split()`] + /// * get a typed-erased `Decoder`/`Encoder` pair with [`Handshake::split_boxed()`] + /// + /// You can use [`Decoder::extend()`][extend] with the _missed messages_ if you want to receive them after this call, but generally you can ignore them. + /// + /// # Errors + /// * [`HandshakeError::BadPass`] + /// * occurs when you provide an invalid OAuth token + /// * [`HandshakeError::ShouldReconnect`] + /// * occurs when the server restarted while you were connecting + /// * [`HandshakeError::InvalidCapability`] + /// * occurs when you provide an invalid capability + /// * [`HandshakeError::Encode`] + /// * occurs when one of the required commands could not be encoded to the provided writer + /// * [`HandshakeError::Decode`] + /// * occurs when a message could not be parsed (or read) from the provided reader + /// + /// [extend]: crate::stream::StreamDecoder::extend() + pub async fn wait_until_ready( + &mut self, + user_config: &UserConfig, + ) -> Result<(Identity, Vec>), HandshakeError> { + use crate::wait_for; + + let (write, read) = (&mut self.inner).split(); + let (mut read, mut write) = (stream::Decoder::new(read), stream::Encoder::new(write)); + + write.encode(commands::register(user_config)).await?; + + let mut missed_messages = Vec::new(); + let mut state = wait_for::ReadyState::new(user_config); + loop { + let msg = read.read_message().await?; + + use wait_for::StepState::*; + match wait_for::check_message(&msg, &mut state) + .map_err(HandshakeError::from_check_err)? + { + Skip => continue, + Continue => { + missed_messages.push(msg.into_owned()); + continue; + } + ShouldPong(token) => write.encode(commands::pong(&token)).await?, + Identity(identity) => break Ok((identity, missed_messages)), + } + } + } + + /// Consume the Handshake returning a `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split(self) -> (Decoder, Encoder) { + let (write, read) = self.inner.split(); + (stream::Decoder::new(read), stream::Encoder::new(write)) + } + + /// Consume the Handshake returning a type erased `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split_boxed(self) -> (BoxedDecoder, BoxedEncoder) { + let (write, read) = self.inner.split(); + let read: BoxedStream<::Item> = Box::new(read); + let write: BoxedSink>::Error> = Box::new(write); + ( + stream::StreamDecoder::new(read), + stream::SinkEncoder::new(write), + ) + } + + /// Consume the Handshake returning the inner IO + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn into_inner(self) -> IO { + self.inner + } +} + +impl std::fmt::Debug for Handshake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HandshakeStream").finish() + } +} diff --git a/twitchchat/src/handshake/sync.rs b/twitchchat/src/handshake/sync.rs new file mode 100644 index 0000000..0959e4f --- /dev/null +++ b/twitchchat/src/handshake/sync.rs @@ -0,0 +1,134 @@ +use crate::{ + commands, + io::{BoxedRead, BoxedWrite, ReadHalf, WriteHalf}, + irc::IrcMessage, + sync::{DecodeError, Decoder, Encoder, HandshakeError, Identity}, + twitch::UserConfig, + util::is_blocking_error, + IntoOwned as _, +}; + +use std::io::{Read, Write}; + +/// The initial connection handshake. +/// +/// ### Usage +/// +/// * Open/create your IO object (usually your tcp/ws connection (or in-memory buffer), etc.). +/// * Make this type from the `Read + Write` representation for that. +/// * If you only have an `Stream + Sink` look into this [`Handshake`][crate::stream::Handshake] instead. +/// * If you only have a `AsyncRead + AyncWrite` look into this [`Handshake`][crate::asynchronous::Handshake] instead. +/// * When ready, call [`Handshake::wait_until_ready()`] +/// * When it returns `Ok`, use one of: +/// * [`Handshake::into_inner()`] +/// * [`Handshake::split()`] +/// * [`Handshake::split_boxed()`] +/// +pub struct Handshake { + inner: IO, +} + +impl Handshake +where + IO: Read + Write + Send + 'static, +{ + /// Create a new Handshake from the provided `Read + Write` + pub fn new(io: IO) -> Self { + Self { inner: io } + } + + /// This ***registers*** the provided user configuration with the connection. + /// + /// This blocks until Twitch is ready or an error is produced. + /// + /// Once ready, it returns a tuple of your determined [`Identity`] and any messages that were 'consumed' up until the identity was determined. + /// + /// Now, you can do one of three things: + /// * get the inner `AsyncRead+AsyncWrite` out with [`Handshake::into_inner()`] + /// * get a `Decoder`/`Encoder` pair with [`Handshake::split()`] + /// * get a typed-erased `Decoder`/`Encoder` pair with [`Handshake::split_boxed()`] + /// + /// You can use [`Decoder::extend()`][extend] with the _missed messages_ if you want to receive them after this call, but generally you can ignore them. + /// + /// # Errors + /// * [`HandshakeError::BadPass`] + /// * occurs when you provide an invalid OAuth token + /// * [`HandshakeError::ShouldReconnect`] + /// * occurs when the server restarted while you were connecting + /// * [`HandshakeError::InvalidCapability`] + /// * occurs when you provide an invalid capability + /// * [`HandshakeError::Encode`] + /// * occurs when one of the required commands could not be encoded to the provided writer + /// * [`HandshakeError::Decode`] + /// * occurs when a message could not be parsed (or read) from the provided reader + /// + /// [extend]: crate::sync::Decoder::extend() + pub fn wait_until_ready( + &mut self, + user_config: &UserConfig, + ) -> Result<(Identity, Vec>), HandshakeError> { + use crate::wait_for; + + let (read, write) = crate::io::split(&mut self.inner); + let (mut read, mut write) = (Decoder::new(read), Encoder::new(write)); + + write.encode(commands::register(user_config))?; + + let mut missed_messages = Vec::new(); + let mut state = wait_for::ReadyState::new(user_config); + loop { + let msg = match read.read_message() { + Err(DecodeError::Io(err)) if is_blocking_error(&err) => continue, + Err(err) => Err(err), + Ok(ok) => Ok(ok), + }?; + + use wait_for::StepState::*; + match wait_for::check_message(&msg, &mut state) + .map_err(HandshakeError::from_check_err)? + { + Skip => continue, + Continue => { + missed_messages.push(msg.into_owned()); + continue; + } + ShouldPong(token) => write.encode(commands::pong(&token))?, + Identity(identity) => return Ok((identity, missed_messages)), + } + } + } + + /// Consume the Handshake returning a `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split(self) -> (Decoder>, Encoder>) { + let (read, write) = crate::io::split(self.inner); + (Decoder::new(read), Encoder::new(write)) + } + + /// Consume the Handshake returning a type erased `Decoder`/`Encoder` pair + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn split_boxed(self) -> (Decoder, Encoder) + where + IO: Send + 'static, + { + let (read, write) = crate::io::split(self.inner); + let read: BoxedRead = Box::new(read); + let write: BoxedWrite = Box::new(write); + (Decoder::new(read), Encoder::new(write)) + } + + /// Consume the Handshake returning the inner IO + /// + /// You should generally only call this after [`Handshake::wait_until_ready()`] has returned Ok + pub fn into_inner(self) -> IO { + self.inner + } +} + +impl std::fmt::Debug for Handshake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Handshake").finish() + } +} diff --git a/src/runner/identity.rs b/twitchchat/src/identity.rs similarity index 60% rename from src/runner/identity.rs rename to twitchchat/src/identity.rs index 870ecd3..17af568 100644 --- a/src/runner/identity.rs +++ b/twitchchat/src/identity.rs @@ -1,14 +1,16 @@ -use crate::{runner::Capabilities, twitch::Color}; +use crate::twitch::Color; +use std::collections::HashSet; /// Your identity on Twitch. /// /// Currently this is only updated when you connect. #[derive(Debug, Clone)] +#[non_exhaustive] pub enum Identity { /// An anonymous identity. Anonymous { /// The capabilities you'll have - caps: Capabilities, + caps: YourCapabilities, }, /// A basic identity. @@ -16,7 +18,7 @@ pub enum Identity { /// Your username name: String, /// The capabilities you'll have - caps: Capabilities, + caps: YourCapabilities, }, /// A full identity @@ -31,10 +33,10 @@ pub enum Identity { user_id: i64, /// Your display name, if set display_name: Option, - /// You display color, if set + /// Your display color, if set color: Color, /// The capabilities you'll have - caps: Capabilities, + caps: YourCapabilities, }, } @@ -50,3 +52,17 @@ impl Identity { } } } + +/// Capabilities that Twitch acknowledged. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct YourCapabilities { + /// You have the [membership](https://dev.twitch.tv/docs/irc/membership) capability + pub membership: bool, + /// You have the [commands](https://dev.twitch.tv/docs/irc/commands) capability + pub commands: bool, + /// You have the [tags](https://dev.twitch.tv/docs/irc/tags) capability + pub tags: bool, + /// A set of unknown capabilities that Twitch acknowledged + pub unknown: HashSet, +} diff --git a/twitchchat/src/io.rs b/twitchchat/src/io.rs new file mode 100644 index 0000000..e99b1ad --- /dev/null +++ b/twitchchat/src/io.rs @@ -0,0 +1,53 @@ +//! This is a collection of type aliases to make naming things easier +//! +//! Additionally, two concrete types are provided for when using `std::io` types. +//! +use std::{ + io::{Read, Write}, + sync::{Arc, Mutex}, +}; + +/// A boxed [`std::io::Read`] trait object +pub type BoxedRead = Box; +/// A boxed [`std::io::Write`] trait object +pub type BoxedWrite = Box; + +#[derive(Debug, Clone)] +/// Read half of an `std::io::Read + std::io::Write` implementation +pub struct ReadHalf(Arc>); + +impl Read for ReadHalf +where + T: Read, +{ + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.lock().unwrap().read(buf) + } +} + +#[derive(Debug, Clone)] +/// Write half of an `std::io::Read + std::io::Write` implementation +pub struct WriteHalf(Arc>); + +impl Write for WriteHalf +where + T: Write, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.lock().unwrap().flush() + } +} + +/// Splits this IO object into `Read` and `Write` halves +#[allow(dead_code)] +pub(crate) fn split(io: IO) -> (ReadHalf, WriteHalf) +where + IO: Read + Write, +{ + let this = Arc::new(Mutex::new(io)); + (ReadHalf(this.clone()), WriteHalf(this)) +} diff --git a/src/irc.rs b/twitchchat/src/irc.rs similarity index 96% rename from src/irc.rs rename to twitchchat/src/irc.rs index df7282f..58bb933 100644 --- a/src/irc.rs +++ b/twitchchat/src/irc.rs @@ -12,7 +12,7 @@ //! With the parsed type, you can further refine it into specific Twitch-oriented messages. //! //! ``` -//! use twitchchat::{IrcMessage, MessageError}; +//! use twitchchat::irc::{IrcMessage, MessageError}; //! // a raw message from the server //! let input = "@key1=val;key2=true :user!user@user PRIVMSG #some_channel :\x01ACTION hello world\x01\r\n"; //! @@ -76,7 +76,7 @@ pub use parser::IrcParserIter; /// Parses a string and returns an iterator over the `IrcMessages` in it. /// /// This borrows from the input string. -pub fn parse(input: &str) -> IrcParserIter<'_> { +pub const fn parse(input: &str) -> IrcParserIter<'_> { IrcParserIter::new(input) } diff --git a/src/irc/error.rs b/twitchchat/src/irc/error.rs similarity index 80% rename from src/irc/error.rs rename to twitchchat/src/irc/error.rs index 1664856..c721308 100644 --- a/src/irc/error.rs +++ b/twitchchat/src/irc/error.rs @@ -36,6 +36,20 @@ pub enum MessageError { error: Box, }, + /// An empty key in the tags was provided + MissingTagKey( + /// Tag pair index this key was expected to be at + usize, + ), + + /// A value wasn't provided for a tag pair + /// + /// This usually means the tag was 'key;' and not 'key=val'. 'key=' is allowed. + MissingTagValue( + /// Tag pair index this key was expected to be at + usize, + ), + /// An incomplete message was provided IncompleteMessage { /// At index `pos` @@ -63,6 +77,8 @@ impl std::fmt::Display for MessageError { Self::ExpectedData => write!(f, "expected a data segment in the message"), Self::ExpectedTag { name } => write!(f, "expected tag '{}'", name), Self::CannotParseTag { name, error } => write!(f, "cannot parse '{}': {}", name, error), + Self::MissingTagKey(index) => write!(f, "missing tag key at pair index: {}", index), + Self::MissingTagValue(index) => write!(f, "missing tag value at pair index: {}", index), Self::IncompleteMessage { pos } => write!(f, "incomplete message starting at: {}", pos), Self::EmptyMessage => write!(f, "no message could be parsed"), Self::Custom { error } => write!(f, "custom error: {}", error), diff --git a/src/irc/message.rs b/twitchchat/src/irc/message.rs similarity index 100% rename from src/irc/message.rs rename to twitchchat/src/irc/message.rs diff --git a/src/irc/parser.rs b/twitchchat/src/irc/parser.rs similarity index 100% rename from src/irc/parser.rs rename to twitchchat/src/irc/parser.rs diff --git a/src/irc/prefix.rs b/twitchchat/src/irc/prefix.rs similarity index 85% rename from src/irc/prefix.rs rename to twitchchat/src/irc/prefix.rs index 6a722b9..404a9e6 100644 --- a/src/irc/prefix.rs +++ b/twitchchat/src/irc/prefix.rs @@ -14,12 +14,12 @@ impl<'a> std::fmt::Debug for Prefix<'a> { impl<'a> Prefix<'a> { /// Was this message from the server? - pub fn is_server(&self) -> bool { + pub const fn is_server(&self) -> bool { !self.is_user() } /// Was this message from a user? - pub fn is_user(&self) -> bool { + pub const fn is_user(&self) -> bool { matches!(self.index, PrefixIndex::User{ .. }) } @@ -51,17 +51,17 @@ pub enum PrefixIndex { impl PrefixIndex { /// Was this message from the server? - pub fn is_server(&self) -> bool { + pub const fn is_server(&self) -> bool { !self.is_nick() } /// Was this message from a user? - pub fn is_nick(&self) -> bool { + pub const fn is_nick(&self) -> bool { matches!(self, Self::User{ .. }) } /// Get the index of the nickname - pub fn nick_index(self) -> Option { + pub const fn nick_index(self) -> Option { match self { Self::User { nick } => Some(nick), Self::Server { .. } => None, @@ -69,7 +69,7 @@ impl PrefixIndex { } /// Get the index of the hostname - pub fn host_index(self) -> Option { + pub const fn host_index(self) -> Option { match self { Self::Server { host } => Some(host), Self::User { .. } => None, @@ -77,7 +77,7 @@ impl PrefixIndex { } /// Consumes this returning the index - pub fn as_index(self) -> MaybeOwnedIndex { + pub const fn as_index(self) -> MaybeOwnedIndex { match self { Self::User { nick } => nick, Self::Server { host } => host, diff --git a/twitchchat/src/irc/tag_indices.rs b/twitchchat/src/irc/tag_indices.rs new file mode 100644 index 0000000..a16300f --- /dev/null +++ b/twitchchat/src/irc/tag_indices.rs @@ -0,0 +1,90 @@ +use std::borrow::Cow; + +use crate::{irc::MessageError, maybe_owned::MaybeOwned, IntoOwned}; + +/// Pre-computed tag indices +/// +/// This type is only exposed for those wanting to extend/make custom types. +#[derive(Default, Clone, PartialEq)] +pub struct TagIndices { + // NOTE this is a hack to keep the semver stable, in v0.15 this'll go back to being borrowed. + pub(super) map: Box<[(Cow<'static, str>, Cow<'static, str>)]>, +} + +impl std::fmt::Debug for TagIndices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map() + .entries(self.map.iter().map(|(k, v)| (k, v))) + .finish() + } +} + +impl TagIndices { + /// Build indices from this tags fragment + /// + /// The fragment should be in the form of `'@k1=v2;k2=v2'` + pub fn build_indices(input: &str) -> Result { + if !input.starts_with('@') { + return Ok(Self::default()); + } + + input[1..] + .split_terminator(';') + .enumerate() + .map(|(pos, input)| { + use MessageError::{MissingTagKey, MissingTagValue}; + let expect = |s: Option<&str>, err: fn(usize) -> MessageError| { + s.map(ToString::to_string) + .map(Cow::from) + .ok_or_else(|| err(pos)) + }; + + let mut iter = input.split('='); + let key = expect(iter.next().filter(|s| !s.is_empty()), MissingTagKey)?; + let value = expect(iter.next(), MissingTagValue)?; + Ok((key, value)) + }) + .collect::>() + .map(|map| Self { map }) + } + + /// Get the number of parsed tags + pub fn len(&self) -> usize { + self.map.len() + } + + /// Checks whether any tags were parsed + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + // NOTE: this isn't public because they don't verify 'data' is the same as the built-indices data + pub(crate) fn get_unescaped<'a>(&'a self, key: &str) -> Option> { + self.get(key).map(crate::test::unescape_str) + } + + // NOTE: this isn't public because they don't verify 'data' is the same as the built-indices data + pub(crate) fn get<'a>(&'a self, key: &str) -> Option<&'a str> { + let key = crate::test::escape_str(key); + self.map + .iter() + .find_map(|(k, v)| if &key == k { Some(&**v) } else { None }) + } +} + +impl IntoOwned<'static> for TagIndices { + type Output = Self; + fn into_owned(self) -> Self::Output { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn utf8_tags() { + let input = "@id=86293428;login=yuebing233;display_name=月饼;foo=bar"; + TagIndices::build_indices(input).unwrap(); + } +} diff --git a/src/irc/tags.rs b/twitchchat/src/irc/tags.rs similarity index 94% rename from src/irc/tags.rs rename to twitchchat/src/irc/tags.rs index ffaa756..9280101 100644 --- a/src/irc/tags.rs +++ b/twitchchat/src/irc/tags.rs @@ -47,7 +47,7 @@ impl<'a> Tags<'a> { where K: ?Sized + Borrow, { - self.indices.get_unescaped(key.borrow(), &*self.data) + self.indices.get_unescaped(key.borrow()) } /// Tries to get this `key` @@ -58,7 +58,7 @@ impl<'a> Tags<'a> { where K: ?Sized + Borrow, { - self.indices.get(key.borrow(), &*self.data) + self.indices.get(key.borrow()) } /** Tries to get the tag as a parsable [std::str::FromStr] type. @@ -68,7 +68,7 @@ impl<'a> Tags<'a> { ```rust # use twitchchat::{irc::{TagIndices, Tags}, maybe_owned::MaybeOwned}; let input: MaybeOwned<'_> = "@foo=42;color=#1E90FF".into(); - let indices = TagIndices::build_indices(&*input); + let indices = TagIndices::build_indices(&*input).unwrap(); let tags = Tags::from_data_indices(&input, &indices); // 'foo' can be parsed as a usize @@ -111,7 +111,7 @@ impl<'a> Tags<'a> { # use twitchchat::irc::{TagIndices, Tags}; # use twitchchat::maybe_owned::MaybeOwned; let input: MaybeOwned<'_> = "@foo=42;ok=true;nope=false;test=1;not_test=0".into(); - let indices = TagIndices::build_indices(&*input); + let indices = TagIndices::build_indices(&*input).unwrap(); let tags = Tags::from_data_indices(&input, &indices); // key 'foo' is not a bool @@ -188,8 +188,8 @@ impl<'a> Iterator for TagsIter<'a> { let pos = self.pos; self.pos += 1; - let (k, v) = self.inner.indices.map.get(pos)?; - Some((&self.inner.data[k], &self.inner.data[v])) + let (k, v) = &self.inner.indices.map.get(pos)?; + Some((&**k, &**v)) } } @@ -312,7 +312,7 @@ mod tests { fn escaped_tag() { let s = escape_str(r"@hello;world=abc\ndef"); let data = MaybeOwned::Borrowed(&*s); - let indices = TagIndices::build_indices(&*data); + let indices = TagIndices::build_indices(&*data).unwrap(); let tags = Tags::from_data_indices(&data, &indices); assert_eq!(tags.get_unescaped("hello;world").unwrap(), r"abc\ndef"); @@ -322,7 +322,7 @@ mod tests { #[test] fn invalid_input_missing_leading_at() { let data = MaybeOwned::Borrowed("foo=bar;baz=quux"); - let indices = TagIndices::build_indices(&*data); + let indices = TagIndices::build_indices(&*data).unwrap(); let tags = Tags::from_data_indices(&data, &indices); assert!(tags.is_empty()); @@ -334,7 +334,7 @@ mod tests { for input in inputs { let data = MaybeOwned::Borrowed(*input); - let indices = TagIndices::build_indices(&*data); + let indices = TagIndices::build_indices(&*data).unwrap(); let tags = Tags::from_data_indices(&data, &indices); assert!(tags.is_empty()); @@ -344,7 +344,7 @@ mod tests { #[test] fn get_parsed() { let input = MaybeOwned::Borrowed("@foo=42;badges=broadcaster/1,subscriber/6"); - let indices = TagIndices::build_indices(&*input); + let indices = TagIndices::build_indices(&*input).unwrap(); let tags = Tags::from_data_indices(&input, &indices); assert_eq!(tags.get_parsed::<_, usize>("foo").unwrap(), 42); @@ -379,7 +379,7 @@ mod tests { #[test] fn get_bool() { let input = MaybeOwned::Borrowed("@foo=42;ok=true;nope=false"); - let indices = TagIndices::build_indices(&*input); + let indices = TagIndices::build_indices(&*input).unwrap(); let tags = Tags::from_data_indices(&input, &indices); assert!(!tags.get_as_bool("foo")); @@ -398,7 +398,7 @@ mod tests { for input in inputs { let data = MaybeOwned::Borrowed(*input); - let indices = TagIndices::build_indices(&*data); + let indices = TagIndices::build_indices(&*data).unwrap(); let tags = Tags::from_data_indices(&data, &indices); assert_eq!(tags.get("foo").unwrap(), "bar"); @@ -418,7 +418,7 @@ mod tests { for input in inputs { let data = MaybeOwned::Borrowed(*input); - let indices = TagIndices::build_indices(&*data); + let indices = TagIndices::build_indices(&*data).unwrap(); let tags = Tags::from_data_indices(&data, &indices); let len = tags.into_iter().count(); @@ -477,7 +477,7 @@ mod tests { ]; let input = MaybeOwned::Borrowed(input); - let indices = TagIndices::build_indices(&*input); + let indices = TagIndices::build_indices(&*input).unwrap(); let tags = Tags::from_data_indices(&input, &indices); diff --git a/twitchchat/src/lib.rs b/twitchchat/src/lib.rs new file mode 100644 index 0000000..70a9bef --- /dev/null +++ b/twitchchat/src/lib.rs @@ -0,0 +1,175 @@ +#![allow( + clippy::missing_const_for_fn, + clippy::redundant_pub_crate, + clippy::use_self +)] +#![deny( + deprecated_in_future, + exported_private_dependencies, + future_incompatible, + missing_copy_implementations, + missing_crate_level_docs, + missing_debug_implementations, + missing_docs, // TODO re-enable this + private_in_public, + rust_2018_compatibility, + // rust_2018_idioms, // this complains about elided lifetimes. + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unstable_features, + unused_import_braces, + unused_qualifications +)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(docsrs, feature(doc_alias))] +#![cfg_attr(docsrs, feature(broken_intra_doc_links))] +/*! + +This crate provides a way to interface with [Twitch](https://dev.twitch.tv/docs/irc)'s chat (via IRC). + +Along with the messages as Rust types, it provides methods for sending messages. +--- + +By default, this crate depends on zero external crates -- but it makes it rather limited in scope. + +This allows parsing, and decoding/encoding to standard trait types (`std::io::{Read, Write}`). + +```toml +twitchchat = { version = "0.15", features = ["async", "writer"] } +``` + +### Available features: + +| feature | effect | +| ------------- | ------------------------------------------------------------------------------ | +| `async` | provides the [asynchronous] module (and generally all of the `async` functions | +| `sink_stream` | provides the [stream] module (for use with `Sink+Stream` | +| `writer` | this enables the [`writer::MpscWriter`] | + +### Connectors: + +In version `0.14` a `Connector` trait was provided with some common implementations. This has been moved out into separate crates. + +* [twitchchat_async_io][twitchchat_async_io] +* [twitchchat_async_net][twitchchat_async_net] +* [twitchchat_async_std][twitchchat_async_std] +* [twitchchat_smol][twitchchat_smol] +* [twitchchat_tokio][twitchchat_tokio] +* [twitchchat_tokio02][twitchchat_tokio02] + +--- + +### Useful modules for decoding/parsing/encoding: + +For Twitch types: +* [twitch] +* [messages] +* [commands] +--- +For the 'irc' types underneath it all: +* [irc] +--- + +[twitchchat_async_io]: https://docs.rs/twitchchat_async_io/latest/twitchchat_async_io +[twitchchat_async_net]: https://docs.rs/twitchchat_async_net/latest/twitchchat_async_net +[twitchchat_async_std]: https://docs.rs/twitchchat_async_std/latest/twitchchat_async_std +[twitchchat_smol]: https://docs.rs/twitchchat_smol/latest/twitchchat_smol +[twitchchat_tokio]: https://docs.rs/twitchchat_tokio/latest/twitchchat_tokio +[twitchchat_tokio02]: https://docs.rs/twitchchat_tokio02/latest/twitchchat_tokio02 +*/ + +/// The Twitch IRC address for non-TLS connections +/// +/// `irc.chat.twitch.tv:6667` +pub const TWITCH_IRC_ADDRESS: &str = "irc.chat.twitch.tv:6667"; + +/// The Twitch IRC address for TLS connections +/// +/// `irc.chat.twitch.tv:6697` +pub const TWITCH_IRC_ADDRESS_TLS: &str = "irc.chat.twitch.tv:6697"; + +/// The Twitch WebSocket address for non-TLS connections +/// +/// `irc-ws.chat.twitch.tv:80` +pub const TWITCH_WS_ADDRESS: &str = "irc-ws.chat.twitch.tv:80"; + +/// The Twitch WebSocket address for TLS connections +/// +/// `irc-ws.chat.twitch.tv:443` +pub const TWITCH_WS_ADDRESS_TLS: &str = "irc-ws.chat.twitch.tv:443"; + +/// A TLS domain for Twitch's websocket +/// +/// `irc-ws.chat.twitch.tv` +pub const TWITCH_WS_TLS_DOMAIN: &str = "irc-ws.chat.twitch.tv"; + +/// A TLS domain for Twitch +/// +/// `irc.chat.twitch.tv` +pub const TWITCH_TLS_DOMAIN: &str = "irc.chat.twitch.tv"; + +/// An anonymous login. +pub const ANONYMOUS_LOGIN: (&str, &str) = (JUSTINFAN1234, JUSTINFAN1234); +pub(crate) const JUSTINFAN1234: &str = "justinfan1234"; + +#[macro_use] +#[allow(unused_macros)] +mod macros; + +#[cfg(feature = "serde")] +mod serde; + +pub mod maybe_owned; + +pub mod commands; +pub mod messages; + +pub mod irc; +pub mod twitch; + +pub mod test; + +pub use encodable::Encodable; +pub use ext::PrivmsgExt; +#[doc(inline)] +pub use irc::{FromIrcMessage, IntoIrcMessage}; +pub use maybe_owned::IntoOwned; +pub use validator::Validator; + +use maybe_owned::{MaybeOwned, MaybeOwnedIndex}; + +mod encodable; +mod identity; +mod validator; + +mod ext; +mod util; + +mod decoder; +mod encoder; + +mod handshake; +mod io; + +mod timeout; +mod wait_for; + +pub mod sync; + +#[cfg(feature = "writer")] +#[cfg_attr(docsrs, doc(cfg(feature = "writer")))] +pub mod writer; + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub mod asynchronous; + +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub mod stream; + +mod make_sure_features_flags_are_correct { + #[cfg(all(feature = "sink_stream", not(feature = "async")))] + compile_error!("`async` must be enabled when `sink_stream` is enabled"); +} diff --git a/src/macros.rs b/twitchchat/src/macros.rs similarity index 91% rename from src/macros.rs rename to twitchchat/src/macros.rs index dac5b7d..5226fa0 100644 --- a/src/macros.rs +++ b/twitchchat/src/macros.rs @@ -9,10 +9,13 @@ macro_rules! into_owned { }; } -macro_rules! serde_struct { - (@one $($x:tt)*) => { () }; - (@len $($e:expr),*) => { <[()]>::len(&[$(serde_struct!(@one $e)),*]); }; +macro_rules! count_it { + () => { 0 }; + ($head:tt $($x:tt $xs:tt)*) => { (count_it!($($x)*) << 1) | 1 }; + ($($x:tt $tail:tt)*) => { count_it!($($x)*) << 1 }; +} +macro_rules! serde_struct { ($ty:ident { $($field:ident),* $(,)? }) => { #[cfg(feature = "serde")] impl<'a> ::serde::Serialize for $ty<'a> { @@ -21,7 +24,7 @@ macro_rules! serde_struct { S: ::serde::Serializer, { use ::serde::ser::SerializeMap as _; - let len = serde_struct!(@len $($field),*); + let len = count_it!($($field)*); let mut s = serializer.serialize_map(Some(len))?; $( s.serialize_entry(stringify!($field), &self.$field())?; )* s.end() @@ -78,7 +81,7 @@ macro_rules! into_inner_raw { macro_rules! tags { () => { /// Get a view of parsable tags - pub fn tags(&self) -> $crate::irc::Tags<'_> { + pub const fn tags(&self) -> $crate::irc::Tags<'_> { Tags { data: &self.raw, indices: &self.tags, diff --git a/src/maybe_owned/into_owned.rs b/twitchchat/src/maybe_owned/into_owned.rs similarity index 100% rename from src/maybe_owned/into_owned.rs rename to twitchchat/src/maybe_owned/into_owned.rs diff --git a/src/maybe_owned/maybe_owned_index.rs b/twitchchat/src/maybe_owned/maybe_owned_index.rs similarity index 98% rename from src/maybe_owned/maybe_owned_index.rs rename to twitchchat/src/maybe_owned/maybe_owned_index.rs index d7b31fe..f77c7c4 100644 --- a/src/maybe_owned/maybe_owned_index.rs +++ b/twitchchat/src/maybe_owned/maybe_owned_index.rs @@ -3,7 +3,7 @@ use std::ops::{Index, Range}; type IndexWidth = u16; -/// An index into a [MaybeOwned]. +/// An index into a [`MaybeOwned`]. #[derive(Copy, Clone, Default, Debug, PartialEq, PartialOrd, Eq, Ord, Hash)] pub struct MaybeOwnedIndex { /// The start index diff --git a/src/maybe_owned/mod.rs b/twitchchat/src/maybe_owned/mod.rs similarity index 96% rename from src/maybe_owned/mod.rs rename to twitchchat/src/maybe_owned/mod.rs index 8c4f0ec..7efddd9 100644 --- a/src/maybe_owned/mod.rs +++ b/twitchchat/src/maybe_owned/mod.rs @@ -28,12 +28,12 @@ pub enum MaybeOwned<'a> { impl<'a> MaybeOwned<'a> { /// Checks whether this type is in the `Owned` state - pub fn is_owned(&self) -> bool { + pub const fn is_owned(&self) -> bool { !self.is_borrowed() } /// Checks whether this type is in the `Borrowed` state - pub fn is_borrowed(&self) -> bool { + pub const fn is_borrowed(&self) -> bool { matches!(self, Self::Borrowed{..}) } } diff --git a/src/messages.rs b/twitchchat/src/messages.rs similarity index 95% rename from src/messages.rs rename to twitchchat/src/messages.rs index f23a17c..8057603 100644 --- a/src/messages.rs +++ b/twitchchat/src/messages.rs @@ -1,5 +1,4 @@ -//! Twitch messages that can be parsed from `IrcMessage`, or subscribed to from the `Dispatcher` -//! +//! Twitch messages that can be parsed from `IrcMessage` //! //! # Converting from an `IrcMessage` to a specific message //! diff --git a/src/messages/cap.rs b/twitchchat/src/messages/cap.rs similarity index 88% rename from src/messages/cap.rs rename to twitchchat/src/messages/cap.rs index b8f5c2e..4d14d97 100644 --- a/src/messages/cap.rs +++ b/twitchchat/src/messages/cap.rs @@ -47,7 +47,14 @@ impl<'a> FromIrcMessage<'a> for Cap<'a> { msg.expect_command(IrcMessage::CAP)?; let this = Self { - capability: msg.expect_data_index()?, + // See: https://github.com/museun/twitchchat/issues/164#issuecomment-712095586 + // "* ACK :cap" + capability: match msg.data { + Some(index) => index, + // "* ACK cap" + None => msg.expect_arg_index(2)?, + }, + // capability: msg.expect_data_index()?, acknowledged: msg.expect_arg(1)? == ACK, raw: msg.raw, }; diff --git a/src/messages/clear_chat.rs b/twitchchat/src/messages/clear_chat.rs similarity index 100% rename from src/messages/clear_chat.rs rename to twitchchat/src/messages/clear_chat.rs diff --git a/src/messages/clear_msg.rs b/twitchchat/src/messages/clear_msg.rs similarity index 100% rename from src/messages/clear_msg.rs rename to twitchchat/src/messages/clear_msg.rs diff --git a/src/messages/commands.rs b/twitchchat/src/messages/commands.rs similarity index 99% rename from src/messages/commands.rs rename to twitchchat/src/messages/commands.rs index 0c93460..9eeb121 100644 --- a/src/messages/commands.rs +++ b/twitchchat/src/messages/commands.rs @@ -103,7 +103,7 @@ impl<'a> IntoOwned<'a> for Commands<'a> { } impl<'a> FromIrcMessage<'a> for Commands<'a> { - type Error = MessageError; + type Error = crate::irc::MessageError; fn from_irc(msg: IrcMessage<'a>) -> Result { macro_rules! map { @@ -174,10 +174,7 @@ macro_rules! from_other { }; } -type Raw<'a> = IrcMessage<'a>; - from_other! { - Raw IrcReady Ready Cap diff --git a/src/messages/global_user_state.rs b/twitchchat/src/messages/global_user_state.rs similarity index 100% rename from src/messages/global_user_state.rs rename to twitchchat/src/messages/global_user_state.rs diff --git a/src/messages/host_target.rs b/twitchchat/src/messages/host_target.rs similarity index 100% rename from src/messages/host_target.rs rename to twitchchat/src/messages/host_target.rs diff --git a/src/messages/irc_ready.rs b/twitchchat/src/messages/irc_ready.rs similarity index 100% rename from src/messages/irc_ready.rs rename to twitchchat/src/messages/irc_ready.rs diff --git a/src/messages/join.rs b/twitchchat/src/messages/join.rs similarity index 100% rename from src/messages/join.rs rename to twitchchat/src/messages/join.rs diff --git a/src/messages/notice.rs b/twitchchat/src/messages/notice.rs similarity index 100% rename from src/messages/notice.rs rename to twitchchat/src/messages/notice.rs diff --git a/src/messages/part.rs b/twitchchat/src/messages/part.rs similarity index 100% rename from src/messages/part.rs rename to twitchchat/src/messages/part.rs diff --git a/src/messages/ping.rs b/twitchchat/src/messages/ping.rs similarity index 100% rename from src/messages/ping.rs rename to twitchchat/src/messages/ping.rs diff --git a/src/messages/pong.rs b/twitchchat/src/messages/pong.rs similarity index 100% rename from src/messages/pong.rs rename to twitchchat/src/messages/pong.rs diff --git a/src/messages/privmsg.rs b/twitchchat/src/messages/privmsg.rs similarity index 100% rename from src/messages/privmsg.rs rename to twitchchat/src/messages/privmsg.rs diff --git a/src/messages/ready.rs b/twitchchat/src/messages/ready.rs similarity index 100% rename from src/messages/ready.rs rename to twitchchat/src/messages/ready.rs diff --git a/src/messages/reconnect.rs b/twitchchat/src/messages/reconnect.rs similarity index 100% rename from src/messages/reconnect.rs rename to twitchchat/src/messages/reconnect.rs diff --git a/src/messages/room_state.rs b/twitchchat/src/messages/room_state.rs similarity index 100% rename from src/messages/room_state.rs rename to twitchchat/src/messages/room_state.rs diff --git a/src/messages/user_notice.rs b/twitchchat/src/messages/user_notice.rs similarity index 100% rename from src/messages/user_notice.rs rename to twitchchat/src/messages/user_notice.rs diff --git a/src/messages/user_state.rs b/twitchchat/src/messages/user_state.rs similarity index 100% rename from src/messages/user_state.rs rename to twitchchat/src/messages/user_state.rs diff --git a/src/messages/whisper.rs b/twitchchat/src/messages/whisper.rs similarity index 100% rename from src/messages/whisper.rs rename to twitchchat/src/messages/whisper.rs diff --git a/src/serde.rs b/twitchchat/src/serde.rs similarity index 97% rename from src/serde.rs rename to twitchchat/src/serde.rs index c95c627..c5549e9 100644 --- a/src/serde.rs +++ b/twitchchat/src/serde.rs @@ -1,4 +1,4 @@ -use crate::{FromIrcMessage, IrcMessage, MaybeOwned}; +use crate::{irc::IrcMessage, FromIrcMessage, MaybeOwned}; use serde::{ de::{Error, MapAccess, Visitor}, diff --git a/twitchchat/src/stream.rs b/twitchchat/src/stream.rs new file mode 100644 index 0000000..f71bada --- /dev/null +++ b/twitchchat/src/stream.rs @@ -0,0 +1,37 @@ +//! Sink/Stream ([`futures::Sink`] + [`futures::Stream`]) types +//! +//! TODO write a demo here, and more of a description + +/// Boxed [`futures::Stream`][ref] trait object +/// +/// [ref]: https://docs.rs/futures-core/0.3.7/futures_core/stream/trait.Stream.html +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub type BoxedStream = Box + Send + Sync + Unpin>; + +/// Boxed [`futures::Sink`][ref] trait object +/// +/// [ref]: https://docs.rs/futures_sink/0.3.7/futures_sink/trait.Sink.html +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub type BoxedSink = Box + Send + Sync + Unpin>; + +// re-exports +pub use crate::{ + decoder::{DecodeError, ReadMessage, StreamDecoder}, + encoder::SinkEncoder, +}; + +pub use crate::handshake::{ + stream::{BoxedDecoder, BoxedEncoder, Decoder, Encoder, Handshake}, + HandshakeError, +}; +pub use crate::timeout::{Activity, ActivityReceiver, ActivitySender, TIMEOUT, WINDOW}; + +pub use crate::identity::{Identity, YourCapabilities}; + +#[doc(inline)] +pub use crate::asynchronous::idle_detection_loop; + +#[doc(inline)] +pub use crate::asynchronous::respond_to_idle_events; diff --git a/twitchchat/src/sync.rs b/twitchchat/src/sync.rs new file mode 100644 index 0000000..98e5479 --- /dev/null +++ b/twitchchat/src/sync.rs @@ -0,0 +1,131 @@ +//! Synchronous ([`std::io::Read`] + [`std::io::Write`]) types +//! +//! TODO write a demo here, and more of a description + +use crate::timeout::timeout_detection_inner; +use std::sync::mpsc::SyncSender; + +// re-exports +pub use crate::decoder::{DecodeError, Decoder}; +pub use crate::encoder::Encoder; + +pub use crate::io::{BoxedRead, BoxedWrite, ReadHalf, WriteHalf}; + +pub use crate::handshake::{sync::Handshake, HandshakeError}; +pub use crate::timeout::{ + Activity, ActivityReceiver, ActivitySender, TimedOutError, TIMEOUT, WINDOW, +}; + +pub use crate::identity::{Identity, YourCapabilities}; + +/// A helper function that loops over the 'token' Receiver and responds with `PONGs` +/// +/// This blocks the current thread. +/// +/// This is only available when you have `features = ["writer"]` enabled +#[cfg(feature = "writer")] +#[cfg_attr(docsrs, doc(cfg(feature = "writer")))] +pub fn respond_to_idle_events( + writer: crate::writer::MpscWriter, + tokens: std::sync::mpsc::Receiver, +) { + for token in tokens { + if writer.send(crate::commands::pong(&token)).is_err() { + break; + } + } +} + +/// A synchronous idle detection loop -- this will block until a timeout is detected, or you send a Quit signal +/// +/// This allows you keep a connection alive with an out-of-band loop. +/// +/// Normally, Twitch will send a `PING` every so often and you must reply with a `PONG` or you'll be disconnected. +/// +/// But sometimes you want to detect an idle connection and force the 'heartbeat' to happen sooner. This function gives you that ability. +/// +/// Usage: +/// * create [`ActivitySender`] and [`ActivityReceiver`] with the [`Activity::pair()`] function. +/// * create a [`std::sync::mpsc::sync_channel`] that is used for its responses (the **PING tokens**). +/// * start the loop (you'll probably want to spawn a thread so you don't block the current one) +/// * see [`asynchronous::idle_detection_loop()`][idle_detection_loop] for an async version +/// * when you want to delay the timeout (e.g. you wrote something), send a [`Activity::Tick`] via [`ActivitySender::message()`]. +/// * when you read a message, you should give a copy of it to the loop, send a [`Activity::Message`] via [`ActivitySender::message()`]. +/// * when you want to signal a shutdown, send a [`Activity::Quit`] via [`ActivitySender::message()`]. +/// * you'll periodically get a message via the channel you passed to it. +/// * you should encode this string via [`twitchchat::commands::ping()`][ping] to your Encoder. +/// +/// When [`WINDOW`][window] has passed without any activity from you (`Quit`, `Message`), it will produce a **token** via the channel. +/// +/// This is the type you should encode as `ping`. +/// +/// If the server does not reply within [`TIMEOUT`][timeout] then the loop will exit, producing an [`TimedOutError::TimedOut`] +/// +/// # Example +/// +/// ```no_run +/// # use twitchchat::{sync::*, *, irc::*}; +/// # let stream = std::io::Cursor::new(Vec::new()); +/// # let decoder = Decoder::new(stream); +/// // create an activity pair -- this lets you reschedule the timeout +/// let (sender, receiver) = Activity::pair(); +/// +/// // interacting with the loop: +/// // this is a generic event to push the timeout forward +/// // you'll want to do this anytime you write +/// sender.message(Activity::Tick); +/// +/// # let msg: IrcMessage = irc::parse(":PING 123456789\r\n").next().unwrap().unwrap(); +/// // when you read a message, you should send a copy of it to the timeout +/// // detector +/// sender.message(Activity::Message(msg)); +/// +/// // you can also signal for it to quit +/// // when you send this message, the loop will end and return `Ok(())` +/// sender.message(Activity::Quit); +/// +/// // you can otherwise shut it down by dropping the `ActivitySender` the loop +/// // will end and return `Ok(())` +/// +/// // reading from suggested responses the loop: +/// // you'll need a channel that the detector will respond with. +/// // it'll send you a 'token' that you should send to the connection via +/// // `commands::ping(&token).encode(&mut writer)`; +/// let (tx, rx) = std::sync::mpsc::sync_channel(1); +/// +/// // the loop will block the current thread +/// let handle = std::thread::spawn(move || idle_detection_loop(receiver, tx)); +/// +/// // you can either spawn the receiver loop off, or use a method like +/// // `Receiver::try_recv` for non-blocking receives +/// # let mut writer = Encoder::new(vec![]); +/// std::thread::spawn(move || { +/// // this receiver return None when the loop exits +/// // this happens on success (Quit, you drop the ActivitySender) +/// // or a timeout error occurs +/// for token in rx { +/// // send a ping to the server, wtih this token. +/// commands::ping(&token).encode(&mut writer).unwrap() +/// // if the server does not reply within the `TIMEOUT` an error +/// // will be produced on the other end +/// } +/// }); +/// +/// // when the thread joins, it should return a Result, if a timeout occured, +/// // it'll return an `Error::Timeout` +/// if let Err(TimedOutError::TimedOut) = handle.join().unwrap() { +/// // the connection timed out +/// } +/// ``` +/// +/// [window]: WINDOW +/// [timeout]: TIMEOUT +/// [ping]: crate::commands::ping +/// [idle_detection_loop]: crate::asynchronous::idle_detection_loop +/// +pub fn idle_detection_loop( + input: ActivityReceiver, + output: SyncSender, +) -> Result<(), TimedOutError> { + timeout_detection_inner(input, output) +} diff --git a/twitchchat/src/test/dummy.rs b/twitchchat/src/test/dummy.rs new file mode 100644 index 0000000..8b52d2a --- /dev/null +++ b/twitchchat/src/test/dummy.rs @@ -0,0 +1,40 @@ +use futures::{Sink, Stream}; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use crate::decoder::{DecodeError, ReadMessage}; + +#[derive(Copy, Clone, Debug)] +pub struct Dummy; + +impl Sink for Dummy { + type Error = DecodeError; + + fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + unimplemented!() + } + fn start_send(self: Pin<&mut Self>, _: String) -> Result<(), Self::Error> { + unimplemented!() + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + unimplemented!() + } + fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + unimplemented!() + } +} + +impl ReadMessage for String { + fn read_string(self) -> Result { + Ok(self) + } +} + +impl Stream for Dummy { + type Item = Result; + fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + unimplemented!() + } +} diff --git a/twitchchat/src/test/mod.rs b/twitchchat/src/test/mod.rs new file mode 100644 index 0000000..5484c15 --- /dev/null +++ b/twitchchat/src/test/mod.rs @@ -0,0 +1,13 @@ +//! Helpful testing utilities + +#[doc(inline)] +pub use crate::irc::tags::{escape_str, unescape_str}; + +mod tags_builder; +pub use tags_builder::{BuilderError, TagsBuilder, UserTags}; + +#[allow(missing_docs)] +#[doc(hidden)] +#[cfg(feature = "sink_stream")] +#[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] +pub mod dummy; diff --git a/src/test/tags_builder.rs b/twitchchat/src/test/tags_builder.rs similarity index 94% rename from src/test/tags_builder.rs rename to twitchchat/src/test/tags_builder.rs index 1c29af8..905d823 100644 --- a/src/test/tags_builder.rs +++ b/twitchchat/src/test/tags_builder.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use std::collections::HashMap; -use crate::irc::{TagIndices, Tags}; +use crate::irc::{MessageError, TagIndices, Tags}; use crate::MaybeOwned; #[derive(Debug)] @@ -129,7 +129,14 @@ impl<'a> TagsBuilder<'a> { .expect("memory for string allocation"); } - let indices = TagIndices::build_indices(&buf); + let indices = TagIndices::build_indices(&buf).map_err(|err| match err { + MessageError::MissingTagKey { .. } => BuilderError::EmptyKey, + MessageError::MissingTagValue { .. } => BuilderError::EmptyTags, + _ => { + unreachable!() + } + })?; + Ok(UserTags { data: buf.into(), indices, @@ -231,7 +238,7 @@ mod tests { use crate::FromIrcMessage as _; let msg = "@badge-info=;badges=broadcaster/1;color=#FF69B4;display-name=museun;emote-only=1;emotes=25:0-4,6-10/81274:12-17;flags=;id=4e160a53-5482-4764-ba28-f224cd59a51f;mod=0;room-id=23196011;subscriber=0;tmi-sent-ts=1601079032426;turbo=0;user-id=23196011;user-type= :museun!museun@museun.tmi.twitch.tv PRIVMSG #museun :Kappa Kappa VoHiYo\r\n"; - let msg = crate::IrcMessage::parse(crate::MaybeOwned::Borrowed(msg)).unwrap(); + let msg = crate::irc::IrcMessage::parse(crate::MaybeOwned::Borrowed(msg)).unwrap(); let pm = crate::messages::Privmsg::from_irc(msg).unwrap(); let tags = pm.tags(); diff --git a/twitchchat/src/timeout.rs b/twitchchat/src/timeout.rs new file mode 100644 index 0000000..4657f2e --- /dev/null +++ b/twitchchat/src/timeout.rs @@ -0,0 +1,189 @@ +use std::{ + sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender}, + time::{Duration, Instant}, +}; + +use crate::{ + irc::IrcMessage, + messages::Commands::{self, *}, + util::timestamp, + FromIrcMessage as _, +}; + +/// The Timeout window. Its hardcoded to ***45 seconds***. +/// +/// This is the amount of time that has to pass before the loop tries to send a heartbeat +pub const WINDOW: Duration = Duration::from_secs(45); + +/// The Timeout timeout. Its hardcoded to ***10 seconds***. +/// +/// This is the amount of time the server has to respond to the heartbeat +pub const TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug)] +#[non_exhaustive] +#[allow(missing_copy_implementations)] +/// An error returned by the timeout detection logic +pub enum TimedOutError { + /// A timeout was detected + TimedOut, +} + +impl std::fmt::Display for TimedOutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TimedOut => f.write_str("connection timed out"), + } + } +} + +impl std::error::Error for TimedOutError {} + +/// A handle for notifying the detection logic that it should delay +/// +/// If you drop this, the other end will hang up +pub struct ActivitySender(SyncSender); + +impl std::fmt::Debug for ActivitySender { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActivitySender").finish() + } +} + +impl ActivitySender { + /// Send this activitiy + pub fn message(&self, activity: Activity) { + let _ = self.0.send(activity); + } +} + +/// A handle that you give to the timeout detection logic +pub struct ActivityReceiver(Receiver); + +impl std::fmt::Debug for ActivityReceiver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActivityReceiver").finish() + } +} + +/// The kind of activity +#[derive(Debug)] +#[non_exhaustive] +pub enum Activity { + /// A message was read + /// + /// When you read a messsage, you should give the logic a copy of it, via + /// this type + Message(IrcMessage<'static>), + /// Delay the logic until the next deadline + /// + /// When you write a message, you should send this. + Tick, + + /// A signal that the loop should exit + Quit, +} + +impl Activity { + /// Create a pair of activity handles + pub fn pair() -> (ActivitySender, ActivityReceiver) { + let (tx, rx) = sync_channel(64); + (ActivitySender(tx), ActivityReceiver(rx)) + } +} + +pub(crate) fn timeout_detection_inner( + input: ActivityReceiver, + output: impl SendIt, +) -> Result<(), TimedOutError> { + let mut state = TimeoutState::Start; + + loop { + match input.0.recv_timeout(WINDOW) { + Ok(Activity::Message(msg)) => match Commands::from_irc(msg) { + Ok(Ping(msg)) => { + if !output.send(msg.token().to_string()) { + break; + } + state = TimeoutState::activity() + } + Ok(Pong(..)) if matches!(state, TimeoutState::WaitingForPong{..}) => { + state = TimeoutState::activity() + } + _ => {} + }, + + Ok(Activity::Tick) => state = TimeoutState::activity(), + + Ok(Activity::Quit) | Err(RecvTimeoutError::Disconnected) => break, + + Err(..) if matches!(state, TimeoutState::Activity{..} | TimeoutState::Start) => { + if !output.send(timestamp().to_string()) { + break; + } + + state = TimeoutState::waiting_for_pong(); + } + + // we're already waiting for a PONG + _ => {} + } + + match state { + TimeoutState::WaitingForPong(dt) => { + if dt.elapsed() > TIMEOUT { + log::warn!(target: "twitchchat::timeout", "timeout detected after {:.0?}", dt.elapsed()); + return Err(TimedOutError::TimedOut); + } + } + + TimeoutState::Activity(dt) => { + if dt.elapsed() > TIMEOUT { + if !output.send(timestamp().to_string()) { + break; + } + log::warn!(target: "twitchchat::timeout", "sending a PING"); + state = TimeoutState::waiting_for_pong(); + } + } + + TimeoutState::Start => {} + } + } + + Ok(()) +} + +#[derive(Copy, Clone, Debug)] +enum TimeoutState { + WaitingForPong(Instant), + Activity(Instant), + Start, +} + +impl TimeoutState { + fn activity() -> Self { + Self::Activity(Instant::now()) + } + + fn waiting_for_pong() -> Self { + Self::WaitingForPong(Instant::now()) + } +} + +pub(crate) trait SendIt { + fn send(&self, item: T) -> bool; +} + +impl SendIt for SyncSender { + fn send(&self, item: T) -> bool { + self.try_send(item).is_ok() + } +} + +#[cfg(feature = "async")] +impl SendIt for flume::Sender { + fn send(&self, item: T) -> bool { + self.try_send(item).is_ok() + } +} diff --git a/twitchchat/src/twitch/badge.rs b/twitchchat/src/twitch/badge.rs new file mode 100644 index 0000000..bac35c7 --- /dev/null +++ b/twitchchat/src/twitch/badge.rs @@ -0,0 +1,154 @@ +/// The kind of the [badges] that are associated with messages. +/// +/// Any unknown (e.g. custom badges/sub events, etc) are placed into the [Unknown] variant. +/// +/// [badges]: Badge +/// [Unknown]: BadgeKind::Unknown +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub enum BadgeKind<'a> { + /// Admin badge + Admin, + /// Bits badge + Bits, + /// Broadcaster badge + Broadcaster, + /// GlobalMod badge + GlobalMod, + /// Moderator badge + Moderator, + /// Subscriber badge + Subscriber, + /// Staff badge + Staff, + /// Turbo badge + Turbo, + /// Premium badge + Premium, + /// VIP badge + VIP, + /// Partner badge + Partner, + /// Unknown badge. Likely a custom badge + Unknown(&'a str), +} + +/// Badges attached to a message +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] +pub struct Badge<'a> { + /// The kind of the Badge + pub kind: BadgeKind<'a>, + /// Any associated data with the badge + /// + /// May be: + /// - version + /// - number of bits + /// - number of months needed for sub badge + /// - etc + pub data: &'a str, +} + +impl<'a> Badge<'a> { + /// Tries to parse a badge from this message part + pub fn parse(input: &'a str) -> Option> { + use BadgeKind::*; + let mut iter = input.split('/'); + let kind = match iter.next()? { + "admin" => Admin, + "bits" => Bits, + "broadcaster" => Broadcaster, + "global_mod" => GlobalMod, + "moderator" => Moderator, + "subscriber" => Subscriber, + "staff" => Staff, + "turbo" => Turbo, + "premium" => Premium, + "vip" => VIP, + "partner" => Partner, + badge => Unknown(badge), + }; + + iter.next().map(|data| Badge { kind, data }) + } + + /// The `&str` representation of the [`BadgeKind`] + /// + /// In case of [`BadgeKind::Unknown`], this is the same value as `BadgeKind::Unknown(badge)` + pub const fn kind_raw(&self) -> &'a str { + use BadgeKind::*; + match self.kind { + Admin => "admin", + Bits => "bits", + Broadcaster => "broadcaster", + GlobalMod => "global_mod", + Moderator => "moderator", + Subscriber => "subscriber", + Staff => "staff", + Turbo => "turbo", + Premium => "premium", + VIP => "vip", + Partner => "partner", + Unknown(s) => s, + } + } +} + +/// Metadata to the chat badges +pub type BadgeInfo<'a> = Badge<'a>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_known_badges() { + // ("input", expected value) + const BADGE_KINDS: &[(&str, BadgeKind<'_>)] = &[ + ("admin", BadgeKind::Admin), + ("bits", BadgeKind::Bits), + ("broadcaster", BadgeKind::Broadcaster), + ("global_mod", BadgeKind::GlobalMod), + ("moderator", BadgeKind::Moderator), + ("subscriber", BadgeKind::Subscriber), + ("staff", BadgeKind::Staff), + ("turbo", BadgeKind::Turbo), + ("premium", BadgeKind::Premium), + ("vip", BadgeKind::VIP), + ("partner", BadgeKind::Partner), + ("unknown", BadgeKind::Unknown("unknown")), + ]; + + for (raw, kind) in BADGE_KINDS { + let badge_str = format!("{}/0", raw); + let badge = Badge::parse(&badge_str).expect("Malformed badge test"); + + assert_eq!(badge.kind, *kind); + assert_eq!(badge.kind_raw(), *raw); + assert_eq!(badge.data, "0"); + } + } + + #[test] + fn parse_unknown() { + let badge_str = "this_badge_does_not_exist/0"; + let badge = Badge::parse(badge_str).unwrap(); + assert_eq!( + badge, + Badge { + kind: BadgeKind::Unknown("this_badge_does_not_exist"), + data: "0" + } + ); + + assert_eq!(badge.kind_raw(), "this_badge_does_not_exist") + } + + #[test] + fn parse_invalid() { + let badge_str = "this_badge_is_invalid"; + let badge = Badge::parse(badge_str); + assert_eq!(badge, None) + } +} diff --git a/src/twitch/capability.rs b/twitchchat/src/twitch/capability.rs similarity index 50% rename from src/twitch/capability.rs rename to twitchchat/src/twitch/capability.rs index d1c222d..b0bb4d9 100644 --- a/src/twitch/capability.rs +++ b/twitchchat/src/twitch/capability.rs @@ -1,3 +1,18 @@ +#[derive(Debug)] +#[non_exhaustive] +/// An error returned by [`std::str::FromStr`] for [`Capability`] +pub struct CapabilityParseError { + cap: String, +} + +impl std::fmt::Display for CapabilityParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown capability: {}", self.cap.escape_debug()) + } +} + +impl std::error::Error for CapabilityParseError {} + /// Capability used to enable extra functionality with the protocol /// /// Without any of these specified, you will just able to read/write basic messages @@ -28,17 +43,26 @@ impl Capability { Self::Commands => "CAP REQ :twitch.tv/commands", } } +} - /// Attempts to 'parse' this capability from a string +impl std::str::FromStr for Capability { + type Err = CapabilityParseError; + + /// Currently only these caps are supported: /// - /// This will take the form of `twitch.tv/$tag` and produce a [Capability] - #[allow(dead_code)] - pub(crate) fn maybe_from_str(input: &str) -> Option { - match input { - "twitch.tv/membership" => Some(Self::Membership), - "twitch.tv/tags" => Some(Self::Tags), - "twitch.tv/commands" => Some(Self::Commands), - _ => None, - } + /// * "twitch.tv/membership" + /// * "twitch.tv/tags" + /// * "twitch.tv/commands" + fn from_str(input: &str) -> Result { + let this = match input { + "twitch.tv/membership" => Self::Membership, + "twitch.tv/tags" => Self::Tags, + "twitch.tv/commands" => Self::Commands, + cap => { + let cap = cap.to_string(); + return Err(CapabilityParseError { cap }); + } + }; + Ok(this) } } diff --git a/src/twitch/color.rs b/twitchchat/src/twitch/color.rs similarity index 98% rename from src/twitch/color.rs rename to twitchchat/src/twitch/color.rs index 45ed109..32ad08a 100644 --- a/src/twitch/color.rs +++ b/twitchchat/src/twitch/color.rs @@ -256,9 +256,7 @@ impl FromStr for Color { } impl Default for Color { - /// Defaults to having a kind of [Turbo] and RGB of #FFFFFF (white) - /// - /// [Turbo]: TwitchColor::Turbo + /// Defaults to having a kind of [`TwitchColor::Turbo`] and RGB of #FFFFFF (white) fn default() -> Self { Self { kind: TwitchColor::Turbo, @@ -364,7 +362,7 @@ impl From for RGB { } } -/// A utility method that returns an array of [TwitchColor]s mapped to its corresponding [RGB] +/// A utility method that returns an array of [`TwitchColor`]s mapped to its corresponding [`RGB`] pub const fn twitch_colors() -> [(TwitchColor, RGB); 15] { use TwitchColor::*; [ diff --git a/src/twitch/emotes.rs b/twitchchat/src/twitch/emotes.rs similarity index 94% rename from src/twitch/emotes.rs rename to twitchchat/src/twitch/emotes.rs index 98a32ad..0d33500 100644 --- a/src/twitch/emotes.rs +++ b/twitchchat/src/twitch/emotes.rs @@ -15,9 +15,7 @@ They are presented (to the irc connection) in a `id:range1,range2/id2:range1,..` pub struct Emotes { /// This emote id, e.g. `Kappa = 25` pub id: usize, - /// A list of [Range] in the message where this emote is found - /// - /// [Range]: https://doc.rust-lang.org/std/ops/struct.Range.html + /// A list of [`std::ops::Range`] in the message where this emote is found pub ranges: Vec>, } @@ -39,13 +37,11 @@ impl Emotes { } } -#[inline] fn get_parts(input: &str, sep: char) -> Option<(&str, &str)> { let mut split = input.split_terminator(sep); (split.next()?, split.next()?).into() } -#[inline] fn get_ranges(tail: &str) -> impl Iterator> + '_ { tail.split_terminator(',') .filter_map(|s| get_parts(s, '-')) diff --git a/src/twitch/mod.rs b/twitchchat/src/twitch/mod.rs similarity index 93% rename from src/twitch/mod.rs rename to twitchchat/src/twitch/mod.rs index 358fa95..127a8bc 100644 --- a/src/twitch/mod.rs +++ b/twitchchat/src/twitch/mod.rs @@ -1,7 +1,7 @@ //! Common Twitch types mod capability; -pub use capability::Capability; +pub use capability::{Capability, CapabilityParseError}; mod userconfig; pub use userconfig::{UserConfig, UserConfigBuilder, UserConfigError}; diff --git a/src/twitch/userconfig.rs b/twitchchat/src/twitch/userconfig.rs similarity index 98% rename from src/twitch/userconfig.rs rename to twitchchat/src/twitch/userconfig.rs index 469dc28..0c44cab 100644 --- a/src/twitch/userconfig.rs +++ b/twitchchat/src/twitch/userconfig.rs @@ -38,7 +38,7 @@ pub struct UserConfig { } impl UserConfig { - /// Create a builder to make a [UserConfig] + /// Create a builder to make a [`UserConfig`] pub fn builder() -> UserConfigBuilder { UserConfigBuilder::default() } @@ -49,7 +49,7 @@ impl UserConfig { } } -/// User config error returned by the [UserConfigBuilder] +/// User config error returned by the [`UserConfigBuilder] #[non_exhaustive] #[derive(Debug, Copy, Clone)] pub enum UserConfigError { @@ -77,7 +77,7 @@ impl std::fmt::Display for UserConfigError { impl std::error::Error for UserConfigError {} -/// Builder for making a [UserConfig] +/// Builder for making a [`UserConfig`] #[derive(Default, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct UserConfigBuilder { @@ -168,12 +168,10 @@ impl UserConfigBuilder { } } -#[inline] const fn validate_name(s: &str) -> bool { !s.is_empty() } -#[inline] fn validate_token(s: &str) -> bool { if s == crate::JUSTINFAN1234 { return true; diff --git a/twitchchat/src/util.rs b/twitchchat/src/util.rs new file mode 100644 index 0000000..351c795 --- /dev/null +++ b/twitchchat/src/util.rs @@ -0,0 +1,11 @@ +pub(crate) fn timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +pub(crate) fn is_blocking_error(err: &std::io::Error) -> bool { + use std::io::ErrorKind::*; + matches!(err.kind(), WouldBlock | Interrupted | TimedOut) +} diff --git a/src/validator.rs b/twitchchat/src/validator.rs similarity index 96% rename from src/validator.rs rename to twitchchat/src/validator.rs index 9b89272..0bcdbd0 100644 --- a/src/validator.rs +++ b/twitchchat/src/validator.rs @@ -24,7 +24,7 @@ pub trait Validator { impl<'a> Validator for IrcMessage<'a> { fn parse_tags(&self) -> TagIndices { self.tags - .map(|index| TagIndices::build_indices(&self.raw[index])) + .and_then(|index| TagIndices::build_indices(&self.raw[index]).ok()) .unwrap_or_default() } diff --git a/twitchchat/src/wait_for.rs b/twitchchat/src/wait_for.rs new file mode 100644 index 0000000..cc5fc6e --- /dev/null +++ b/twitchchat/src/wait_for.rs @@ -0,0 +1,269 @@ +use std::collections::HashSet; + +use crate::{ + decoder::DecodeError, + identity::{Identity, YourCapabilities}, + irc::IrcMessage, + twitch::{Capability, UserConfig}, + util::is_blocking_error, +}; + +/// An event to wait for +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[non_exhaustive] +pub enum Event { + /// Raw event + Raw, + /// IrcReady event + IrcReady, + /// Ready event + Ready, + /// Capabilities event + Cap, + /// ClearChat event + ClearChat, + /// ClearMsg event + ClearMsg, + /// GlobalUserState event + GlobalUserState, + /// HostTarget event + HostTarget, + /// Join event + Join, + /// Notice event + Notice, + /// Part event + Part, + /// Ping event + Ping, + /// Pong event + Pong, + /// Privmsg event + Privmsg, + /// Reconnect event + Reconnect, + /// RoomState event + RoomState, + /// UserNotice event + UserNotice, + /// UserState event + UserState, + /// Whisper event + Whisper, +} + +impl Event { + pub(crate) fn from_raw(s: &str) -> Self { + use IrcMessage as M; + match s { + M::IRC_READY => Self::IrcReady, + M::READY => Self::Ready, + M::CAP => Self::Cap, + M::CLEAR_CHAT => Self::ClearChat, + M::CLEAR_MSG => Self::ClearMsg, + M::GLOBAL_USER_STATE => Self::GlobalUserState, + M::HOST_TARGET => Self::HostTarget, + M::JOIN => Self::Join, + M::NOTICE => Self::Notice, + M::PART => Self::Part, + M::PING => Self::Ping, + M::PONG => Self::Pong, + M::PRIVMSG => Self::Privmsg, + M::RECONNECT => Self::Reconnect, + M::ROOM_STATE => Self::RoomState, + M::USER_NOTICE => Self::UserNotice, + M::USER_STATE => Self::UserState, + M::WHISPER => Self::Whisper, + _ => Self::Raw, + } + } +} + +pub(crate) enum State<'a> { + Done(IrcMessage<'a>), + Requeue(IrcMessage<'a>), + Yield, +} + +pub(crate) fn wait_inner( + result: Result, DecodeError>, + event: Event, +) -> Result, DecodeError> { + let msg = match result { + Err(DecodeError::Io(err)) if is_blocking_error(&err) => return Ok(State::Yield), + Err(err) => return Err(err), + Ok(msg) => msg, + }; + + if Event::from_raw(msg.get_command()) == event { + Ok(State::Done(msg)) + } else { + Ok(State::Requeue(msg)) + } +} + +pub(crate) fn check_message( + msg: &IrcMessage<'_>, + state: &mut ReadyState, +) -> Result { + const FAILURE: &str = "Login authentication failed"; + + use crate::{ + messages::Capability as TwitchCap, // + messages::Commands as C, + FromIrcMessage as _, + }; + + match C::from_irc(msg.clone()).unwrap() { + C::Notice(msg) if msg.message() == FAILURE => Err(CheckError::BadPass), + + C::Ready(msg) => { + state.our_name.replace(msg.username().to_string()); + + // if we aren't going to be receiving tags, then we + // won't be looking for any more messages + + // if we're anonymous, we won't get GLOBALUSERSTATE even + // if we do send Tags + if state.is_anonymous { + return Ok(StepState::Identity(Identity::Anonymous { + caps: std::mem::take(&mut state.caps), + })); + } + + // if we're not looking for any more caps and we won't be + // getting a GlobalUserState just give them the Basic Identity + if state.looking_for.is_empty() && !state.will_be_getting_global_user_state_hopefully { + return Ok(StepState::Identity(Identity::Basic { + name: state.our_name.take().unwrap(), + caps: std::mem::take(&mut state.caps), + })); + } + + Ok(StepState::Continue) + } + + C::Cap(msg) => { + match msg.capability() { + TwitchCap::Acknowledged(name) => { + use crate::twitch::Capability as Cap; + + let cap = match name.parse() { + Ok(cap) => cap, + // Twitch sent us an unknown capability + Err(..) => { + state.caps.unknown.insert(name.to_string()); + return Ok(StepState::Continue); + } + }; + + *match cap { + Cap::Tags => &mut state.caps.tags, + Cap::Membership => &mut state.caps.membership, + Cap::Commands => &mut state.caps.commands, + } = true; + + state.looking_for.remove(&cap); + } + + TwitchCap::NotAcknowledged(name) => { + return Err(CheckError::InvalidCap(name.to_string())) + } + } + + Ok(StepState::Continue) + } + + // NOTE: This will only be sent when there's both Commands and atleast + // one other CAP requested + C::GlobalUserState(msg) => { + // TODO: this is so shitty. + let id = match msg.user_id { + Some(id) => id.parse().unwrap(), + // XXX: we can get this message without any tags + None => { + return Ok(StepState::Identity(Identity::Basic { + name: state.our_name.take().unwrap(), + caps: std::mem::take(&mut state.caps), + })) + } + }; + + Ok(StepState::Identity(Identity::Full { + // these unwraps should be safe because we'll have all of the TAGs here + name: state.our_name.take().unwrap(), + user_id: id, + display_name: msg.display_name.map(|s| s.to_string()), + color: msg.color, + caps: std::mem::take(&mut state.caps), + })) + } + + // Reply to any PINGs while waiting. Although Twitch doesn't + // currently send a PING for spoof detection on initial + // handshake, one day they may. Most IRC servers do this + // already + C::Ping(msg) => Ok(StepState::ShouldPong(msg.token().to_string())), + + // Skip this message because its a synthetic response + C::Pong(_) => Ok(StepState::Skip), + + C::Reconnect(_) => Err(CheckError::ShouldReconnect), + + // we have our name, but we won't be getting GlobalUserState and we've + // got all of our Caps + _ if state.our_name.is_some() + && !state.will_be_getting_global_user_state_hopefully + && state.looking_for.is_empty() => + { + Ok(StepState::Identity(Identity::Basic { + name: state.our_name.take().unwrap(), + caps: std::mem::take(&mut state.caps), + })) + } + + _ => Ok(StepState::Continue), + } +} + +#[derive(Debug)] +pub(crate) enum CheckError { + InvalidCap(String), + BadPass, + ShouldReconnect, +} + +pub(crate) enum StepState { + Skip, + Continue, + ShouldPong(String), + Identity(Identity), +} + +pub(crate) struct ReadyState { + pub(crate) looking_for: HashSet, + pub(crate) caps: YourCapabilities, + pub(crate) our_name: Option, + pub(crate) is_anonymous: bool, + pub(crate) will_be_getting_global_user_state_hopefully: bool, +} + +impl ReadyState { + pub(crate) fn new(user_config: &UserConfig) -> Self { + let is_anonymous = user_config.is_anonymous(); + + let will_be_getting_global_user_state_hopefully = + user_config.capabilities.contains(&Capability::Tags) + && user_config.capabilities.contains(&Capability::Commands); + + Self { + looking_for: user_config.capabilities.iter().copied().collect(), + caps: YourCapabilities::default(), + our_name: None, + is_anonymous, + will_be_getting_global_user_state_hopefully, + } + } +} + +// TODO record a bunch of twitch handshakes and test them here diff --git a/twitchchat/src/writer.rs b/twitchchat/src/writer.rs new file mode 100644 index 0000000..223143d --- /dev/null +++ b/twitchchat/src/writer.rs @@ -0,0 +1,200 @@ +//! A collection of useful writers, which allow you to share access to an `Encoder` +//! +//! ## Required/Optional features: +//! +//! | Feature | | +//! | ------------- | ------------------------------------------------------------------------------------ | +//! | `writer` | **_required_** for this module | +//! | `async` | enables the use of [`asynchronous::Encoder`][async_enc] and [`MpscWriter::shutdown`] | +//! | `sink_stream` | enables the use of [`stream::Encoder`][stream_enc] | +//! +//! You can combine them into configurations such as: `["writer", "async", "sink_stream"]` +//! +//! ## Example +//! +//! ```no_run +//! # use twitchchat::sync::Encoder; +//! # let encoder = Encoder::new(std::io::Cursor::new(Vec::new())); +//! use twitchchat::commands; +//! let writer = twitchchat::writer::MpscWriter::from_encoder(encoder); +//! writer.send(commands::join("#museun")).unwrap(); +//! writer.send(commands::part("#museun")).unwrap(); +//! +//! // you can clone it +//! let writer2 = writer.clone(); +//! writer2.send(commands::part("#museun")).unwrap(); +//! +//! // this'll shutdown the receiver. +//! writer.blocking_shutdown(); +//! +//! // any futher sends after a shutdown will result in an error +//! writer2.send(commands::raw("foobar\r\n")).unwrap_err(); +//! ``` +//! +//! [async_enc]: crate::asynchronous::Encoder +//! [stream_enc]: crate::stream::Encoder +//! +use crate::Encodable; + +use std::io::Write; + +enum Packet { + Data(Box<[u8]>), + Quit, +} + +/// An MPSC-based writer. +/// +/// This type is clonable and is thread-safe. +/// +/// This requires `feature = "writer"` +#[derive(Clone)] +pub struct MpscWriter { + tx: flume::Sender, + wait_for_it: flume::Receiver<()>, +} + +impl MpscWriter { + /// Create a writer from a synchronous [`Encoder`][enc] + /// + /// [enc]: crate::sync::Encoder + pub fn from_encoder(encoder: crate::sync::Encoder) -> Self + where + W: Write + Send + 'static, + { + let mut encoder = encoder; + let (tx, rx) = flume::unbounded(); + let (stop_tx, wait_for_it) = flume::bounded(1); + Self::_start(rx, stop_tx, move |data| encoder.encode(data)); + Self { tx, wait_for_it } + } + + /// Create a writer from an asynchronous [`Encoder`][enc] + /// + /// This requires `feature = "async"` + /// + /// [enc]: crate::asynchronous::Encoder + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub fn from_async_encoder(encoder: crate::asynchronous::Encoder) -> Self + where + W: futures_lite::io::AsyncWrite + Send + Unpin + 'static, + { + let mut encoder = encoder; + use futures_lite::future::block_on; + let (tx, rx) = flume::unbounded(); + let (stop_tx, wait_for_it) = flume::bounded(1); + Self::_start(rx, stop_tx, move |data| block_on(encoder.encode(data))); + Self { tx, wait_for_it } + } + + /// Create a writer from an asynchronous, sink-backed [`Encoder`][enc] + /// + /// This requires `feature = "sink_stream"` + /// + /// [enc]: crate::stream::Encoder + #[cfg(feature = "sink_stream")] + #[cfg_attr(docsrs, doc(cfg(feature = "sink_stream")))] + pub fn from_sink_encoder(encoder: crate::stream::Encoder) -> Self + where + IO: futures::Sink + Send + Sync + Unpin + 'static, + M: From + Send + Sync + 'static, + >::Error: std::error::Error + Send + Sync + 'static, + { + let mut encoder = encoder; + use futures_lite::future::block_on; + let (tx, rx) = flume::unbounded(); + let (stop_tx, wait_for_it) = flume::bounded(1); + Self::_start(rx, stop_tx, move |data| { + block_on(async { encoder.encode(data).await }) + }); + Self { tx, wait_for_it } + } + + /// Send this [`Encodable`] item to the writer. This does not block + /// + /// This returns an error when: + /// * the message could not be encoded + /// * the other side of the MPSC channel hung up + /// + /// the other side only hangs up if + /// * all the senders (the `MpscWriter`) are dropped + /// * [`MpscWriter::shutdown`] or [`MpscWriter::blocking_shutdown`] are called + pub fn send(&self, enc: impl Encodable) -> std::io::Result<()> { + let mut buf = Vec::new(); + enc.encode(&mut buf)?; + + if let Err(flume::TrySendError::Disconnected(..)) = + self.tx.try_send(Packet::Data(buf.into_boxed_slice())) + { + return Err(std::io::ErrorKind::BrokenPipe.into()); + } + Ok(()) + } + + /// Shutdown the writer (and connection). This blocks the current future + /// + /// This sends a `QUIT\r\n` to the connection. + /// + /// This requires `feature = "async"` + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn shutdown(self) { + let _ = self.send(crate::commands::raw("QUIT\r\n")); + let _ = self.tx.try_send(Packet::Quit); + let _ = self.wait_for_it.into_recv_async().await; + } + + /// Shutdown the writer (and connection). This blocks the current thread + /// + /// This sends a `QUIT\r\n` to the connection. + pub fn blocking_shutdown(self) { + let _ = self.send(crate::commands::raw("QUIT\r\n")); + let _ = self.tx.try_send(Packet::Quit); + let _ = self.wait_for_it.recv(); + } + + fn _start(receiver: flume::Receiver, stop: flume::Sender<()>, mut encode: F) + where + F: FnMut(Box<[u8]>) -> std::io::Result<()>, + F: Send + 'static, + { + // TODO don't do this for the async one + let _ = std::thread::spawn(move || { + let _stop = stop; + for msg in receiver { + match msg { + Packet::Data(data) => encode(data)?, + Packet::Quit => break, + } + } + std::io::Result::Ok(()) + }); + } +} + +impl std::fmt::Debug for MpscWriter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MpscWriter").finish() + } +} + +#[test] +fn assert_mpscwriter_send_sync() { + fn assert() {} + fn assert_ref(_d: &T) {} + fn assert_mut_ref(_d: &mut T) {} + + assert::(); + + let (a, _b) = flume::unbounded(); + let (_c, d) = flume::bounded(1); + + let mut w = MpscWriter { + tx: a, + wait_for_it: d, + }; + + assert_ref(&w); + assert_mut_ref(&mut w); +} diff --git a/twitchchat_async_io/Cargo.toml b/twitchchat_async_io/Cargo.toml new file mode 100644 index 0000000..bc41656 --- /dev/null +++ b/twitchchat_async_io/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "twitchchat_async_io" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using async_io" +documentation = "https://docs.rs/twitchchat_async_io/latest/twitchchat_async_io" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["async-tls"] + +[dependencies] +twitchchat = "0.15" +async-io = "1.1.10" +async-tls = { version = "0.10.0", optional = true } + +[dev-dependencies] +futures-io = "0.3.7" diff --git a/twitchchat_async_io/LICENSE-APACHE b/twitchchat_async_io/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_async_io/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_async_io/LICENSE-MIT b/twitchchat_async_io/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_async_io/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_async_io/src/lib.rs b/twitchchat_async_io/src/lib.rs new file mode 100644 index 0000000..7c592e9 --- /dev/null +++ b/twitchchat_async_io/src/lib.rs @@ -0,0 +1,91 @@ +use async_io::Async; +use std::net::{TcpStream, ToSocketAddrs}; + +pub async fn connect_twitch() -> std::io::Result> { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom(addrs: A) -> std::io::Result> +where + A: ToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, +{ + for addr in addrs.to_socket_addrs()? { + if let Ok(stream) = Async::::connect(addr).await { + return Ok(stream); + } + } + + Err(std::io::ErrorKind::AddrNotAvailable.into()) +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use async_io::Async; + use async_tls::{client::TlsStream, TlsConnector}; + use std::net::{TcpStream, ToSocketAddrs}; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: A, + domain: D, + ) -> std::io::Result>> + where + A: ToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, + D: Into + Send + Sync, + { + let domain = domain.into(); + for addr in addrs.to_socket_addrs()? { + if let Ok(stream) = Async::::connect(addr).await { + return TlsConnector::new().connect(&domain, stream).await; + } + } + + Err(std::io::ErrorKind::AddrNotAvailable.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + Sync + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_tls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_async_net/Cargo.toml b/twitchchat_async_net/Cargo.toml new file mode 100644 index 0000000..4a63f9d --- /dev/null +++ b/twitchchat_async_net/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "twitchchat_async_net" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using async_net" +documentation = "https://docs.rs/twitchchat_async_net/latest/twitchchat_async_net/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["async-tls"] + +[dependencies] +twitchchat = "0.15" +async-net = "1.4" +async-tls = { version = "0.10", optional = true } + +[dev-dependencies] +futures-io = "0.3.6" diff --git a/twitchchat_async_net/LICENSE-APACHE b/twitchchat_async_net/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_async_net/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_async_net/LICENSE-MIT b/twitchchat_async_net/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_async_net/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_async_net/src/lib.rs b/twitchchat_async_net/src/lib.rs new file mode 100644 index 0000000..1b3976d --- /dev/null +++ b/twitchchat_async_net/src/lib.rs @@ -0,0 +1,75 @@ +use async_net::{AsyncToSocketAddrs, TcpStream}; + +pub async fn connect_twitch() -> std::io::Result { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom(addrs: A) -> std::io::Result +where + A: AsyncToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, +{ + TcpStream::connect(addrs).await +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use async_net::{AsyncToSocketAddrs, TcpStream}; + use async_tls::{client::TlsStream, TlsConnector}; + + pub async fn connect_twitch() -> std::io::Result> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom(addrs: A, domain: D) -> std::io::Result> + where + A: AsyncToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, + D: Into + Send + Sync, + { + let stream = TcpStream::connect(addrs).await?; + let domain = domain.into(); + TlsConnector::new().connect(&domain, stream).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_tls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_async_std/Cargo.toml b/twitchchat_async_std/Cargo.toml new file mode 100644 index 0000000..49a8886 --- /dev/null +++ b/twitchchat_async_std/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "twitchchat_async_std" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous", "async_std"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using async_std" +documentation = "https://docs.rs/twitchchat_async_std/latest/twitchchat_async_std/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["async-tls"] + +[dependencies] +twitchchat = "0.15" +async-std = "1.6.5" +async-tls = { version = "0.10.0", optional = true } + +[dev-dependencies] +futures-io = "0.3.6" diff --git a/twitchchat_async_std/LICENSE-APACHE b/twitchchat_async_std/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_async_std/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_async_std/LICENSE-MIT b/twitchchat_async_std/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_async_std/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_async_std/src/lib.rs b/twitchchat_async_std/src/lib.rs new file mode 100644 index 0000000..762b56c --- /dev/null +++ b/twitchchat_async_std/src/lib.rs @@ -0,0 +1,75 @@ +use async_std::net::{TcpStream, ToSocketAddrs}; + +pub async fn connect_twitch() -> std::io::Result { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom(addrs: A) -> std::io::Result +where + A: ToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, +{ + TcpStream::connect(addrs).await +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use async_std::net::{TcpStream, ToSocketAddrs}; + use async_tls::{client::TlsStream, TlsConnector}; + + pub async fn connect_twitch() -> std::io::Result> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom(addrs: A, domain: D) -> std::io::Result> + where + A: ToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, + D: Into + Send + Sync, + { + let stream = TcpStream::connect(addrs).await?; + let domain = domain.into(); + TlsConnector::new().connect(&domain, stream).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + Sync + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_tls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_smol/Cargo.toml b/twitchchat_smol/Cargo.toml new file mode 100644 index 0000000..d8b598b --- /dev/null +++ b/twitchchat_smol/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "twitchchat_smol" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous", "smol"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using smol" +documentation = "https://docs.rs/twitchchat_smol/latest/twitchchat_smol/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["async-tls"] + +[dependencies] +twitchchat = "0.15" +smol = "1.2" +async-tls = { version = "0.10", optional = true } + +[dev-dependencies] +futures-io = "0.3.6" diff --git a/twitchchat_smol/LICENSE-APACHE b/twitchchat_smol/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_smol/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_smol/LICENSE-MIT b/twitchchat_smol/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_smol/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_smol/src/lib.rs b/twitchchat_smol/src/lib.rs new file mode 100644 index 0000000..8080b3d --- /dev/null +++ b/twitchchat_smol/src/lib.rs @@ -0,0 +1,75 @@ +use smol::net::{AsyncToSocketAddrs, TcpStream}; + +pub async fn connect_twitch() -> std::io::Result { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom(addrs: A) -> std::io::Result +where + A: AsyncToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, +{ + TcpStream::connect(addrs).await +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use async_tls::{client::TlsStream, TlsConnector}; + use smol::net::{AsyncToSocketAddrs, TcpStream}; + + pub async fn connect_twitch() -> std::io::Result> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom(addrs: A, domain: D) -> std::io::Result> + where + A: AsyncToSocketAddrs + Send + Sync, + A::Iter: Send + Sync, + D: Into + Send + Sync, + { + let stream = TcpStream::connect(addrs).await?; + let domain = domain.into(); + TlsConnector::new().connect(&domain, stream).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_tls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_tokio/Cargo.toml b/twitchchat_tokio/Cargo.toml new file mode 100644 index 0000000..df450e5 --- /dev/null +++ b/twitchchat_tokio/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "twitchchat_tokio" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous", "tokio"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using tokio 0.3" +documentation = "https://docs.rs/twitchchat_tokio/latest/twitchchat_tokio/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["tokio-rustls", "webpki-roots"] +native_tls = ["tokio-native-tls", "native-tls"] +openssl = ["tokio-openssl", "openssl10"] + +[dependencies] +twitchchat = "0.15" + +tokio = { version = "0.3", features = ["net"] } +tokio-util = { version = "0.4", features = ["compat"] } + +tokio-rustls = { version = "0.20", optional = true } +webpki-roots = { version = "0.20", optional = true } + +tokio-native-tls = { version = "0.2", optional = true } +native-tls = { version = "0.2", optional = true } + +tokio-openssl = { version = "0.5", optional = true } +openssl10 = { version = "0.10", optional = true, features = ["v111"], package = "openssl" } + +[dev-dependencies] +futures-io = "0.3.6" diff --git a/twitchchat_tokio/LICENSE-APACHE b/twitchchat_tokio/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_tokio/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_tokio/LICENSE-MIT b/twitchchat_tokio/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_tokio/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_tokio/src/lib.rs b/twitchchat_tokio/src/lib.rs new file mode 100644 index 0000000..bbe0e7f --- /dev/null +++ b/twitchchat_tokio/src/lib.rs @@ -0,0 +1,183 @@ +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio_util::compat::Compat; + +pub async fn connect_twitch() -> std::io::Result> { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, +) -> std::io::Result> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + TcpStream::connect(addrs) + .await + .map(Tokio02AsyncReadCompatExt::compat) +} + +#[cfg(feature = "native_tls")] +pub mod native_tls { + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_native_tls::{TlsConnector, TlsStream}; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let connector: TlsConnector = ::native_tls::TlsConnector::new() + .map_err(|err| Error::new(ErrorKind::Other, err))? + .into(); + + let stream = TcpStream::connect(addrs).await?; + connector + .connect(&domain.into(), stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + .map_err(|err| Error::new(ErrorKind::Other, err)) + } +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_rustls::{client::TlsStream, rustls::ClientConfig, webpki::DNSNameRef, TlsConnector}; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let domain = domain.into(); + let domain = DNSNameRef::try_from_ascii_str(&domain) + .map_err(|err| Error::new(ErrorKind::Other, err))?; + + let connector: TlsConnector = std::sync::Arc::new({ + let mut c = ClientConfig::new(); + c.root_store + .add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS); + c + }) + .into(); + + let stream = TcpStream::connect(addrs).await?; + connector + .connect(domain, stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + } +} + +#[cfg(feature = "openssl")] +pub mod openssl { + use ::openssl10::ssl::{SslConnector, SslMethod}; + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_openssl::SslStream; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let config = SslConnector::builder(SslMethod::tls()) + .and_then(|c| c.build().configure()) + .map_err(|err| Error::new(ErrorKind::Other, err))?; + + let stream = TcpStream::connect(addrs).await?; + tokio_openssl::connect(config, &*domain.into(), stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + .map_err(|err| Error::new(ErrorKind::Other, err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + Sync + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_rustls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } + + #[cfg(feature = "native_tls")] + #[test] + fn assert_native_tls_traits() { + assert_it(native_tls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + native_tls::connect_custom(addrs, domain).await + }); + } + + #[cfg(feature = "openssl")] + #[test] + fn assert_openssl_traits() { + assert_it(openssl::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + openssl::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_tokio02/Cargo.toml b/twitchchat_tokio02/Cargo.toml new file mode 100644 index 0000000..9b09dc4 --- /dev/null +++ b/twitchchat_tokio02/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "twitchchat_tokio02" +edition = "2018" +version = "0.1.0" +authors = ["museun "] +keywords = ["twitch", "irc", "async", "asynchronous", "tokio"] +license = "MIT OR Apache-2.0" +readme = "README.md" +description = "twitchchat connector using tokio 0.2" +documentation = "https://docs.rs/twitchchat_tokio02/latest/twitchchat_tokio02/" +repository = "https://github.com/museun/twitchchat" +categories = ["asynchronous", "network-programming"] + +[features] +rustls = ["tokio-rustls", "webpki-roots"] +native_tls = ["tokio-native-tls", "native-tls"] +openssl = ["tokio-openssl", "openssl10"] + +[dependencies] +twitchchat = "0.14" + +tokio = { version = "0.2", features = ["net"] } +tokio-util = { version = "0.3", features = ["compat"] } + +tokio-rustls = { version = "0.14", optional = true } +webpki-roots = { version = "0.20", optional = true } + +tokio-native-tls = { version = "0.1", optional = true } +native-tls = { version = "0.2", optional = true } + +tokio-openssl = { version = "0.4", optional = true } +openssl10 = { version = "0.10", optional = true, features = ["v111"], package = "openssl" } + +[dev-dependencies] +futures-io = "0.3.6" diff --git a/twitchchat_tokio02/LICENSE-APACHE b/twitchchat_tokio02/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/twitchchat_tokio02/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/twitchchat_tokio02/LICENSE-MIT b/twitchchat_tokio02/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/twitchchat_tokio02/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/twitchchat_tokio02/src/lib.rs b/twitchchat_tokio02/src/lib.rs new file mode 100644 index 0000000..4e4f9db --- /dev/null +++ b/twitchchat_tokio02/src/lib.rs @@ -0,0 +1,181 @@ +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio_util::compat::Compat; + +pub async fn connect_twitch() -> std::io::Result> { + connect_custom(twitchchat::TWITCH_IRC_ADDRESS).await +} + +pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, +) -> std::io::Result> { + use tokio_util::compat::Tokio02AsyncReadCompatExt as _; + TcpStream::connect(addrs).await.map(|s| s.compat()) +} + +#[cfg(feature = "native_tls")] +pub mod native_tls { + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_native_tls::{TlsConnector, TlsStream}; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let connector: TlsConnector = ::native_tls::TlsConnector::new() + .map_err(|err| Error::new(ErrorKind::Other, err))? + .into(); + + let stream = TcpStream::connect(addrs).await?; + connector + .connect(&domain.into(), stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + .map_err(|err| Error::new(ErrorKind::Other, err)) + } +} + +#[cfg(feature = "rustls")] +pub mod rustls { + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_rustls::{client::TlsStream, rustls::ClientConfig, webpki::DNSNameRef, TlsConnector}; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let domain = domain.into(); + let domain = DNSNameRef::try_from_ascii_str(&domain) + .map_err(|err| Error::new(ErrorKind::Other, err))?; + + let connector: TlsConnector = std::sync::Arc::new({ + let mut c = ClientConfig::new(); + c.root_store + .add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS); + c + }) + .into(); + + let stream = TcpStream::connect(addrs).await?; + connector + .connect(domain, stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + } +} + +#[cfg(feature = "openssl")] +pub mod openssl { + use ::openssl10::ssl::{SslConnector, SslMethod}; + use std::io::{Error, ErrorKind}; + use tokio::net::{TcpStream, ToSocketAddrs}; + use tokio_openssl::SslStream; + use tokio_util::compat::Compat; + + pub async fn connect_twitch() -> std::io::Result>> { + connect_custom( + twitchchat::TWITCH_IRC_ADDRESS_TLS, + twitchchat::TWITCH_TLS_DOMAIN, + ) + .await + } + + pub async fn connect_custom( + addrs: impl ToSocketAddrs + Send + Sync, + domain: impl Into + Send + Sync, + ) -> std::io::Result>> { + use tokio_util::compat::Tokio02AsyncReadCompatExt; + + let config = SslConnector::builder(SslMethod::tls()) + .and_then(|c| c.build().configure()) + .map_err(|err| Error::new(ErrorKind::Other, err))?; + + let stream = TcpStream::connect(addrs).await?; + tokio_openssl::connect(config, &*domain.into(), stream) + .await + .map(Tokio02AsyncReadCompatExt::compat) + .map_err(|err| Error::new(ErrorKind::Other, err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use futures_io::{AsyncRead, AsyncWrite}; + use std::future::Future; + + fn assert_it(_func: F) + where + F: Fn() -> Fut, + Fut: Future> + Send + Sync + 'static, + R: AsyncRead + AsyncWrite, + R: Send + Sync + Unpin + 'static, + { + } + + #[test] + fn assert_non_tls_traits() { + assert_it(connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + connect_custom(addrs).await + }); + } + + #[cfg(feature = "rustls")] + #[test] + fn assert_rustls_traits() { + assert_it(rustls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + rustls::connect_custom(addrs, domain).await + }); + } + + #[cfg(feature = "native_tls")] + #[test] + fn assert_native_tls_traits() { + assert_it(native_tls::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + native_tls::connect_custom(addrs, domain).await + }); + } + + #[cfg(feature = "openssl")] + #[test] + fn assert_openssl_traits() { + assert_it(openssl::connect_twitch); + assert_it(|| async move { + let addrs = "localhost".to_string(); + let domain = "localhost".to_string(); + openssl::connect_custom(addrs, domain).await + }); + } +} diff --git a/twitchchat_tungstenite/Cargo.toml b/twitchchat_tungstenite/Cargo.toml new file mode 100644 index 0000000..9faabf4 --- /dev/null +++ b/twitchchat_tungstenite/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "twitchchat_tungstenite" +version = "0.1.0" +authors = ["museun "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +# async-native-tls = ["async-tungstenite/async-native-tls"] +# async-std-runtime = ["async-tungstenite/async-std-runtime"] +# async-tls = ["async-tungstenite/async-tls"] +# gio-runtime = ["async-tungstenite/gio-runtime"] +# tokio-native-tls = ["async-tungstenite/tokio-native-tls"] +# tokio-openssl = ["async-tungstenite/tokio-openssl"] +# tokio-runtime = ["async-tungstenite/tokio-runtime"] +# tokio-rustls = ["async-tungstenite/tokio-rustls"] + +[dependencies] +twitchchat = "0.15" + +async-tungstenite = "0.10.0" +futures-core = "0.3.7" +futures-io = "0.3.7" +futures-sink = "0.3.7" + diff --git a/twitchchat_tungstenite/src/lib.rs b/twitchchat_tungstenite/src/lib.rs new file mode 100644 index 0000000..6a85cff --- /dev/null +++ b/twitchchat_tungstenite/src/lib.rs @@ -0,0 +1,147 @@ +use std::{ + future::Future, + io::{Error, ErrorKind}, + pin::Pin, + task::{Context, Poll}, +}; + +use async_tungstenite::tungstenite::Message; +use futures_core::Stream; +use futures_io::{AsyncRead, AsyncWrite}; +use futures_sink::Sink; + +// this type is re-exported in 'sync' 'asynchronous' and 'stream' +// but without any default features, we can grab it from 'sync' +use twitchchat::sync::DecodeError; + +pub struct WebSocketStream { + conn: async_tungstenite::WebSocketStream, + errored: bool, +} + +impl std::fmt::Debug for WebSocketStream +where + IO: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.conn.fmt(f) + } +} + +impl Stream for WebSocketStream +where + IO: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.errored { + return Poll::Ready(None); + } + + let this = self.get_mut(); + + match futures_core::ready!(Pin::new(&mut this.conn).poll_next(cx)) { + Some(Ok(Message::Text(data))) => Poll::Ready(Some(Ok(data))), + + Some(Ok(Message::Close(..))) | None => Poll::Ready(None), + + Some(Err(err)) => { + this.errored = true; + Poll::Ready(Some(Err(map_to_decode_err(err)))) + } + + _ => { + cx.waker().wake_by_ref(); + Poll::Pending + } + } + } +} + +impl Sink for WebSocketStream +where + IO: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + type Error = std::io::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = &mut self.get_mut().conn; + Pin::new(this).poll_ready(cx).map_err(map_to_io_err) + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + let this = &mut self.get_mut().conn; + Pin::new(this).start_send(item).map_err(map_to_io_err) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = &mut self.get_mut().conn; + Pin::new(this).poll_flush(cx).map_err(map_to_io_err) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = &mut self.get_mut().conn; + Pin::new(this).poll_close(cx).map_err(map_to_io_err) + } +} + +fn map_to_io_err(err: E) -> std::io::Error +where + E: std::error::Error + Send + Sync + 'static, +{ + Error::new(ErrorKind::Other, err) +} + +fn map_to_decode_err(err: E) -> DecodeError +where + E: std::error::Error + Send + Sync + 'static, +{ + DecodeError::Io(map_to_io_err(err)) +} + +/// Connect to the given address with the provided 'connect' function +/// +/// This is intentionally vague so you can provide whatever runtime/tls configuration you may need. +/// +/// For example, say you want to use `async-std` (or one if its compatible sibling crates) with `async-tls`: +/// ```no_compile,toml +/// # in your toml +/// twitchchat_tungstenite = { version = "0.1", features = ["async_tungstenite/async-std-runtime,async-tls"]} +/// ``` +/// +/// ```no_compile,rust +/// let stream = twitchchat_tungstenite::connect("irc-ws.chat.twitch.tv:443", |addr| async move { +/// let stream = async_std::net::TcpStream::connect(&addr).await?; +/// async_tls::TlsConnector::new().connect("irc-ws.chat.twitch.tv", stream).await +/// }).await.unwrap(); +/// ``` +pub async fn connect(address: &str, connect: F) -> Result, Error> +where + F: Fn(String) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + IO: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let mut iter = address.splitn(2, "://"); + + let (tcp_addr, address) = match (iter.next(), iter.next()) { + (Some(addr), None) => (addr, format!("wss://{}", addr)), + (Some(..), Some(addr)) => (addr, address.to_string()), + _ => { + return Err(Error::new( + ErrorKind::InvalidInput, + format!("invalid address: '{}'", address.escape_debug()), + )) + } + }; + + let stream = connect(tcp_addr.to_string()).await?; + let (conn, _resp) = async_tungstenite::client_async(address, stream) + .await + .map_err(|err| Error::new(ErrorKind::Other, err))?; + + Ok(WebSocketStream { + conn, + errored: false, + }) +}