├── .cargo └── config.toml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── benches ├── README.md ├── data.txt └── parse.rs ├── examples └── basic.rs ├── src ├── client.rs ├── client │ ├── conn.rs │ ├── macros.rs │ ├── read.rs │ ├── util.rs │ └── write.rs ├── common.rs ├── common │ └── channel.rs ├── irc.rs ├── irc │ ├── channel.rs │ ├── command.rs │ ├── params.rs │ ├── prefix.rs │ ├── tags.rs │ ├── tags │ │ ├── scalar.rs │ │ └── simd.rs │ ├── wide.rs │ └── wide │ │ ├── aarch64.rs │ │ ├── aarch64 │ │ └── neon.rs │ │ ├── x86_64.rs │ │ └── x86_64 │ │ ├── avx2.rs │ │ ├── avx512.rs │ │ └── sse2.rs ├── lib.rs ├── msg.rs └── msg │ ├── clear_chat.rs │ ├── clear_msg.rs │ ├── global_user_state.rs │ ├── join.rs │ ├── macros.rs │ ├── notice.rs │ ├── part.rs │ ├── ping.rs │ ├── pong.rs │ ├── privmsg.rs │ ├── room_state.rs │ ├── snapshots │ ├── tmi__msg__clear_chat__tests__parse_clearchat_ban.snap │ ├── tmi__msg__clear_chat__tests__parse_clearchat_clear.snap │ ├── tmi__msg__clear_chat__tests__parse_clearchat_timeout.snap │ ├── tmi__msg__clear_msg__tests__parse_clearmsg_action.snap │ ├── tmi__msg__clear_msg__tests__parse_clearmsg_basic.snap │ ├── tmi__msg__global_user_state__tests__parse_globaluserstate.snap │ ├── tmi__msg__join__tests__parse_join.snap │ ├── tmi__msg__notice__tests__parse_notice_basic.snap │ ├── tmi__msg__notice__tests__parse_notice_before_login.snap │ ├── tmi__msg__part__tests__parse_join.snap │ ├── tmi__msg__ping__tests__parse_ping.snap │ ├── tmi__msg__ping__tests__parse_ping_nonce.snap │ ├── tmi__msg__pong__tests__parse_ping.snap │ ├── tmi__msg__pong__tests__parse_ping_nonce.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_action_and_badges.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_basic_example.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_custom_reward_id.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_display_name_with_middle_space.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_display_name_with_trailing_space.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_emote_non_numeric_id.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_emotes_1.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_korean_display_name.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_message_with_bits.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_pinned_chat.snap │ ├── tmi__msg__privmsg__tests__parse_privmsg_reply_parent_included.snap │ ├── tmi__msg__room_state__tests__parse_room_state_basic_full.snap │ ├── tmi__msg__room_state__tests__parse_room_state_basic_full2.snap │ ├── tmi__msg__room_state__tests__parse_room_state_followers_non_zero.snap │ ├── tmi__msg__room_state__tests__parse_room_state_partial_1.snap │ ├── tmi__msg__room_state__tests__parse_room_state_partial_2.snap │ ├── tmi__msg__user_notice__tests__parse_anongiftpaidupgrade_with_promo.snap │ ├── tmi__msg__user_notice__tests__parse_anonsubgift.snap │ ├── tmi__msg__user_notice__tests__parse_anonsubmysterygift.snap │ ├── tmi__msg__user_notice__tests__parse_bitsbadgetier.snap │ ├── tmi__msg__user_notice__tests__parse_giftpaidupgrade_with_promo.snap │ ├── tmi__msg__user_notice__tests__parse_raid.snap │ ├── tmi__msg__user_notice__tests__parse_resub.snap │ ├── tmi__msg__user_notice__tests__parse_resub_no_share_streak.snap │ ├── tmi__msg__user_notice__tests__parse_ritual.snap │ ├── tmi__msg__user_notice__tests__parse_sub.snap │ ├── tmi__msg__user_notice__tests__parse_subgift_ananonymousgifter.snap │ ├── tmi__msg__user_notice__tests__parse_submysterygift.snap │ ├── tmi__msg__user_notice__tests__parse_submysterygift_ananonymousgifter.snap │ ├── tmi__msg__user_notice__tests__parse_user_notice_announcement.snap │ ├── tmi__msg__user_notice__tests__parse_user_notice_announcement_no_color.snap │ ├── tmi__msg__user_state__tests__parse_userstate.snap │ ├── tmi__msg__user_state__tests__parse_userstate_uuid_emote_set_id.snap │ └── tmi__msg__whisper__tests__parse_whisper.snap │ ├── user_notice.rs │ ├── user_state.rs │ └── whisper.rs └── xtask ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── src ├── main.rs ├── task.rs ├── task ├── changelog.rs ├── setup.rs └── test.rs └── util.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | x = "run --quiet --manifest-path ./xtask/Cargo.toml --" 3 | 4 | [target.x86_64-unknown-linux-gnu] 5 | # target-cpu=native 6 | # target-feature=+avx2 7 | rustflags = [ 8 | "-C", "target-cpu=native", 9 | # "-C", "target-feature=+avx2", 10 | ] 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: ci-${{ github.event.pull_request.number || 'main' }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | lint: 19 | name: Lint 20 | runs-on: ubuntu-22.04 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | rust: ["stable", "nightly"] 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - name: Install Rust 29 | uses: dtolnay/rust-toolchain@v1 30 | with: 31 | toolchain: ${{ matrix.rust }} 32 | components: rustfmt, clippy 33 | 34 | - name: Cache 35 | uses: Swatinem/rust-cache@v2 36 | with: 37 | key: "${{ runner.os }}-lint-${{ matrix.rust }}" 38 | 39 | - name: Lint 40 | uses: actions-rs/cargo@v1 41 | with: 42 | command: clippy 43 | args: --all-targets --all-features -- -D warnings 44 | 45 | doctest: 46 | name: Test Docs 47 | runs-on: ubuntu-22.04 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | rust: ["stable", "nightly"] 52 | steps: 53 | - uses: actions/checkout@v3 54 | 55 | - name: Install Rust 56 | uses: dtolnay/rust-toolchain@v1 57 | with: 58 | toolchain: ${{ matrix.rust }} 59 | 60 | - name: Cache 61 | uses: Swatinem/rust-cache@v2 62 | with: 63 | key: "${{ runner.os }}-doctest-${{ matrix.rust }}" 64 | 65 | - name: cargo doc (all features) 66 | uses: actions-rs/cargo@v1 67 | with: 68 | command: test 69 | args: --doc --all-features 70 | 71 | test: 72 | name: Test 73 | runs-on: ubuntu-22.04 74 | strategy: 75 | fail-fast: false 76 | matrix: 77 | rust: ["stable", "nightly"] 78 | features: 79 | [ 80 | { "key": "no-default-features", "args": "--no-default-features" }, 81 | { "key": "default-features", "args": "" }, 82 | { "key": "all-features", "args": "--all-features" }, 83 | ] 84 | steps: 85 | - uses: actions/checkout@v3 86 | 87 | - name: Install Rust 88 | uses: dtolnay/rust-toolchain@v1 89 | with: 90 | toolchain: ${{ matrix.rust }} 91 | 92 | - name: Cache 93 | uses: Swatinem/rust-cache@v2 94 | with: 95 | key: "${{ runner.os }}-test-${{ matrix.rust }}-${{ matrix.features.args }}" 96 | 97 | - name: cargo test (${{ matrix.features.key }}) 98 | uses: actions-rs/cargo@v1 99 | with: 100 | command: test 101 | args: --lib ${{ matrix.features.args }} 102 | 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | bench/ 3 | tmp/ 4 | 5 | perf.* 6 | 7 | CHANGELOG.new.md 8 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | wrap_comments = false 3 | tab_spaces = 2 4 | imports_granularity = "module" 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": ["xtask/Cargo.toml", "Cargo.toml"], 3 | // rust-analyzer target-cpu=native 4 | "rust-analyzer.cargo.extraEnv": { 5 | "RUSTFLAGS": "-C target-cpu=native" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.9.0 2 | 3 | * Support `channel` getter for `Names` and `EndOfNames` [17df3a8](https://github.com/jprochazk/tmi-rs/commit/17df3a8) 4 | * Return all params from `text` if trailer is missing [5f240c8](https://github.com/jprochazk/tmi-rs/commit/5f240c8) 5 | 6 | Full commit range: [0.8.0..bf3e823](https://github.com/jprochazk/tmi-rs/compare/0.8.0...bf3e823) 7 | 8 | ## 0.8.0 9 | 10 | * Return `User` and `SubGiftPromo` by ref [b0f0157](https://github.com/jprochazk/tmi-rs/commit/b0f0157) 11 | 12 | Full commit range: [0.7.3..3d497e1](https://github.com/jprochazk/tmi-rs/compare/0.7.3...3d497e1) 13 | 14 | ## 0.7.3 15 | 16 | * Fix typed message parsing for announcement `USERNOTICE`s without `msg-param-color` tag [38a2173](https://github.com/jprochazk/tmi-rs/commit/38a2173) by [ByteZ1337](https://github.com/ByteZ1337) 17 | * Now defaults to `PRIMARY` if not present 18 | 19 | ## 0.7.2 20 | 21 | * Fix equals in tag value regression again [1a0ba79](https://github.com/jprochazk/tmi-rs/commit/1a0ba79) 22 | 23 | Full commit range: [0.7.1..3d497e1](https://github.com/jprochazk/tmi-rs/compare/0.7.1...3d497e1) 24 | 25 | ## 0.7.1 26 | 27 | This release include another full rewrite of the tag parser, using a new approach that resulted 28 | in an average 50% performance improvement over version `0.7.0`. 29 | 30 | ``` 31 | # Baseline: f5c6c32da475a7436c0aa58e4f24874364955dcf 32 | 33 | # ARM NEON 34 | twitch/1000 35 | before: 245.584 µs 36 | after: 121.391 µs 37 | change: -49.4% 38 | 39 | # x86 AVX512 40 | twitch/1000 41 | before: 188.064 µs 42 | after: 94.260 µs 43 | change: -50.1% 44 | ``` 45 | 46 | x86 now has implementations using SSE2, AVX2, and AVX512, choosing the best available at compile time. 47 | For that reason, the crate should ideally be compiled with `RUSTFLAGS="-C target-cpu=native"`. 48 | 49 | Full commit range: [0.7.0..3b19a23](https://github.com/jprochazk/tmi-rs/compare/0.7.0...3b19a23) 50 | 51 | ## 0.7.0 52 | 53 | This release adds support for a few new tags, and changes the names of some typed message fields 54 | to better match the tag names used by Twitch. 55 | 56 | ### New tags 57 | 58 | - `pinned-chat-paid` on `Privmsg` 59 | - `msg-id` on `Privmsg` 60 | 61 | ### Breaking changes 62 | 63 | - `message_id` on `Privmsg` is now `id` 64 | - `message_id` on `ClearMsg` is now `target_message_id` 65 | - `tags` on `IrcMessage`/`IrcMessageRef` now returns string slices for keys 66 | - You can use `tmi::Tag::parse` to continue using the enum in your match statements 67 | 68 | ### Performance 69 | 70 | This release includes a full rewrite of the tag parser, which resulted in a ~15% performance improvement. 71 | 72 | Full commit range: [0.6.1..f5e539f](https://github.com/jprochazk/tmi-rs/compare/0.6.1...f5e539f) 73 | 74 | ## 0.6.1 75 | 76 | This is a bugfix release with no new features or breaking changes. 77 | 78 | ### Fixes 79 | 80 | - Under certain conditions, the SIMD version of the prefix parser would cause a panic. 81 | It has been disabled until the issue can be resolved. 82 | 83 | Full commit range: [0.6.0..36f8210](https://github.com/jprochazk/tmi-rs/compare/0.6.0...36f8210) 84 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tmi" 3 | version = "0.9.0" 4 | authors = ["jprochazk "] 5 | description = "twitch.tv messaging interface" 6 | repository = "https://github.com/jprochazk/tmi-rs" 7 | edition = "2021" 8 | license = "MIT" 9 | exclude = ["src/**/*.snap", "/.vscode", "/.github", "/benches/data.txt"] 10 | 11 | [features] 12 | default = ["simd", "client", "message-types"] 13 | 14 | # Enable strongly-typed Twitch IRC message types. 15 | message-types = ["dep:chrono", "dep:smallvec"] 16 | 17 | # Enable SIMD-accelerated parser. 18 | simd = [] 19 | 20 | # Enable the client API. 21 | client = [ 22 | "dep:futures-util", 23 | "dep:rand", 24 | "dep:rustls-native-certs", 25 | "dep:tokio", 26 | "dep:tokio-rustls", 27 | "dep:tokio-stream", 28 | "dep:tracing", 29 | ] 30 | 31 | # Enable serializing message types. 32 | serde = ["dep:serde", "chrono/serde"] 33 | 34 | [dependencies] 35 | # `message-types` feature 36 | chrono = { version = "0.4.40", optional = true, default-features = false, features = [ 37 | "std", 38 | "clock", 39 | ] } 40 | smallvec = { version = "1.11.1", optional = true, default-features = false } 41 | 42 | # `client` feature 43 | futures-util = { version = "0.3.28", optional = true } 44 | rand = { version = "0.8.5", optional = true } 45 | rustls-native-certs = { version = "0.6.3", optional = true } 46 | tokio = { version = "1.28.2", optional = true, features = [ 47 | "net", 48 | "rt", 49 | "signal", 50 | "time", 51 | "io-util", 52 | ] } 53 | tokio-rustls = { version = "0.24.1", optional = true } 54 | tokio-stream = { version = "0.1.14", optional = true, features = ["io-util"] } 55 | tracing = { version = "0.1.37", optional = true } 56 | 57 | # `serde` feature 58 | serde = { version = "1.0", optional = true, features = ["derive"] } 59 | cfg-if = "1.0.0" 60 | 61 | [dev-dependencies] 62 | mimalloc = { version = "0.1.37", default-features = false } 63 | 64 | criterion = "0.5.1" 65 | 66 | tokio = { version = "1.28.2", features = ["full"] } 67 | tracing-subscriber = "0.3.17" 68 | insta = "1.33.0" 69 | clap = { version = "4.4.6", features = ["derive"] } 70 | anyhow = "1.0.75" 71 | serde_json = "1.0.108" 72 | 73 | [profile.bench] 74 | lto = "fat" 75 | debug = true 76 | 77 | [profile.release] 78 | lto = "fat" 79 | 80 | [lib] 81 | bench = false 82 | 83 | [[bench]] 84 | name = "parse" 85 | harness = false 86 | 87 | [workspace] 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Jan Procházka 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `tmi-rs`   [![Documentation]][docs.rs] [![Latest Version]][crates.io] 2 | 3 | [docs.rs]: https://docs.rs/tmi/latest/tmi/ 4 | [crates.io]: https://crates.io/crates/tmi 5 | [Documentation]: https://img.shields.io/docsrs/tmi 6 | [Latest Version]: https://img.shields.io/crates/v/tmi.svg 7 | 8 | [Blazingly fast](#performance) 🚀 Rust 🦀 library for interacting with [twitch.tv](https://twitch.tv)'s chat interface. 9 | 10 | ## Quick Start 11 | 12 | ```text,ignore 13 | $ cargo add tmi anyhow tokio -F tokio/full 14 | ``` 15 | 16 | ```rust,no_run 17 | const CHANNELS: &[&str] = &["#forsen"]; 18 | 19 | #[tokio::main] 20 | async fn main() -> anyhow::Result<()> { 21 | let mut client = tmi::Client::anonymous().await?; 22 | client.join_all(CHANNELS).await?; 23 | 24 | loop { 25 | let msg = client.recv().await?; 26 | match msg.as_typed()? { 27 | tmi::Message::Privmsg(msg) => { 28 | println!("{}: {}", msg.sender().name(), msg.text()); 29 | } 30 | tmi::Message::Reconnect => { 31 | client.reconnect().await?; 32 | client.join_all(CHANNELS).await?; 33 | } 34 | tmi::Message::Ping(ping) => { 35 | client.pong(&ping).await?; 36 | } 37 | _ => {} 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | ## Performance 44 | 45 | Calling the library blazingly fast is done in jest, but it is true that `tmi-rs` is very fast. `tmi-rs` is part of the [twitch-irc-benchmarks](https://github.com/jprochazk/twitch-irc-benchmarks), where it is currently the fastest implementation by a significant margin (nearly 6x faster than the second best Rust implementation). This is because underlying IRC message parser is handwritten and accelerated using SIMD on x86 and ARM. For every other architecture, there is a scalar fallback. 46 | 47 | ## Acknowledgements 48 | 49 | Initially based on [dank-twitch-irc](https://github.com/robotty/dank-twitch-irc), and [twitch-irc-rs](https://github.com/robotty/twitch-irc-rs). Lots of test messages were taken directly from [twitch-irc-rs](https://github.com/robotty/twitch-irc-rs). 50 | -------------------------------------------------------------------------------- /benches/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | This repository only holds benchmarks used to guide performance work on `twitch-rs`. To see `twitch-rs` compared against other libraries (including other languages), go to [twitch-irc-benchmarks](https://github.com/jprochazk/twitch-irc-benchmarks). 4 | 5 | The benchmarks use [criterion](https://github.com/bheisler/criterion.rs). 6 | 7 | Running the benchmarks: 8 | 9 | ``` 10 | $ cargo bench 11 | ``` 12 | 13 | Running the benchmarks with simd enabled: 14 | 15 | ``` 16 | $ cargo bench -F simd 17 | ``` 18 | -------------------------------------------------------------------------------- /benches/parse.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use criterion::{black_box, BenchmarkId, Criterion}; 4 | use mimalloc::MiMalloc; 5 | use tmi::IrcMessageRef; 6 | 7 | #[global_allocator] 8 | static GLOBAL: MiMalloc = MiMalloc; 9 | 10 | fn read_input(lines: usize) -> Vec { 11 | include_str!("data.txt") 12 | .lines() 13 | .map(String::from) 14 | .take(lines) 15 | .collect::>() 16 | } 17 | 18 | fn twitch(c: &mut Criterion) { 19 | // messages representative of all of twitch dot television: 20 | let long_line = "@badge-info=;badges=;color=#008000;display-name=Allister20;emote-only=1;emotes=emotesv2_36040ff90da142938fa53287cf166373:328-334/emotesv2_e88cc46144b84732929c75512e8a2d3d:399-407/308078032:6-14/emotesv2_5d509428f6c748be8c597e9c416957b7:34-41/emotesv2_f018575b018f4c8f89a9076c7c3a47ac:70-77/emotesv2_354e23a6676f4576b51939c4b959ac33:292-298/emotesv2_a61b82c209db409db562409d5bc598c1:320-326/emotesv2_025b5879f91848d9920800592d1cb611:344-351/emotesv2_141f5da3271a4d958d684469d988365d:91-99/emotesv2_fdc6fc40fc3f4c0d8a43fedbfbd8204b:191-198/emotesv2_03a37edcf7554c968c1c10064799b0df:211-217/emotesv2_4d154487ee3844adbb724993bd905684:248-255/emotesv2_af82b76c0bfa42cabe9224690f723a4c:336-342/emotesv2_5dd1d324b05848399f6de05e409b7513:300-308/emotesv2_f912884613514b5c9a80bff390e1b5ca:381-388/emotesv2_0eb8bac56ab5443bb854d2335a12b47c:409-417/emotesv2_e9a734af13324b46bcd69bc1451251b2:43-54/304482946:157-163/emotesv2_51a4ee0cfe764f8db60cedb7168f361e:219-226/emotesv2_04ff32c657d149a5bb4bc73b38a87d3e:266-274/emotesv2_6d4b9357d5764b268ea5d2aea852fde7:276-282/emotesv2_f5518bacd19c432f872f90e5262fd2f0:0-4/emotesv2_af79a4289df346d88e8083357189662a:79-89/emotesv2_11eff6a54749464c9dfa40570dd356bd:101-108/emotesv2_3b57922dc1464a55870170158f97e1a8:165-171/emotesv2_3c1551a89eda4214b05bf60fcacac9d0:362-370/emotesv2_f366652225ca4f21bc0d3bd1fa790965:141-147/emotesv2_34b2a285e0ba49808daa933f7b143056:200-209/306112344:238-246/emotesv2_2589054b7fb64622b983367b99a65d01:257-264/emotesv2_be17775b4c9d4b3ab62ca6beeb098eee:353-360/emotesv2_c013e62f7945411da8d0fb7a03dd5e4f:182-189/emotesv2_79090f64904d4d499091ad71662cd60f:228-236/emotesv2_67cfc3d84f244644a6891e57215cf79d:419-428/emotesv2_6b0dac6e57584f84a0cc903b6afac595:56-62/emotesv2_f6d589cb12cd4f9fb9fb7741277464b1:110-117/emotesv2_7e50cd0fb15c4e26a8d8f05dbfc68aa8:119-128/emotesv2_3f8df5684f254ab99725dde938ce39b2:149-155/emotesv2_5278f8ea850942ebaf9efb88f9a16e4d:173-180/emotesv2_4397a8b926944ee19e39528decc7a23b:16-23/emotesv2_a820613d7b86414385f13f715dc4c3b3:64-68/emotesv2_fb857e88c017438ebdb28806ea50db49:284-290/emotesv2_e50c94d7ab144c8fbc70abdbd41653dd:430-437/emotesv2_f4226bd2c0334cd290ea2b064b76ef54:439-445/emotesv2_ccad3c03685a4c90bc5c2cac375e0264:25-32/emotesv2_28c5dbd1746e49bb9d97b94d0d151f4e:130-139/emotesv2_214c32a3dcd2419abac72d0c6959fd62:310-318/emotesv2_b64198cde643471cbf3a48c6346e1643:372-379/emotesv2_71685e25a9b74ed1832388b1a2e39800:390-397;first-msg=0;flags=;id=73fce4cb-b215-4bc6-acfc-caa4efd7c381;mod=0;returning-chatter=0;room-id=22484632;subscriber=0;tmi-sent-ts=1685734498590;turbo=0;user-id=169252202;user-type= :allister20!allister20@allister20.tmi.twitch.tv PRIVMSG #forsen :elis7 elisBased elisBite elisBlob elisBruh elisBusiness elisCry elisD elisDank elisDespair elisEHEHE elisElis elisFail elisFlower elisGrumpy elisHmm elisHug elisHuh elisNom elisNerd elisLove elisLost elisLookUp elisLUL elisIsee elisICANT elisOmega elisPain elisPray elisShrug elisShy elisSip elisSit elisSleep elisSmile elisYes elisWow elisWot elisWave elisUWAA elisSweat elisSubs elisSmug elisSlap elisDance elisDancy elisRockin elisSpin elisYay"; 21 | let average_line = "@badge-info=subscriber/30;badges=subscriber/24;client-nonce=0cbb6912300538decb76d5d64f7a6e60;color=#0000FF;display-name=TheShiz93;emotes=;first-msg=0;flags=;id=14aa5932-d95c-430a-9a68-a62cc9310f58;mod=0;returning-chatter=0;room-id=22484632;subscriber=1;tmi-sent-ts=1685665726948;turbo=0;user-id=48356725;user-type= :theshiz93!theshiz93@theshiz93.tmi.twitch.tv PRIVMSG #forsen :!ecount Ogey"; 22 | let short_line = "@room-id=22484632;target-user-id=916768740;tmi-sent-ts=1685713312412 :tmi.twitch.tv CLEARCHAT #forsen :forsenclone666"; 23 | 24 | let mut bench = |name: &str, line: &str| { 25 | c.bench_with_input(BenchmarkId::new("twitch", name), &line, |b, line| { 26 | b.iter(|| { 27 | black_box(IrcMessageRef::parse(line).expect("failed to parse")); 28 | }); 29 | }); 30 | }; 31 | 32 | bench("long", long_line); 33 | bench("average", average_line); 34 | bench("short", short_line); 35 | 36 | let lines = read_input(1000); 37 | 38 | c.bench_with_input(BenchmarkId::new("twitch", "1000"), &lines, |b, lines| { 39 | b.iter(|| { 40 | for line in lines { 41 | black_box(IrcMessageRef::parse(line).expect("failed to parse")); 42 | } 43 | }); 44 | }); 45 | } 46 | 47 | fn criterion() -> Criterion { 48 | Criterion::default() 49 | .configure_from_args() 50 | .warm_up_time(Duration::from_millis(100)) 51 | .measurement_time(Duration::from_secs(1)) 52 | .sample_size(50) 53 | } 54 | 55 | fn main() { 56 | let mut criterion = criterion(); 57 | twitch(&mut criterion); 58 | criterion.final_summary(); 59 | } 60 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | //! Basic usage example. 2 | //! 3 | //! ```text,ignore 4 | //! $ cargo run --example basic -- \ 5 | //! --login your_user_name \ 6 | //! --token oauth:yfvzjqb705z12hrhy1zkwa9xt7v662 \ 7 | //! --channel #forsen 8 | //! ``` 9 | 10 | use anyhow::Result; 11 | use clap::Parser; 12 | use tokio::select; 13 | use tokio::signal::ctrl_c; 14 | 15 | #[derive(Parser)] 16 | #[command(author, version)] 17 | struct Args { 18 | /// Login username 19 | #[arg(long)] 20 | login: Option, 21 | 22 | /// Login oauth2 token 23 | #[arg(long)] 24 | token: Option, 25 | 26 | /// Channels to join 27 | #[arg(long)] 28 | channel: Vec, 29 | } 30 | 31 | #[tokio::main(flavor = "current_thread")] 32 | async fn main() -> Result<()> { 33 | tracing_subscriber::fmt::init(); 34 | 35 | let args = Args::parse(); 36 | 37 | let credentials = match args.login.zip(args.token) { 38 | Some((login, token)) => tmi::client::Credentials::new(login, token), 39 | None => tmi::client::Credentials::anon(), 40 | }; 41 | let channels = args.channel; 42 | 43 | println!("Connecting as {}", credentials.login()); 44 | let mut client = tmi::Client::builder() 45 | .credentials(credentials) 46 | .connect() 47 | .await?; 48 | 49 | client.join_all(&channels).await?; 50 | println!("Joined the following channels: {}", channels.join(", ")); 51 | 52 | select! { 53 | _ = ctrl_c() => { 54 | Ok(()) 55 | } 56 | res = tokio::spawn(run(client, channels)) => { 57 | res? 58 | } 59 | } 60 | } 61 | 62 | async fn run(mut client: tmi::Client, channels: Vec) -> Result<()> { 63 | loop { 64 | let msg = client.recv().await?; 65 | match msg.as_typed()? { 66 | tmi::Message::Privmsg(msg) => on_msg(&mut client, msg).await?, 67 | tmi::Message::Reconnect => { 68 | client.reconnect().await?; 69 | client.join_all(&channels).await?; 70 | } 71 | tmi::Message::Ping(ping) => client.pong(&ping).await?, 72 | _ => {} 73 | }; 74 | } 75 | } 76 | 77 | async fn on_msg(client: &mut tmi::Client, msg: tmi::Privmsg<'_>) -> Result<()> { 78 | println!("{}: {}", msg.sender().name(), msg.text()); 79 | 80 | if client.credentials().is_anon() { 81 | return Ok(()); 82 | } 83 | 84 | if !msg.text().starts_with("!yo") { 85 | return Ok(()); 86 | } 87 | 88 | client 89 | .privmsg(msg.channel(), "yo") 90 | .reply_to(msg.id()) 91 | .send() 92 | .await?; 93 | 94 | println!("< {} yo", msg.channel()); 95 | 96 | Ok(()) 97 | } 98 | -------------------------------------------------------------------------------- /src/client/conn.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::io; 3 | use std::sync::Arc; 4 | use tokio::net::TcpStream; 5 | use tokio_rustls::client::TlsStream; 6 | use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerName}; 7 | use tokio_rustls::{rustls, TlsConnector}; 8 | 9 | pub const HOST: &str = "irc.chat.twitch.tv"; 10 | pub const PORT: u16 = 6697; 11 | 12 | pub type Stream = TlsStream; 13 | 14 | pub async fn open(config: TlsConfig) -> Result { 15 | trace!(?config, "opening tls stream to twitch"); 16 | Ok( 17 | TlsConnector::from(config.client()) 18 | .connect( 19 | config.server_name(), 20 | TcpStream::connect((HOST, PORT)).await?, 21 | ) 22 | .await?, 23 | ) 24 | } 25 | 26 | /// Failed to open a TLS stream. 27 | #[derive(Debug)] 28 | pub enum OpenStreamError { 29 | /// The underlying I/O operation failed. 30 | Io(io::Error), 31 | } 32 | 33 | impl From for OpenStreamError { 34 | fn from(value: io::Error) -> Self { 35 | Self::Io(value) 36 | } 37 | } 38 | 39 | impl Display for OpenStreamError { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | match self { 42 | OpenStreamError::Io(e) => write!(f, "failed to open tls stream: {e}"), 43 | } 44 | } 45 | } 46 | 47 | impl std::error::Error for OpenStreamError {} 48 | 49 | #[derive(Debug, Clone)] 50 | pub struct TlsConfig { 51 | config: Arc, 52 | server_name: ServerName, 53 | } 54 | 55 | impl TlsConfig { 56 | pub fn load(server_name: ServerName) -> Result { 57 | trace!("loading native certificates"); 58 | let mut root_store = RootCertStore::empty(); 59 | let native_certs = rustls_native_certs::load_native_certs()?; 60 | for cert in native_certs { 61 | root_store.add(&rustls::Certificate(cert.0))?; 62 | } 63 | let config = rustls::ClientConfig::builder() 64 | .with_safe_defaults() 65 | .with_root_certificates(root_store) 66 | .with_no_client_auth(); 67 | Ok(Self { 68 | config: Arc::new(config), 69 | server_name, 70 | }) 71 | } 72 | 73 | pub fn client(&self) -> Arc { 74 | self.config.clone() 75 | } 76 | 77 | pub fn server_name(&self) -> ServerName { 78 | self.server_name.clone() 79 | } 80 | } 81 | 82 | /// Failed to load the TLS config. 83 | #[derive(Debug)] 84 | pub enum TlsConfigError { 85 | /// The underlying I/O operation failed. 86 | Io(io::Error), 87 | /// Failed to load certificates. 88 | Tls(rustls::Error), 89 | } 90 | 91 | impl From for TlsConfigError { 92 | fn from(value: io::Error) -> Self { 93 | Self::Io(value) 94 | } 95 | } 96 | 97 | impl From for TlsConfigError { 98 | fn from(value: rustls::Error) -> Self { 99 | Self::Tls(value) 100 | } 101 | } 102 | 103 | impl Display for TlsConfigError { 104 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 105 | match self { 106 | TlsConfigError::Io(e) => write!(f, "tls config error: {e}"), 107 | TlsConfigError::Tls(e) => write!(f, "tls config error: {e}"), 108 | } 109 | } 110 | } 111 | 112 | impl std::error::Error for TlsConfigError {} 113 | -------------------------------------------------------------------------------- /src/client/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! with_scratch { 2 | ($client:ident, |$scratch:ident| $body:block) => {{ 3 | use ::std::fmt::Write; 4 | let mut scratch = std::mem::take(&mut $client.scratch); 5 | let $scratch = &mut scratch; 6 | let result = { $body }; 7 | scratch.clear(); 8 | $client.scratch = scratch; 9 | result 10 | }}; 11 | } 12 | -------------------------------------------------------------------------------- /src/client/read.rs: -------------------------------------------------------------------------------- 1 | use super::{conn, Client}; 2 | use crate::irc::IrcMessage; 3 | use futures_util::stream::Fuse; 4 | use std::fmt::Display; 5 | use tokio::io; 6 | use tokio::io::{BufReader, ReadHalf}; 7 | use tokio_stream::wrappers::LinesStream; 8 | use tokio_stream::StreamExt; 9 | 10 | pub type ReadStream = Fuse>>>; 11 | 12 | impl Client { 13 | /// Read a single [`IrcMessage`] from the underlying stream. 14 | pub async fn recv(&mut self) -> Result { 15 | if let Some(message) = self.reader.next().await { 16 | let message = message?; 17 | Ok(IrcMessage::parse(&message).ok_or(RecvError::Parse(message))?) 18 | } else { 19 | Err(RecvError::StreamClosed) 20 | } 21 | } 22 | } 23 | 24 | /// Failed to receive a message. 25 | #[derive(Debug)] 26 | pub enum RecvError { 27 | /// The underlying I/O operation failed. 28 | Io(io::Error), 29 | 30 | /// Failed to parse the message. 31 | Parse(String), 32 | 33 | /// The stream was closed. 34 | StreamClosed, 35 | } 36 | 37 | impl RecvError { 38 | /// Returns `true` if this `recv` failed due to a disconnect of some kind. 39 | pub fn is_disconnect(&self) -> bool { 40 | match self { 41 | RecvError::StreamClosed => true, 42 | RecvError::Io(e) 43 | if matches!( 44 | e.kind(), 45 | io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionAborted | io::ErrorKind::TimedOut 46 | ) => 47 | { 48 | true 49 | } 50 | _ => false, 51 | } 52 | } 53 | } 54 | 55 | impl From for RecvError { 56 | fn from(value: io::Error) -> Self { 57 | Self::Io(value) 58 | } 59 | } 60 | 61 | impl Display for RecvError { 62 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 | match self { 64 | RecvError::Io(e) => write!(f, "failed to read message: {e}"), 65 | RecvError::Parse(s) => write!(f, "failed to read message: invalid message `{s}`"), 66 | RecvError::StreamClosed => write!(f, "failed to read message: stream closed"), 67 | } 68 | } 69 | } 70 | 71 | impl std::error::Error for RecvError {} 72 | -------------------------------------------------------------------------------- /src/client/util.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::time::Duration; 3 | 4 | pub trait Timeout: Sized { 5 | fn timeout(self, duration: Duration) -> tokio::time::Timeout; 6 | } 7 | 8 | impl Timeout for F 9 | where 10 | F: Future, 11 | { 12 | fn timeout(self, duration: Duration) -> tokio::time::Timeout { 13 | tokio::time::timeout(duration, self) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/client/write.rs: -------------------------------------------------------------------------------- 1 | use super::{conn, Client}; 2 | use crate::common::JoinIter; 3 | use std::convert::Infallible; 4 | use std::fmt::Display; 5 | use tokio::io; 6 | use tokio::io::{AsyncWriteExt, WriteHalf}; 7 | 8 | pub type WriteStream = WriteHalf; 9 | 10 | pub struct Privmsg<'a> { 11 | client: &'a mut Client, 12 | channel: &'a str, 13 | text: &'a str, 14 | reply_parent_msg_id: Option<&'a str>, 15 | client_nonce: Option<&'a str>, 16 | } 17 | 18 | struct Tag<'a> { 19 | key: &'a str, 20 | value: &'a str, 21 | } 22 | 23 | impl<'a> std::fmt::Display for Tag<'a> { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | let Self { key, value } = self; 26 | // TODO: handle escaping 27 | write!(f, "{key}={value}") 28 | } 29 | } 30 | 31 | impl<'a> Privmsg<'a> { 32 | pub fn reply_to(mut self, reply_parent_msg_id: &'a str) -> Self { 33 | self.reply_parent_msg_id = Some(reply_parent_msg_id); 34 | self 35 | } 36 | 37 | pub fn client_nonce(mut self, value: &'a str) -> Self { 38 | self.client_nonce = Some(value); 39 | self 40 | } 41 | 42 | pub async fn send(self) -> Result<(), SendError> { 43 | let Self { 44 | client, 45 | channel, 46 | text, 47 | reply_parent_msg_id, 48 | client_nonce, 49 | } = self; 50 | 51 | with_scratch!(client, |f| { 52 | let has_tags = reply_parent_msg_id.is_some() || client_nonce.is_some(); 53 | if has_tags { 54 | let reply_parent_msg_id = reply_parent_msg_id.map(|value| Tag { 55 | key: "reply-parent-msg-id", 56 | value, 57 | }); 58 | let client_nonce = client_nonce.map(|value| Tag { 59 | key: "client-nonce", 60 | value, 61 | }); 62 | let tags = reply_parent_msg_id 63 | .iter() 64 | .chain(client_nonce.iter()) 65 | .join(';'); 66 | let _ = write!(f, "@{tags} "); 67 | } 68 | let _ = write!(f, "PRIVMSG {channel} :{text}\r\n"); 69 | client.send_raw(f.as_str()).await 70 | }) 71 | } 72 | } 73 | 74 | impl Client { 75 | /// Send a raw string through the TCP socket. 76 | /// 77 | /// ⚠ This call is not rate limited in any way. 78 | /// 79 | /// ⚠ The string MUST be terminated by `\r\n`. 80 | pub async fn send_raw<'a, S>(&mut self, s: S) -> Result<(), SendError> 81 | where 82 | S: TryInto>, 83 | SendError: From, 84 | { 85 | let RawMessage { data } = s.try_into()?; 86 | trace!(data, "sending message"); 87 | self.writer.write_all(data.as_bytes()).await?; 88 | Ok(()) 89 | } 90 | 91 | /// Create a `privmsg` from a `channel` and `text`. 92 | /// 93 | /// ```rust,no_run 94 | /// # async fn _test() -> anyhow::Result<()> { 95 | /// # let msg: tmi::Privmsg<'_> = todo!(); 96 | /// # let mut client: tmi::Client = todo!(); 97 | /// client 98 | /// .privmsg(msg.channel(), "yo") 99 | /// .reply_to(msg.id()) 100 | /// .send() 101 | /// .await?; 102 | /// # Ok(()) 103 | /// # } 104 | /// ``` 105 | /// 106 | /// You can specify additional properties using the builder methods: 107 | /// - `reply_to`: to specify a `reply-parent-msg-id` tag, which makes this privmsg a reply to another message. 108 | /// - `client_nonce`: to identify the message in the `Notice` which Twitch may send as a response to this message. 109 | pub fn privmsg<'a>(&'a mut self, channel: &'a str, text: &'a str) -> Privmsg<'a> { 110 | Privmsg { 111 | client: self, 112 | channel, 113 | text, 114 | reply_parent_msg_id: None, 115 | client_nonce: None, 116 | } 117 | } 118 | 119 | /// Send a `PING` command with an optional `nonce` argument. 120 | pub async fn ping(&mut self, nonce: &str) -> Result<(), SendError> { 121 | with_scratch!(self, |f| { 122 | let _ = write!(f, "PING :{nonce}\r\n"); 123 | self.send_raw(f.as_str()).await 124 | }) 125 | } 126 | 127 | /// Send a `PONG` command in response to a `PING`. 128 | pub async fn pong(&mut self, ping: &crate::Ping<'_>) -> Result<(), SendError> { 129 | with_scratch!(self, |f| { 130 | if let Some(nonce) = ping.nonce() { 131 | let _ = write!(f, "PONG :{nonce}\r\n"); 132 | } else { 133 | let _ = write!(f, "PONG\r\n"); 134 | } 135 | self.send_raw(f.as_str()).await 136 | }) 137 | } 138 | 139 | /// Send a `JOIN` command. 140 | /// 141 | /// ⚠ This call is not rate limited in any way. 142 | /// 143 | /// ⚠ `channel` MUST be a valid channel name prefixed by `#`. 144 | pub async fn join(&mut self, channel: impl AsRef) -> Result<(), SendError> { 145 | with_scratch!(self, |f| { 146 | let channel = Channel(channel); 147 | let _ = write!(f, "JOIN {channel}\r\n"); 148 | Ok(self.send_raw(f.as_str()).await?) 149 | }) 150 | } 151 | 152 | /// Send a `JOIN` command. 153 | /// 154 | /// ⚠ This call is not rate limited in any way. 155 | /// 156 | /// ⚠ Each channel in `channels` MUST be a valid channel name 157 | /// prefixed by `#`. 158 | pub async fn join_all(&mut self, channels: I) -> Result<(), SendError> 159 | where 160 | I: IntoIterator, 161 | C: AsRef, 162 | { 163 | with_scratch!(self, |f| { 164 | let _ = f.write_str("JOIN "); 165 | let mut channels = channels.into_iter().map(Channel); 166 | if let Some(channel) = channels.next() { 167 | let _ = write!(f, "{channel}"); 168 | } 169 | for channel in channels { 170 | let _ = write!(f, ",{channel}"); 171 | } 172 | let _ = f.write_str("\r\n"); 173 | self.send_raw(f.as_str()).await 174 | }) 175 | } 176 | } 177 | 178 | struct Channel(S); 179 | 180 | impl> Display for Channel { 181 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 182 | let channel = self.0.as_ref(); 183 | if !channel.starts_with('#') { 184 | write!(f, "#")?; 185 | } 186 | write!(f, "{channel}") 187 | } 188 | } 189 | 190 | /// Failed to send a message. 191 | #[derive(Debug)] 192 | pub enum SendError { 193 | /// The underlying I/O operation failed. 194 | Io(io::Error), 195 | 196 | /// The stream was closed. 197 | StreamClosed, 198 | 199 | /// Attempted to send an invalid message. 200 | InvalidMessage(InvalidMessage), 201 | } 202 | 203 | impl From for SendError { 204 | fn from(value: io::Error) -> Self { 205 | Self::Io(value) 206 | } 207 | } 208 | 209 | impl From for SendError { 210 | fn from(value: InvalidMessage) -> Self { 211 | Self::InvalidMessage(value) 212 | } 213 | } 214 | 215 | impl From for SendError { 216 | fn from(_: Infallible) -> Self { 217 | unreachable!() 218 | } 219 | } 220 | 221 | impl Display for SendError { 222 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 223 | match self { 224 | SendError::Io(e) => write!(f, "failed to write message: {e}"), 225 | SendError::StreamClosed => write!(f, "failed to write message: stream closed"), 226 | SendError::InvalidMessage(inner) => write!( 227 | f, 228 | "failed to write message: message was incorrectly formatted, {inner}" 229 | ), 230 | } 231 | } 232 | } 233 | 234 | impl std::error::Error for SendError {} 235 | 236 | /// Bypass the same-message slow mode requirement. 237 | #[derive(Clone, Copy, Debug, PartialEq)] 238 | pub struct SameMessageBypass { 239 | append: bool, 240 | } 241 | 242 | impl SameMessageBypass { 243 | /// Get the current value. 244 | /// 245 | /// This is meant to be appended to the end of the message, before the `\r\n`. 246 | pub fn get(&mut self) -> &'static str { 247 | let out = match self.append { 248 | false => "", 249 | true => { 250 | concat!(" ", "⠀") 251 | } 252 | }; 253 | self.append = !self.append; 254 | out 255 | } 256 | } 257 | 258 | #[allow(clippy::derivable_impls)] 259 | impl Default for SameMessageBypass { 260 | fn default() -> Self { 261 | SameMessageBypass { append: false } 262 | } 263 | } 264 | 265 | /// An IRC message, terminated by `\r\n`. 266 | pub struct RawMessage<'a> { 267 | data: &'a str, 268 | } 269 | 270 | #[derive(Debug)] 271 | pub struct InvalidMessage; 272 | impl std::fmt::Display for InvalidMessage { 273 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 274 | f.write_str("not terminated by \"\\r\\n\"") 275 | } 276 | } 277 | impl std::error::Error for InvalidMessage {} 278 | 279 | impl<'a> TryFrom<&'a str> for RawMessage<'a> { 280 | type Error = InvalidMessage; 281 | 282 | fn try_from(data: &'a str) -> Result { 283 | match data.ends_with("\r\n") { 284 | true => Ok(RawMessage { data }), 285 | false => Err(InvalidMessage), 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/common.rs: -------------------------------------------------------------------------------- 1 | //! Random types and utilties used by the library. 2 | 3 | use std::cell::RefCell; 4 | use std::fmt::Debug; 5 | 6 | /// This type is like a [`Range`][std::ops::Range], 7 | /// only smaller, and also implements `Copy`. 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 9 | pub struct Span { 10 | /// The start index, inclusive. 11 | pub start: u32, 12 | 13 | /// The end index, exclusive. 14 | pub end: u32, 15 | } 16 | 17 | impl Span { 18 | #[allow(dead_code)] 19 | #[doc(hidden)] 20 | #[inline] 21 | pub(crate) fn get<'src>(&self, src: &'src str) -> &'src str { 22 | &src[*self] 23 | } 24 | } 25 | 26 | impl From> for Span { 27 | #[inline] 28 | fn from(value: std::ops::Range) -> Self { 29 | Span { 30 | start: value.start as u32, 31 | end: value.end as u32, 32 | } 33 | } 34 | } 35 | 36 | impl From for std::ops::Range { 37 | #[inline] 38 | fn from(value: Span) -> Self { 39 | value.start as usize..value.end as usize 40 | } 41 | } 42 | 43 | impl std::ops::Index for str { 44 | type Output = >>::Output; 45 | 46 | #[inline] 47 | fn index(&self, index: Span) -> &Self::Output { 48 | self.index(std::ops::Range::from(index)) 49 | } 50 | } 51 | 52 | #[doc(hidden)] 53 | pub struct Join(RefCell>, S); 54 | 55 | impl std::fmt::Display for Join 56 | where 57 | // TODO: get rid of this `Clone` bound by doing `peek` 58 | // manually 59 | I: Iterator, 60 | ::Item: std::fmt::Display, 61 | S: std::fmt::Display, 62 | { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | let Some(iter) = self.0.borrow_mut().take() else { 65 | return Err(std::fmt::Error); 66 | }; 67 | 68 | let sep = &self.1; 69 | let mut peekable = iter.peekable(); 70 | while let Some(item) = peekable.next() { 71 | write!(f, "{item}")?; 72 | if peekable.peek().is_some() { 73 | write!(f, "{sep}")?; 74 | } 75 | } 76 | Ok(()) 77 | } 78 | } 79 | 80 | #[doc(hidden)] 81 | pub trait JoinIter: Sized { 82 | fn join(self, sep: Sep) -> Join; 83 | } 84 | 85 | impl JoinIter for Iter 86 | where 87 | Iter: Sized + Iterator, 88 | { 89 | fn join(self, sep: Sep) -> Join { 90 | Join(RefCell::new(Some(self)), sep) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/common/channel.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Borrow; 2 | use std::ops::Deref; 3 | 4 | /// Channel name known to be prefixed by `#`. 5 | #[derive(PartialEq, Eq, PartialOrd, Ord, Hash)] 6 | #[repr(transparent)] 7 | pub struct ChannelRef(str); 8 | 9 | impl ChannelRef { 10 | /// Get the string value of the channel name. 11 | pub fn as_str(&self) -> &str { 12 | &self.0 13 | } 14 | 15 | /// Parse a string into a channel name. 16 | /// 17 | /// The channel name must begin with a `#` character. 18 | pub fn parse(s: &str) -> Result<&Self, InvalidChannelName> { 19 | match s.starts_with('#') { 20 | true => Ok(Self::from_unchecked(s)), 21 | false => Err(InvalidChannelName), 22 | } 23 | } 24 | 25 | pub(crate) fn from_unchecked(s: &str) -> &Self { 26 | // # Safety: 27 | // - `Self` is `repr(transparent)` and only holds a single `str` field, 28 | // therefore the layout of `Self` is the same as `str`, and it's 29 | // safe to transmute between the two 30 | unsafe { std::mem::transmute(s) } 31 | } 32 | } 33 | 34 | impl Deref for ChannelRef { 35 | type Target = str; 36 | 37 | fn deref(&self) -> &Self::Target { 38 | &self.0 39 | } 40 | } 41 | 42 | impl AsRef for ChannelRef { 43 | fn as_ref(&self) -> &str { 44 | &self.0 45 | } 46 | } 47 | 48 | impl AsRef for ChannelRef { 49 | fn as_ref(&self) -> &ChannelRef { 50 | self 51 | } 52 | } 53 | 54 | impl Borrow for ChannelRef { 55 | fn borrow(&self) -> &str { 56 | &self.0 57 | } 58 | } 59 | 60 | impl std::fmt::Debug for ChannelRef { 61 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 62 | f.debug_tuple("Channel").field(&self.as_str()).finish() 63 | } 64 | } 65 | 66 | impl std::fmt::Display for ChannelRef { 67 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 68 | f.write_str(&self.0) 69 | } 70 | } 71 | 72 | impl ToOwned for ChannelRef { 73 | type Owned = Channel; 74 | 75 | fn to_owned(&self) -> Self::Owned { 76 | Channel::from_unchecked(self.as_str().to_owned()) 77 | } 78 | } 79 | 80 | /// Channel name known to be prefixed by `#`. 81 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 82 | #[repr(transparent)] 83 | pub struct Channel(String); 84 | 85 | impl Channel { 86 | /// Get the string value of the channel name. 87 | pub fn as_str(&self) -> &str { 88 | self.0.as_str() 89 | } 90 | 91 | /// Parse a string into a channel name. 92 | /// 93 | /// The channel name must begin with a `#` character. 94 | pub fn parse(s: String) -> Result { 95 | match s.starts_with('#') { 96 | true => Ok(Self(s)), 97 | false => Err(InvalidChannelName), 98 | } 99 | } 100 | 101 | pub(crate) fn from_unchecked(s: String) -> Self { 102 | Self(s) 103 | } 104 | } 105 | 106 | impl Deref for Channel { 107 | type Target = String; 108 | 109 | fn deref(&self) -> &Self::Target { 110 | &self.0 111 | } 112 | } 113 | 114 | impl AsRef for Channel { 115 | fn as_ref(&self) -> &str { 116 | self.0.as_ref() 117 | } 118 | } 119 | 120 | impl AsRef for Channel { 121 | fn as_ref(&self) -> &ChannelRef { 122 | ChannelRef::from_unchecked(self.0.as_str()) 123 | } 124 | } 125 | 126 | impl Borrow for Channel { 127 | fn borrow(&self) -> &str { 128 | self.0.borrow() 129 | } 130 | } 131 | 132 | impl Borrow for Channel { 133 | fn borrow(&self) -> &ChannelRef { 134 | ChannelRef::from_unchecked(self.0.borrow()) 135 | } 136 | } 137 | 138 | impl std::fmt::Display for Channel { 139 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 140 | f.write_str(&self.0) 141 | } 142 | } 143 | 144 | /// Failed to parse a channel name. 145 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 146 | pub struct InvalidChannelName; 147 | impl std::fmt::Display for InvalidChannelName { 148 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 149 | f.write_str("channel name is missing \"#\" prefix") 150 | } 151 | } 152 | impl std::error::Error for InvalidChannelName {} 153 | 154 | static_assert_send!(ChannelRef); 155 | static_assert_sync!(ChannelRef); 156 | 157 | static_assert_send!(Channel); 158 | static_assert_sync!(Channel); 159 | 160 | #[cfg(test)] 161 | mod tests { 162 | use super::*; 163 | 164 | #[test] 165 | fn parse_channel() { 166 | assert_eq!( 167 | ChannelRef::parse("#test"), 168 | Ok(ChannelRef::from_unchecked("#test")) 169 | ); 170 | assert_eq!(ChannelRef::parse("test"), Err(InvalidChannelName)); 171 | assert_eq!( 172 | Channel::parse("#test".into()), 173 | Ok(Channel::from_unchecked("#test".into())) 174 | ); 175 | assert_eq!(Channel::parse("test".into()), Err(InvalidChannelName)); 176 | } 177 | } 178 | 179 | #[cfg(feature = "serde")] 180 | mod _serde { 181 | use super::*; 182 | use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; 183 | 184 | impl<'de: 'src, 'src> Deserialize<'de> for &'src ChannelRef { 185 | fn deserialize(deserializer: D) -> Result 186 | where 187 | D: Deserializer<'de>, 188 | { 189 | ChannelRef::parse(<&str as Deserialize<'de>>::deserialize(deserializer)?) 190 | .map_err(de::Error::custom) 191 | } 192 | } 193 | 194 | impl<'ser> Serialize for &'ser ChannelRef { 195 | fn serialize(&self, serializer: S) -> Result 196 | where 197 | S: Serializer, 198 | { 199 | <&str as Serialize>::serialize(&self.as_str(), serializer) 200 | } 201 | } 202 | 203 | impl<'de> Deserialize<'de> for Channel { 204 | fn deserialize(deserializer: D) -> Result 205 | where 206 | D: Deserializer<'de>, 207 | { 208 | Channel::parse(>::deserialize(deserializer)?) 209 | .map_err(de::Error::custom) 210 | } 211 | } 212 | 213 | impl Serialize for Channel { 214 | fn serialize(&self, serializer: S) -> Result 215 | where 216 | S: Serializer, 217 | { 218 | ::serialize(&self.0, serializer) 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/irc/channel.rs: -------------------------------------------------------------------------------- 1 | use crate::common::Span; 2 | 3 | /// #channel 4 | #[inline(always)] 5 | pub(super) fn parse(src: &str, pos: &mut usize) -> Option { 6 | match src[*pos..].starts_with('#') { 7 | true => { 8 | let start = *pos; 9 | match src[start..].find(' ') { 10 | Some(end) => { 11 | let end = start + end; 12 | *pos = end + 1; 13 | Some(Span::from(start..end)) 14 | } 15 | None => { 16 | let end = src.len(); 17 | *pos = end; 18 | Some(Span::from(start..end)) 19 | } 20 | } 21 | } 22 | false => None, 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::*; 29 | 30 | #[test] 31 | fn channel() { 32 | let data = "#channel "; 33 | let mut pos = 0; 34 | 35 | let channel = parse(data, &mut pos).unwrap(); 36 | assert_eq!(channel.get(data), "#channel"); 37 | assert_eq!(&data[pos..], ""); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/irc/command.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::common::Span; 4 | 5 | #[derive(Clone, Copy)] 6 | pub(super) enum RawCommand { 7 | Ping, 8 | Pong, 9 | Join, 10 | Part, 11 | Privmsg, 12 | Whisper, 13 | Clearchat, 14 | Clearmsg, 15 | GlobalUserState, 16 | Notice, 17 | Reconnect, 18 | RoomState, 19 | UserNotice, 20 | UserState, 21 | Capability, 22 | RplWelcome, 23 | RplYourHost, 24 | RplCreated, 25 | RplMyInfo, 26 | RplNamReply, 27 | RplEndOfNames, 28 | RplMotd, 29 | RplMotdStart, 30 | RplEndOfMotd, 31 | Other(Span), 32 | } 33 | 34 | impl RawCommand { 35 | #[inline] 36 | pub(super) fn get<'src>(&self, src: &'src str) -> Command<'src> { 37 | match self { 38 | RawCommand::Ping => Command::Ping, 39 | RawCommand::Pong => Command::Pong, 40 | RawCommand::Join => Command::Join, 41 | RawCommand::Part => Command::Part, 42 | RawCommand::Privmsg => Command::Privmsg, 43 | RawCommand::Whisper => Command::Whisper, 44 | RawCommand::Clearchat => Command::ClearChat, 45 | RawCommand::Clearmsg => Command::ClearMsg, 46 | RawCommand::GlobalUserState => Command::GlobalUserState, 47 | RawCommand::Notice => Command::Notice, 48 | RawCommand::Reconnect => Command::Reconnect, 49 | RawCommand::RoomState => Command::RoomState, 50 | RawCommand::UserNotice => Command::UserNotice, 51 | RawCommand::UserState => Command::UserState, 52 | RawCommand::Capability => Command::Capability, 53 | RawCommand::RplWelcome => Command::RplWelcome, 54 | RawCommand::RplYourHost => Command::RplYourHost, 55 | RawCommand::RplCreated => Command::RplCreated, 56 | RawCommand::RplMyInfo => Command::RplMyInfo, 57 | RawCommand::RplNamReply => Command::RplNames, 58 | RawCommand::RplEndOfNames => Command::RplEndOfNames, 59 | RawCommand::RplMotd => Command::RplMotd, 60 | RawCommand::RplMotdStart => Command::RplMotdStart, 61 | RawCommand::RplEndOfMotd => Command::RplEndOfMotd, 62 | RawCommand::Other(span) => Command::Other(&src[*span]), 63 | } 64 | } 65 | } 66 | 67 | /// A Twitch IRC command. 68 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 69 | pub enum Command<'src> { 70 | /// Ping the peer 71 | Ping, 72 | /// The peer's response to a [`Command::Ping`] 73 | Pong, 74 | /// Join a channel 75 | Join, 76 | /// Leave a channel 77 | Part, 78 | /// Send a message to a channel 79 | Privmsg, 80 | /// Send a private message to a user 81 | Whisper, 82 | /// Purge a user's messages in a channel 83 | ClearChat, 84 | /// Remove a single message 85 | ClearMsg, 86 | /// Sent upon successful authentication (PASS/NICK command) 87 | GlobalUserState, 88 | /// General notices from the server 89 | Notice, 90 | /// Rejoins channels after a restart 91 | Reconnect, 92 | /// Identifies the channel's chat settings 93 | RoomState, 94 | /// Announces Twitch-specific events to the channel 95 | UserNotice, 96 | /// Identifies a user's chat settings or properties 97 | UserState, 98 | /// Requesting an IRC capability 99 | Capability, 100 | // Numeric commands 101 | /// `001` 102 | RplWelcome, 103 | /// `002` 104 | RplYourHost, 105 | /// `003` 106 | RplCreated, 107 | /// `004` 108 | RplMyInfo, 109 | /// `353` 110 | RplNames, 111 | /// `366` 112 | RplEndOfNames, 113 | /// `372` 114 | RplMotd, 115 | /// `375` 116 | RplMotdStart, 117 | /// `376` 118 | RplEndOfMotd, 119 | /// Unknown command 120 | Other(&'src str), 121 | } 122 | 123 | impl<'src> Display for Command<'src> { 124 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 125 | f.write_str(self.as_str()) 126 | } 127 | } 128 | 129 | impl<'src> Command<'src> { 130 | /// Get the string value of the [`Command`]. 131 | pub fn as_str(&self) -> &'src str { 132 | use Command::*; 133 | match self { 134 | Ping => "PING", 135 | Pong => "PONG", 136 | Join => "JOIN", 137 | Part => "PART", 138 | Privmsg => "PRIVMSG", 139 | Whisper => "WHISPER", 140 | ClearChat => "CLEARCHAT", 141 | ClearMsg => "CLEARMSG", 142 | GlobalUserState => "GLOBALUSERSTATE", 143 | Notice => "NOTICE", 144 | Reconnect => "RECONNECT", 145 | RoomState => "ROOMSTATE", 146 | UserNotice => "USERNOTICE", 147 | UserState => "USERSTATE", 148 | Capability => "CAP", 149 | RplWelcome => "001", 150 | RplYourHost => "002", 151 | RplCreated => "003", 152 | RplMyInfo => "004", 153 | RplNames => "353", 154 | RplEndOfNames => "366", 155 | RplMotd => "372", 156 | RplMotdStart => "375", 157 | RplEndOfMotd => "376", 158 | Other(cmd) => cmd, 159 | } 160 | } 161 | } 162 | 163 | /// `COMMAND ` 164 | /// 165 | /// Returns `None` if command is unknown *and* empty 166 | #[inline(always)] 167 | pub(super) fn parse(src: &str, pos: &mut usize) -> Option { 168 | let (end, next_pos) = match src[*pos..].find(' ') { 169 | Some(end) => { 170 | let end = *pos + end; 171 | (end, end + 1) 172 | } 173 | None => (src.len(), src.len()), 174 | }; 175 | 176 | use RawCommand as C; 177 | let cmd = match &src[*pos..end] { 178 | "PING" => C::Ping, 179 | "PONG" => C::Pong, 180 | "JOIN" => C::Join, 181 | "PART" => C::Part, 182 | "PRIVMSG" => C::Privmsg, 183 | "WHISPER" => C::Whisper, 184 | "CLEARCHAT" => C::Clearchat, 185 | "CLEARMSG" => C::Clearmsg, 186 | "GLOBALUSERSTATE" => C::GlobalUserState, 187 | "NOTICE" => C::Notice, 188 | "RECONNECT" => C::Reconnect, 189 | "ROOMSTATE" => C::RoomState, 190 | "USERNOTICE" => C::UserNotice, 191 | "USERSTATE" => C::UserState, 192 | "CAP" => C::Capability, 193 | "001" => C::RplWelcome, 194 | "002" => C::RplYourHost, 195 | "003" => C::RplCreated, 196 | "004" => C::RplMyInfo, 197 | "353" => C::RplNamReply, 198 | "366" => C::RplEndOfNames, 199 | "372" => C::RplMotd, 200 | "375" => C::RplMotdStart, 201 | "376" => C::RplEndOfMotd, 202 | other if !other.is_empty() => C::Other(Span::from(*pos..end)), 203 | _ => return None, 204 | }; 205 | 206 | *pos = next_pos; 207 | 208 | Some(cmd) 209 | } 210 | 211 | #[cfg(test)] 212 | mod tests { 213 | use super::*; 214 | 215 | #[test] 216 | fn command() { 217 | let data = "PING "; 218 | let mut pos = 0; 219 | 220 | let command = parse(data, &mut pos).unwrap(); 221 | assert_eq!(command.get(data), Command::Ping); 222 | assert_eq!(&data[pos..], ""); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/irc/params.rs: -------------------------------------------------------------------------------- 1 | use crate::common::Span; 2 | 3 | #[inline(always)] 4 | pub(super) fn parse(src: &str, pos: &usize) -> Option { 5 | if !src[*pos..].is_empty() { 6 | Some(Span::from(*pos..src.len())) 7 | } else { 8 | None 9 | } 10 | } 11 | 12 | #[cfg(test)] 13 | mod tests { 14 | use super::*; 15 | 16 | #[test] 17 | fn params() { 18 | let data = ":param_a :param_b"; 19 | let params = parse(data, &0).unwrap(); 20 | assert_eq!(params.get(data), data) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/irc/prefix.rs: -------------------------------------------------------------------------------- 1 | use crate::common::Span; 2 | 3 | #[derive(Debug, Clone, Copy)] 4 | pub(super) struct RawPrefix { 5 | nick: Option, 6 | user: Option, 7 | host: Span, 8 | } 9 | 10 | impl RawPrefix { 11 | pub(super) fn get<'src>(&self, src: &'src str) -> Prefix<'src> { 12 | Prefix { 13 | nick: self.nick.map(|span| &src[span]), 14 | user: self.user.map(|span| &src[span]), 15 | host: &src[self.host], 16 | } 17 | } 18 | } 19 | 20 | // TODO: have prefix only be two variants: `User` and `Host` 21 | /// A message prefix. 22 | /// 23 | /// ```text,ignore 24 | /// :nick!user@host 25 | /// ``` 26 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 27 | pub struct Prefix<'src> { 28 | /// The `nick` part of the prefix. 29 | pub nick: Option<&'src str>, 30 | /// The `user` part of the prefix. 31 | pub user: Option<&'src str>, 32 | /// The `host` part of the prefix. 33 | pub host: &'src str, 34 | } 35 | 36 | impl<'src> std::fmt::Display for Prefix<'src> { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 38 | match (self.nick, self.user, self.host) { 39 | (Some(nick), Some(user), host) => write!(f, "{nick}!{user}@{host}"), 40 | (Some(nick), None, host) => write!(f, "{nick}@{host}"), 41 | (None, None, host) => write!(f, "{host}"), 42 | _ => Ok(()), 43 | } 44 | } 45 | } 46 | 47 | /// `:nick!user@host ` 48 | #[inline(always)] 49 | pub(super) fn parse(src: &str, pos: &mut usize) -> Option { 50 | if !src[*pos..].starts_with(':') { 51 | return None; 52 | } 53 | 54 | // :host 55 | // :nick@host 56 | // :nick!user@host 57 | let bytes = src.as_bytes(); 58 | 59 | let start = *pos + 1; 60 | let mut host_start = start; 61 | let mut nick = None; 62 | let mut nick_end = None; 63 | let mut user = None; 64 | for i in start..bytes.len() { 65 | match unsafe { *bytes.get_unchecked(i) } { 66 | b' ' => { 67 | let host = Span::from(host_start..i); 68 | *pos = i + 1; 69 | return Some(RawPrefix { nick, user, host }); 70 | } 71 | b'@' => { 72 | host_start = i + 1; 73 | if let Some(nick_end) = nick_end { 74 | user = Some(Span::from(nick_end + 1..i)); 75 | } else { 76 | nick = Some(Span::from(start..i)); 77 | } 78 | } 79 | b'!' => { 80 | nick = Some(Span::from(start..i)); 81 | nick_end = Some(i); 82 | } 83 | _ => {} 84 | } 85 | } 86 | 87 | None 88 | } 89 | -------------------------------------------------------------------------------- /src/irc/tags.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::ops::Deref; 3 | 4 | use crate::common::Span; 5 | 6 | macro_rules! tags_def { 7 | ( 8 | $tag:ident, $raw_tag:ident, $tag_mod:ident; 9 | $($(#[$meta:meta])* $bytes:literal; $key:literal = $name:ident),* $(,)? 10 | ) => { 11 | /// A parsed tag value. 12 | #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 13 | #[non_exhaustive] 14 | pub enum $tag<'src> { 15 | $( 16 | $(#[$meta])* 17 | $name, 18 | )* 19 | Unknown(&'src str), 20 | } 21 | 22 | impl<'src> $tag<'src> { 23 | #[doc = concat!("Get the string value of the [`", stringify!($tag), "`].")] 24 | #[inline] 25 | pub fn as_str(&self) -> &'src str { 26 | match self { 27 | $(Self::$name => $key,)* 28 | Self::Unknown(key) => key, 29 | } 30 | } 31 | 32 | #[doc = concat!("Parse a [`", stringify!($tag), "`] from a string.")] 33 | #[inline] 34 | pub fn parse(src: &'src str) -> Self { 35 | match src.as_bytes() { 36 | $($bytes => Self::$name,)* 37 | _ => Self::Unknown(src), 38 | } 39 | } 40 | } 41 | 42 | #[derive(Clone, Copy)] 43 | #[non_exhaustive] 44 | pub(super) enum $raw_tag { 45 | $($name,)* 46 | Unknown(Span), 47 | } 48 | 49 | impl $raw_tag { 50 | #[inline] 51 | fn get<'src>(&self, src: &'src str) -> $tag<'src> { 52 | match self { 53 | $(Self::$name => $tag::$name,)* 54 | Self::Unknown(span) => $tag::Unknown(&src[*span]), 55 | } 56 | } 57 | 58 | #[inline(never)] 59 | pub(super) fn parse(src: &str, span: Span) -> Self { 60 | match src[span].as_bytes() { 61 | $($bytes => Self::$name,)* 62 | _ => Self::Unknown(span), 63 | } 64 | } 65 | } 66 | 67 | #[allow(non_upper_case_globals)] 68 | pub(super) mod $tag_mod { 69 | $(pub const $name: &'static [u8] = $bytes;)* 70 | } 71 | } 72 | } 73 | 74 | impl<'src> From<&'src str> for Tag<'src> { 75 | fn from(value: &'src str) -> Self { 76 | Tag::parse(value) 77 | } 78 | } 79 | 80 | tags_def! { 81 | Tag, RawTag, tags; 82 | b"msg-id"; "msg-id" = MsgId, 83 | b"badges"; "badges" = Badges, 84 | b"badge-info"; "badge-info" = BadgeInfo, 85 | b"display-name"; "display-name" = DisplayName, 86 | b"emote-only"; "emote-only" = EmoteOnly, 87 | b"emotes"; "emotes" = Emotes, 88 | b"flags"; "flags" = Flags, 89 | b"id"; "id" = Id, 90 | b"mod"; "mod" = Mod, 91 | b"room-id"; "room-id" = RoomId, 92 | b"subscriber"; "subscriber" = Subscriber, 93 | b"tmi-sent-ts"; "tmi-sent-ts" = TmiSentTs, 94 | b"turbo"; "turbo" = Turbo, 95 | b"user-id"; "user-id" = UserId, 96 | b"user-type"; "user-type" = UserType, 97 | b"client-nonce"; "client-nonce" = ClientNonce, 98 | b"first-msg"; "first-msg" = FirstMsg, 99 | 100 | b"reply-parent-display-name"; "reply-parent-display-name" = ReplyParentDisplayName, 101 | 102 | b"reply-parent-msg-body"; "reply-parent-msg-body" = ReplyParentMsgBody, 103 | 104 | /// ID of the message the user replied to. 105 | /// 106 | /// This is different from `reply-thread-parent-msg-id` as it identifies the specific message 107 | /// the user replied to, not the thread. 108 | b"reply-parent-msg-id"; "reply-parent-msg-id" = ReplyParentMsgId, 109 | 110 | b"reply-parent-user-id"; "reply-parent-user-id" = ReplyParentUserId, 111 | 112 | b"reply-parent-user-login"; "reply-parent-user-login" = ReplyParentUserLogin, 113 | 114 | /// Root message ID of the thread the user replied to. 115 | /// 116 | /// This never changes for a given thread, so it can be used to identify the thread. 117 | b"reply-thread-parent-msg-id"; "reply-thread-parent-msg-id" = ReplyThreadParentMsgId, 118 | 119 | /// Login of the user who posted the root message in the thread the user replied to. 120 | b"reply-thread-parent-user-login"; "reply-thread-parent-user-login" = ReplyThreadParentUserLogin, 121 | 122 | b"followers-only"; "followers-only" = FollowersOnly, 123 | b"r9k"; "r9k" = R9K, 124 | b"rituals"; "rituals" = Rituals, 125 | b"slow"; "slow" = Slow, 126 | b"subs-only"; "subs-only" = SubsOnly, 127 | b"msg-param-cumulative-months"; "msg-param-cumulative-months" = MsgParamCumulativeMonths, 128 | b"msg-param-displayName"; "msg-param-displayName" = MsgParamDisplayName, 129 | b"msg-param-login"; "msg-param-login" = MsgParamLogin, 130 | b"msg-param-months"; "msg-param-months" = MsgParamMonths, 131 | b"msg-param-promo-gift-total"; "msg-param-promo-gift-total" = MsgParamPromoGiftTotal, 132 | b"msg-param-promo-name"; "msg-param-promo-name" = MsgParamPromoName, 133 | b"msg-param-recipient-display-name"; "msg-param-recipient-display-name" = MsgParamRecipientDisplayName, 134 | b"msg-param-recipient-id"; "msg-param-recipient-id" = MsgParamRecipientId, 135 | b"msg-param-recipient-user-name"; "msg-param-recipient-user-name" = MsgParamRecipientUserName, 136 | b"msg-param-sender-login"; "msg-param-sender-login" = MsgParamSenderLogin, 137 | b"msg-param-sender-name"; "msg-param-sender-name" = MsgParamSenderName, 138 | b"msg-param-should-share-streak"; "msg-param-should-share-streak" = MsgParamShouldShareStreak, 139 | b"msg-param-streak-months"; "msg-param-streak-months" = MsgParamStreakMonths, 140 | b"msg-param-sub-plan"; "msg-param-sub-plan" = MsgParamSubPlan, 141 | b"msg-param-sub-plan-name"; "msg-param-sub-plan-name" = MsgParamSubPlanName, 142 | b"msg-param-viewerCount"; "msg-param-viewerCount" = MsgParamViewerCount, 143 | b"msg-param-ritual-name"; "msg-param-ritual-name" = MsgParamRitualName, 144 | b"msg-param-threshold"; "msg-param-threshold" = MsgParamThreshold, 145 | b"msg-param-gift-months"; "msg-param-gift-months" = MsgParamGiftMonths, 146 | b"msg-param-color"; "msg-param-color" = MsgParamColor, 147 | b"login"; "login" = Login, 148 | b"bits"; "bits" = Bits, 149 | b"system-msg"; "system-msg" = SystemMsg, 150 | b"emote-sets"; "emote-sets" = EmoteSets, 151 | b"thread-id"; "thread-id" = ThreadId, 152 | b"message-id"; "message-id" = MessageId, 153 | b"returning-chatter"; "returning-chatter" = ReturningChatter, 154 | b"color"; "color" = Color, 155 | b"vip"; "vip" = Vip, 156 | b"target-user-id"; "target-user-id" = TargetUserId, 157 | b"target-msg-id"; "target-msg-id" = TargetMsgId, 158 | b"ban-duration"; "ban-duration" = BanDuration, 159 | b"msg-param-multimonth-duration"; "msg-param-multimonth-duration" = MsgParamMultimonthDuration, 160 | b"msg-param-was-gifted"; "msg-param-was-gifted" = MsgParamWasGifted, 161 | b"msg-param-multimonth-tenure"; "msg-param-multimonth-tenure" = MsgParamMultimonthTenure, 162 | b"sent-ts"; "sent-ts" = SentTs, 163 | b"msg-param-origin-id"; "msg-param-origin-id" = MsgParamOriginId, 164 | b"msg-param-fun-string"; "msg-param-fun-string" = MsgParamFunString, 165 | b"msg-param-sender-count"; "msg-param-sender-count" = MsgParamSenderCount, 166 | b"msg-param-profileImageURL"; "msg-param-profileImageURL" = MsgParamProfileImageUrl, 167 | b"msg-param-mass-gift-count"; "msg-param-mass-gift-count" = MsgParamMassGiftCount, 168 | b"msg-param-gift-month-being-redeemed"; "msg-param-gift-month-being-redeemed" = MsgParamGiftMonthBeingRedeemed, 169 | b"msg-param-anon-gift"; "msg-param-anon-gift" = MsgParamAnonGift, 170 | b"custom-reward-id"; "custom-reward-id" = CustomRewardId, 171 | 172 | /// The value of the Hype Chat sent by the user. 173 | b"pinned-chat-paid-amount"; "pinned-chat-paid-amount" = PinnedChatPaidAmount, 174 | 175 | /// The value of the Hype Chat sent by the user. This seems to always be the same as `pinned-chat-paid-amount`. 176 | b"pinned-chat-paid-canonical-amount"; "pinned-chat-paid-amount" = PinnedChatPaidCanonicalAmount, 177 | 178 | /// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in. 179 | b"pinned-chat-paid-currency"; "pinned-chat-paid-currency" = PinnedChatPaidCurrency, 180 | 181 | /// Indicates how many decimal points this currency represents partial amounts in. 182 | b"pinned-chat-paid-exponent"; "pinned-chat-paid-exponent" = PinnedChatPaidExponent, 183 | 184 | /// The level of the Hype Chat, in English. 185 | /// 186 | /// Possible values are capitalized words from `ONE` to `TEN`: ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN 187 | b"pinned-chat-paid-level"; "pinned-chat-paid-level" = PinnedChatPaidLevel, 188 | 189 | /// A Boolean value that determines if the message sent with the Hype Chat was filled in by the system. 190 | /// 191 | /// If `true` (1), the user entered no message and the body message was automatically filled in by the system. 192 | /// If `false` (0), the user provided their own message to send with the Hype Chat. 193 | b"pinned-chat-paid-is-system-message"; "pinned-chat-paid-is-system-message" = PinnedChatPaidIsSystemMessage, 194 | } 195 | 196 | impl<'src> Display for Tag<'src> { 197 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 198 | f.write_str(self.as_str()) 199 | } 200 | } 201 | 202 | #[derive(Default, Clone)] 203 | pub(super) struct RawTags(pub(crate) Vec); 204 | 205 | impl Deref for RawTags { 206 | type Target = Vec; 207 | 208 | fn deref(&self) -> &Self::Target { 209 | &self.0 210 | } 211 | } 212 | 213 | impl IntoIterator for RawTags { 214 | type Item = TagPair; 215 | 216 | type IntoIter = std::vec::IntoIter; 217 | 218 | fn into_iter(self) -> Self::IntoIter { 219 | self.0.into_iter() 220 | } 221 | } 222 | 223 | #[derive(Default, Clone, Copy)] 224 | pub(super) struct TagPair { 225 | // key=value 226 | // ^ 227 | key_start: u32, 228 | // key=value 229 | // ^ 230 | key_end: u16, 231 | 232 | // key=value 233 | // ^ 234 | value_end: u16, 235 | } 236 | 237 | impl TagPair { 238 | // key=value 239 | // ^ ^ 240 | #[inline] 241 | pub fn key(&self) -> Span { 242 | let start = self.key_start; 243 | let end = start + self.key_end as u32; 244 | Span { start, end } 245 | } 246 | 247 | // key=value 248 | // ^ ^ 249 | #[inline] 250 | pub fn value(&self) -> Span { 251 | let start = self.key_start + self.key_end as u32 + 1; 252 | let end = start + self.value_end as u32; 253 | Span { start, end } 254 | } 255 | 256 | #[inline] 257 | pub fn get<'a>(&self, src: &'a str) -> (&'a str, &'a str) { 258 | (&src[self.key()], &src[self.value()]) 259 | } 260 | } 261 | 262 | struct Array { 263 | data: [core::mem::MaybeUninit; CAPACITY], 264 | len: usize, 265 | } 266 | 267 | impl Array { 268 | fn new() -> Self { 269 | unsafe { 270 | let uninit_array = core::mem::MaybeUninit::<[T; CAPACITY]>::uninit(); 271 | let array_of_uninit = uninit_array 272 | .as_ptr() 273 | .cast::<[core::mem::MaybeUninit; CAPACITY]>() 274 | .read(); 275 | 276 | Self { 277 | data: array_of_uninit, 278 | len: 0, 279 | } 280 | } 281 | } 282 | 283 | fn push(&mut self, value: T) { 284 | self.data[self.len].write(value); 285 | self.len += 1; 286 | } 287 | 288 | fn to_vec(&self) -> Vec { 289 | let init = &self.data[..self.len]; 290 | let init = unsafe { core::mem::transmute::<&[core::mem::MaybeUninit], &[T]>(init) }; 291 | init.to_vec() 292 | } 293 | } 294 | 295 | cfg_if::cfg_if! { 296 | if #[cfg(feature = "simd")] { 297 | mod simd; 298 | pub(super) use simd::parse; 299 | } else { 300 | mod scalar; 301 | pub(super) use scalar::parse; 302 | } 303 | } 304 | 305 | #[cfg(test)] 306 | mod tests { 307 | use super::*; 308 | 309 | #[test] 310 | fn roundtrip() { 311 | let src = "@some-key-a=some-value-a;some-key-b=some-value-b;some-key-c=some-value-c "; 312 | 313 | let mut pos = 0; 314 | let parsed = format!( 315 | "@{} ", 316 | parse(src, &mut pos) 317 | .unwrap() 318 | .into_iter() 319 | .map(|tag| format!("{}={}", &src[tag.key()], &src[tag.value()])) 320 | .collect::>() 321 | .join(";") 322 | ); 323 | 324 | assert_eq!(&src[pos..], ""); 325 | assert_eq!(src, parsed); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/irc/tags/scalar.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) fn parse(src: &str, pos: &mut usize) -> Option { 4 | let src = src[*pos..].strip_prefix('@')?.as_bytes(); 5 | 6 | let mut tags = Array::<128, TagPair>::new(); 7 | 8 | let mut state = State::Key { key_start: 0 }; 9 | let mut offset = 0; 10 | while offset < src.len() { 11 | let c = src[offset]; 12 | match c { 13 | b'=' => { 14 | if let State::Key { key_start } = state { 15 | state = State::Value { 16 | key_start, 17 | key_end: offset, 18 | }; 19 | } 20 | } 21 | b';' => { 22 | if let State::Value { key_start, key_end } = state { 23 | tags.push(TagPair { 24 | key_start: key_start as u32 + 1, 25 | key_end: (key_end - key_start) as u16, 26 | value_end: (offset - (key_end + 1)) as u16, 27 | }); 28 | state = State::Key { 29 | key_start: offset + 1, 30 | }; 31 | } 32 | } 33 | b' ' => { 34 | if let State::Value { key_start, key_end } = state { 35 | tags.push(TagPair { 36 | key_start: key_start as u32 + 1, 37 | key_end: (key_end - key_start) as u16, 38 | value_end: (offset - (key_end + 1)) as u16, 39 | }); 40 | } 41 | break; 42 | } 43 | _ => {} 44 | } 45 | 46 | offset += 1; 47 | } 48 | 49 | *pos += offset + 2; // skip '@' + space 50 | 51 | Some(RawTags(tags.to_vec())) 52 | } 53 | 54 | #[derive(Clone, Copy)] 55 | enum State { 56 | Key { key_start: usize }, 57 | Value { key_start: usize, key_end: usize }, 58 | } 59 | -------------------------------------------------------------------------------- /src/irc/tags/simd.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::irc::wide::Vector as V; 3 | 4 | pub(crate) fn parse(src: &str, pos: &mut usize) -> Option { 5 | let src = src[*pos..].strip_prefix('@')?.as_bytes(); 6 | 7 | // 1. scan for ASCII space to find tags end 8 | let end = find_first(src, b' ')?; 9 | *pos += end + 2; // skip '@' + space 10 | 11 | let remainder = &src[..end]; 12 | let mut tags = Array::<128, TagPair>::new(); 13 | let mut offset = 0; 14 | 15 | let mut state = State::Key { key_start: 0 }; 16 | while offset + V::SIZE < remainder.len() { 17 | let chunk = V::load_unaligned(remainder, offset); 18 | parse_chunk(offset, chunk, &mut state, &mut tags); 19 | offset += V::SIZE; 20 | } 21 | 22 | if remainder.len() - offset > 0 { 23 | let chunk = V::load_unaligned_remainder(remainder, offset); 24 | parse_chunk(offset, chunk, &mut state, &mut tags); 25 | 26 | if let State::Value { key_start, key_end } = state { 27 | // value contains whatever is left after key_end 28 | 29 | let pos = remainder.len(); // pos of `;` 30 | 31 | tags.push(TagPair { 32 | // relative to original `src` 33 | key_start: key_start as u32 + 1, 34 | key_end: (key_end - key_start) as u16, 35 | // starts after `=` 36 | value_end: (pos - (key_end + 1)) as u16, 37 | }); 38 | } 39 | } 40 | 41 | Some(RawTags(tags.to_vec())) 42 | } 43 | 44 | #[derive(Clone, Copy)] 45 | enum State { 46 | Key { key_start: usize }, 47 | Value { key_start: usize, key_end: usize }, 48 | } 49 | 50 | #[inline(always)] 51 | fn parse_chunk(offset: usize, chunk: V, state: &mut State, tags: &mut Array<128, TagPair>) { 52 | let mut vector_eq = chunk.eq(b'=').movemask(); 53 | let mut vector_semi = chunk.eq(b';').movemask(); 54 | 55 | loop { 56 | match *state { 57 | State::Key { key_start } => { 58 | if !vector_eq.has_match() { 59 | break; 60 | } 61 | 62 | let m = vector_eq.first_match(); 63 | vector_eq.clear_to(m); 64 | vector_semi.clear_to(m); 65 | 66 | let pos = offset + m.as_index(); // pos of `=` 67 | 68 | *state = State::Value { 69 | key_start, 70 | key_end: pos, 71 | }; 72 | } 73 | State::Value { key_start, key_end } => { 74 | if !vector_semi.has_match() { 75 | break; 76 | } 77 | 78 | let m = vector_semi.first_match(); 79 | vector_eq.clear_to(m); 80 | vector_semi.clear_to(m); 81 | 82 | let pos = offset + m.as_index(); // pos of `;` 83 | 84 | *state = State::Key { key_start: pos + 1 }; 85 | 86 | tags.push(TagPair { 87 | // relative to original `src` 88 | key_start: key_start as u32 + 1, 89 | key_end: (key_end - key_start) as u16, 90 | // starts after `=` 91 | value_end: (pos - (key_end + 1)) as u16, 92 | }); 93 | } 94 | } 95 | } 96 | } 97 | 98 | // I didn't want to use runtime feature detection, or bring in a dependency for this. 99 | // 100 | // This implementation is ported from BurntSushi/memchr to use our vector/mask types: 101 | // https://github.com/BurntSushi/memchr/blob/7fccf70e2a58c1fbedc9b9687c2ba0cf5992537b/src/arch/generic/memchr.rs#L143-L144 102 | // 103 | // The original implementation is licensed under the MIT license. 104 | #[allow(clippy::erasing_op, clippy::identity_op, clippy::needless_range_loop)] 105 | #[inline] 106 | fn find_first(data: &[u8], byte: u8) -> Option { 107 | // 1. scalar fallback for small data 108 | if data.len() < V::SIZE { 109 | for i in 0..data.len() { 110 | if data[i] == byte { 111 | return Some(i); 112 | } 113 | } 114 | 115 | return None; 116 | } 117 | 118 | // 2. read the first chunk unaligned, because we are now 119 | // guaranteed to have more than vector-size bytes 120 | let chunk = V::load_unaligned(data, 0); 121 | let mask = chunk.eq(byte).movemask(); 122 | if mask.has_match() { 123 | return Some(mask.first_match().as_index()); 124 | } 125 | 126 | // 3. read the rest of the data in vector-size aligned chunks 127 | const UNROLLED_BYTES: usize = 4 * V::SIZE; 128 | 129 | // it's fine if we overlap the next vector-size chunk with 130 | // some part of the first chunk, because we already know 131 | // that there is no match in the first vector-size bytes. 132 | let data_addr = data.as_ptr() as usize; 133 | let aligned_start_addr = data_addr + V::SIZE - (data_addr % V::SIZE); 134 | let aligned_start_offset = aligned_start_addr - data_addr; 135 | 136 | let mut offset = aligned_start_offset; 137 | while offset + UNROLLED_BYTES < data.len() { 138 | // do all loads up-front to saturate the pipeline 139 | let chunk_0 = V::load_aligned(data, offset + V::SIZE * 0).eq(byte); 140 | let chunk_1 = V::load_aligned(data, offset + V::SIZE * 1).eq(byte); 141 | let chunk_2 = V::load_aligned(data, offset + V::SIZE * 2).eq(byte); 142 | let chunk_3 = V::load_aligned(data, offset + V::SIZE * 3).eq(byte); 143 | 144 | // TODO: movemask_will_have_non_zero 145 | 146 | let mask = chunk_0.movemask(); 147 | if mask.has_match() { 148 | let pos = mask.first_match().as_index(); 149 | return Some(offset + pos + 0 * V::SIZE); 150 | } 151 | 152 | let mask = chunk_1.movemask(); 153 | if mask.has_match() { 154 | let pos = mask.first_match().as_index(); 155 | return Some(offset + pos + 1 * V::SIZE); 156 | } 157 | 158 | let mask = chunk_2.movemask(); 159 | if mask.has_match() { 160 | let pos = mask.first_match().as_index(); 161 | return Some(offset + pos + 2 * V::SIZE); 162 | } 163 | 164 | let mask = chunk_3.movemask(); 165 | if mask.has_match() { 166 | let pos = mask.first_match().as_index(); 167 | return Some(offset + pos + 3 * V::SIZE); 168 | } 169 | 170 | offset += V::SIZE * 4; 171 | } 172 | 173 | // 4. we may have fewer than UNROLLED_BYTES bytes left, which may 174 | // still be enough for one or more vector-size chunks. 175 | while offset + V::SIZE <= data.len() { 176 | // the data is still guaranteed to be aligned at this point. 177 | let chunk = V::load_aligned(data, offset); 178 | let mask = chunk.eq(byte).movemask(); 179 | if mask.has_match() { 180 | let pos = mask.first_match().as_index(); 181 | return Some(offset + pos); 182 | } 183 | 184 | offset += V::SIZE; 185 | } 186 | 187 | // 5. we definitely have fewer than a single vector-size chunk left, 188 | // so we have to read the last chunk unaligned. 189 | // note that it is fine if it overlaps with the previous chunk, 190 | // for the same reason why it's fine in step 3. 191 | if offset < data.len() { 192 | let offset = data.len() - V::SIZE; 193 | 194 | let chunk = V::load_unaligned(data, offset); 195 | let mask = chunk.eq(byte).movemask(); 196 | if mask.has_match() { 197 | let pos = mask.first_match().as_index(); 198 | return Some(offset + pos); 199 | } 200 | } 201 | 202 | None 203 | } 204 | 205 | #[cfg(test)] 206 | mod tests { 207 | use super::*; 208 | 209 | #[test] 210 | fn find_first_test() { 211 | fn a(size: usize, needle_at: usize) -> Vec { 212 | let mut data = vec![b'.'; size]; 213 | data[needle_at] = b'x'; 214 | data 215 | } 216 | 217 | let cases: &[(&[u8], Option)] = &[ 218 | // sub vector-size chunks 219 | (b"", None), // 0 bytes 220 | (b"x", Some(0)), // 1 byte 221 | (b".", None), // 1 byte 222 | (b"xx", Some(0)), // 2 bytes 223 | (b"x.", Some(0)), // 2 bytes 224 | (b".x", Some(1)), // 2 bytes 225 | // vector-size chunks 226 | // 16 bytes 227 | (b"x...............", Some(0)), 228 | (b".x..............", Some(1)), 229 | (b"..............x.", Some(14)), 230 | (b"...............x", Some(15)), 231 | // uneven + above vector-size chunks 232 | // 17 bytes 233 | (b"x................", Some(0)), 234 | (b".x...............", Some(1)), 235 | (b"...............x.", Some(15)), 236 | (b"................x", Some(16)), 237 | // 31 bytes 238 | (b"x...............................", Some(0)), 239 | (b".x..............................", Some(1)), 240 | (b"..............................x.", Some(30)), 241 | (b"...............................x", Some(31)), 242 | // large chunks 243 | // 1 KiB 244 | (&a(1024, 0)[..], Some(0)), 245 | (&a(1024, 1)[..], Some(1)), 246 | (&a(1024, 1022)[..], Some(1022)), 247 | (&a(1024, 1023)[..], Some(1023)), 248 | ]; 249 | 250 | for (i, case) in cases.iter().enumerate() { 251 | let (data, expected) = *case; 252 | assert_eq!(find_first(data, b'x'), expected, "case {} failed", i); 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/irc/wide.rs: -------------------------------------------------------------------------------- 1 | cfg_if::cfg_if! { 2 | if #[cfg(all( 3 | target_arch = "x86_64", 4 | any( 5 | target_feature = "sse2", 6 | target_feature = "avx2", 7 | all(target_feature = "avx512f", target_feature = "avx512bw") 8 | ) 9 | ))] { 10 | pub(super) mod x86_64; 11 | pub(super) use x86_64::Vector; 12 | } else if #[cfg(all( 13 | target_arch = "aarch64", 14 | target_feature = "neon" 15 | ))] { 16 | pub(super) mod aarch64; 17 | pub(super) use aarch64::Vector; 18 | } else { 19 | compile_error!("unsupported target architecture - please disable the `simd` feature"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/irc/wide/aarch64.rs: -------------------------------------------------------------------------------- 1 | cfg_if::cfg_if! { 2 | if #[cfg(target_feature = "neon")] { 3 | mod neon; 4 | pub(crate) use neon::Vector; 5 | } else { 6 | compile_error!( 7 | "enable the `neon` target features using `target-cpu=native`, or disable the `simd` feature" 8 | ); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/irc/wide/aarch64/neon.rs: -------------------------------------------------------------------------------- 1 | // The method used for emulating movemask is explained in the following article (the link goes to a table of operations): 2 | // https://community.arm.com/arm-community-blogs/b/infrastructure-solutions-blog/posts/porting-x86-vector-bitmask-optimizations-to-arm-neon#:~:text=Consider%20the%C2%A0result%20in%20both%20cases%20as%20the%20result%20of%20PMOVMSKB%20or%20shrn 3 | // 4 | // Archived link: https://web.archive.org/web/20230603011837/https://community.arm.com/arm-community-blogs/b/infrastructure-solutions-blog/posts/porting-x86-vector-bitmask-optimizations-to-arm-neon 5 | // 6 | // For example, to find the first `=` character in `s`: 7 | // 8 | // The implementation splits `s` into 16-byte chunks, loading each chunk into a single 8x16 vector. 9 | // 10 | // The resulting 8x16 vectors are compared against the pre-filled vector of a single character using `vceqq_u8`. 11 | // Next, the 8x16 is reinterpreted as 16x8, to which we apply `vshrn_n_u16`. 12 | // 13 | // `vshrn_n_u16` performs a "vector shift right by constant and narrow". 14 | // The way I understand it is that for every 16-bit element in the vector, 15 | // it trims the 4 most significant bits + 4 least significant bits: 16 | // 17 | // ```text,ignore 18 | // # for a single element: 19 | // 1111111100000000 -> shift right by 4 20 | // 0000111111110000 -> narrow to u8 21 | // 11110000 22 | // ``` 23 | // 24 | // If we count the number of bits in the vector before the first bit set to `1`, 25 | // then divide that number by `4`, we get the same result as a `movemask + ctz` would give us. 26 | // 27 | // So the last step is to reinterpret the resulting 8x8 vector as a single 64-bit integer, 28 | // which is our mask. 29 | // Just like before, we can check for the presence of the "needle" by comparing the mask 30 | // against `0`. 31 | // To obtain the position of the charater, divide its trailing zeros by 4. 32 | 33 | use core::arch::aarch64::{ 34 | uint8x16_t, vceqq_u8, vget_lane_u64, vld1q_u8, vreinterpret_u64_u8, vreinterpretq_u16_u8, 35 | vshrn_n_u16, 36 | }; 37 | 38 | // NOTE: neon has no alignment requirements for loads, 39 | // but alignment is still better than no alignment. 40 | 41 | #[repr(align(16))] 42 | struct Align16([u8; 16]); 43 | 44 | #[derive(Clone, Copy)] 45 | #[repr(transparent)] 46 | pub struct Vector(uint8x16_t); 47 | 48 | impl Vector { 49 | /// Size in bytes. 50 | pub const SIZE: usize = 16; 51 | 52 | #[inline] 53 | pub const fn fill(v: u8) -> Self { 54 | Self(unsafe { core::mem::transmute::<[u8; 16], uint8x16_t>([v; 16]) }) 55 | } 56 | 57 | /// Load 16 bytes from the given slice into a vector. 58 | /// 59 | /// `data[offset..].len()` must be greater than 16 bytes. 60 | #[inline(always)] 61 | pub fn load_unaligned(data: &[u8], offset: usize) -> Self { 62 | unsafe { 63 | debug_assert!(data[offset..].len() >= 16); 64 | Self(vld1q_u8(data.as_ptr().add(offset))) 65 | } 66 | } 67 | 68 | /// Load 16 bytes from the given slice into a vector. 69 | /// 70 | /// `data[offset..].len()` must be greater than 16 bytes. 71 | /// The data must be 16-byte aligned. 72 | #[inline(always)] 73 | pub fn load_aligned(data: &[u8], offset: usize) -> Self { 74 | unsafe { 75 | debug_assert!(data[offset..].len() >= 16); 76 | debug_assert!(data.as_ptr().add(offset) as usize % 16 == 0); 77 | Self(vld1q_u8(data.as_ptr().add(offset))) 78 | } 79 | } 80 | 81 | /// Load at most 16 bytes from the given slice into a vector 82 | /// by loading it into an intermediate buffer on the stack. 83 | #[inline(always)] 84 | pub fn load_unaligned_remainder(data: &[u8], offset: usize) -> Self { 85 | unsafe { 86 | let mut buf = Align16([0; 16]); 87 | buf.0[..data.len() - offset].copy_from_slice(&data[offset..]); 88 | 89 | Self(vld1q_u8(buf.0.as_ptr())) 90 | } 91 | } 92 | 93 | #[inline(always)] 94 | pub fn eq(self, byte: u8) -> Self { 95 | unsafe { Self(vceqq_u8(self.0, Self::fill(byte).0)) } 96 | } 97 | 98 | #[inline(always)] 99 | pub fn movemask(self) -> Mask { 100 | unsafe { 101 | let mask = vreinterpretq_u16_u8(self.0); 102 | let res = vshrn_n_u16(mask, 4); // the magic sauce 103 | let matches = vget_lane_u64(vreinterpret_u64_u8(res), 0); 104 | Mask(matches) 105 | } 106 | } 107 | } 108 | 109 | #[derive(Clone, Copy)] 110 | #[repr(transparent)] 111 | pub struct Mask(u64); 112 | 113 | impl Mask { 114 | #[inline(always)] 115 | pub fn has_match(&self) -> bool { 116 | // We have a match if the mask is not empty. 117 | self.0 != 0 118 | } 119 | 120 | #[inline(always)] 121 | pub fn first_match(&self) -> Match { 122 | Match(self.0.trailing_zeros() as usize) 123 | } 124 | 125 | /// Clear all bits up to and including `m`. 126 | #[inline(always)] 127 | pub fn clear_to(&mut self, m: Match) { 128 | self.0 &= !(0xffff_ffff_ffff_ffff >> (63 - (m.0 + 3))); 129 | } 130 | } 131 | 132 | #[derive(Clone, Copy)] 133 | #[repr(transparent)] 134 | pub struct Match(usize); 135 | 136 | impl Match { 137 | #[inline(always)] 138 | pub fn as_index(self) -> usize { 139 | // There are 4 bits per character, so divide the trailing zeros by 4 (shift right by 2). 140 | self.0 >> 2 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod test { 146 | use super::*; 147 | 148 | #[test] 149 | fn test_clear_to() { 150 | let mut mask = Mask(0b00000000_11110000_11111111_00000000); 151 | mask.clear_to(mask.first_match()); 152 | assert_eq!(mask.0, 0b00000000_11110000_11110000_00000000); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/irc/wide/x86_64.rs: -------------------------------------------------------------------------------- 1 | cfg_if::cfg_if! { 2 | // NOTE: avx512 is still nightly-only and unstable, so disabled for now 3 | /* if #[cfg(all(target_feature = "avx512f", target_feature = "avx512bw"))] { 4 | mod avx512; 5 | pub(crate) use avx512::Vector; 6 | } else */ 7 | if #[cfg(target_feature = "avx2")] { 8 | mod avx2; 9 | pub(crate) use avx2::Vector; 10 | } else if #[cfg(target_feature = "sse2")] { 11 | mod sse2; 12 | pub(crate) use sse2::Vector; 13 | } else { 14 | compile_error!( 15 | "enable the `sse2`/`avx2` target features using `target-cpu=native`, or disable the `simd` feature" 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/irc/wide/x86_64/avx2.rs: -------------------------------------------------------------------------------- 1 | use core::arch::x86_64::{ 2 | __m256i, _mm256_cmpeq_epi8, _mm256_load_si256, _mm256_loadu_si256, _mm256_movemask_epi8, 3 | }; 4 | 5 | #[repr(align(32))] 6 | struct Align32([u8; 32]); 7 | 8 | #[derive(Clone, Copy)] 9 | #[repr(transparent)] 10 | pub struct Vector(__m256i); 11 | 12 | impl Vector { 13 | /// Size in bytes. 14 | pub const SIZE: usize = 32; 15 | 16 | #[inline] 17 | pub const fn fill(v: u8) -> Self { 18 | Self(unsafe { core::mem::transmute::<[u8; 32], __m256i>([v; 32]) }) 19 | } 20 | 21 | /// Load 32 bytes from the given slice into a vector. 22 | /// 23 | /// `data[offset..].len()` must be greater than 32 bytes. 24 | #[inline(always)] 25 | pub fn load_unaligned(data: &[u8], offset: usize) -> Self { 26 | unsafe { 27 | debug_assert!(data[offset..].len() >= 32); 28 | Self(_mm256_loadu_si256( 29 | data.as_ptr().add(offset) as *const __m256i 30 | )) 31 | } 32 | } 33 | 34 | /// Load 32 bytes from the given slice into a vector. 35 | /// 36 | /// `data[offset..].len()` must be greater than 32 bytes. 37 | /// The data must be 32-byte aligned. 38 | #[inline(always)] 39 | pub fn load_aligned(data: &[u8], offset: usize) -> Self { 40 | unsafe { 41 | debug_assert!(data[offset..].len() >= 32); 42 | debug_assert!(data.as_ptr().add(offset) as usize % 32 == 0); 43 | Self(_mm256_load_si256( 44 | data.as_ptr().add(offset) as *const __m256i 45 | )) 46 | } 47 | } 48 | 49 | /// Load at most 32 bytes from the given slice into a vector 50 | /// by loading it into an intermediate buffer on the stack. 51 | #[inline(always)] 52 | pub fn load_unaligned_remainder(data: &[u8], offset: usize) -> Self { 53 | unsafe { 54 | let mut buf = Align32([0; 32]); 55 | buf.0[..data.len() - offset].copy_from_slice(&data[offset..]); 56 | 57 | Self(_mm256_load_si256(buf.0.as_ptr() as *const __m256i)) 58 | } 59 | } 60 | 61 | #[inline(always)] 62 | pub fn eq(self, byte: u8) -> Self { 63 | unsafe { Self(_mm256_cmpeq_epi8(self.0, Self::fill(byte).0)) } 64 | } 65 | 66 | #[inline(always)] 67 | pub fn movemask(self) -> Mask { 68 | unsafe { 69 | let value = std::mem::transmute::(_mm256_movemask_epi8(self.0)); 70 | Mask(value) 71 | } 72 | } 73 | } 74 | 75 | #[derive(Clone, Copy)] 76 | #[repr(transparent)] 77 | pub struct Mask(u32); 78 | 79 | impl Mask { 80 | #[inline(always)] 81 | pub fn has_match(&self) -> bool { 82 | self.0 != 0 83 | } 84 | 85 | #[inline(always)] 86 | pub fn first_match(&self) -> Match { 87 | Match(self.0.trailing_zeros() as usize) 88 | } 89 | 90 | /// Clear all bits up to and including `m`. 91 | #[inline(always)] 92 | pub fn clear_to(&mut self, m: Match) { 93 | self.0 &= !(0xffff_ffff >> (31 - m.0)); 94 | } 95 | } 96 | 97 | #[derive(Clone, Copy)] 98 | #[repr(transparent)] 99 | pub struct Match(usize); 100 | 101 | impl Match { 102 | #[inline(always)] 103 | pub fn as_index(&self) -> usize { 104 | self.0 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/irc/wide/x86_64/avx512.rs: -------------------------------------------------------------------------------- 1 | use core::arch::x86_64::{ 2 | __m512i, _mm512_cmpeq_epi8_mask, _mm512_load_si512, _mm512_loadu_si512, _mm512_movepi8_mask, 3 | }; 4 | 5 | #[repr(align(64))] 6 | struct Align64([u8; 64]); 7 | 8 | #[derive(Clone, Copy)] 9 | #[repr(transparent)] 10 | pub struct Vector(__m512i); 11 | 12 | impl Vector { 13 | /// Size in bytes. 14 | pub const SIZE: usize = 64; 15 | 16 | #[inline] 17 | pub const fn fill(v: u8) -> Self { 18 | Self(unsafe { core::mem::transmute::<[u8; 64], __m512i>([v; 64]) }) 19 | } 20 | 21 | /// Load 64 bytes from the given slice into a vector. 22 | /// 23 | /// `data[offset..].len()` must be greater than 64 bytes. 24 | #[inline(always)] 25 | pub fn load_unaligned(data: &[u8], offset: usize) -> Self { 26 | unsafe { 27 | debug_assert!(data[offset..].len() >= 64); 28 | Self(_mm512_loadu_si512( 29 | data.as_ptr().add(offset) as *const __m512i 30 | )) 31 | } 32 | } 33 | 34 | /// Load 64 bytes from the given slice into a vector. 35 | /// 36 | /// `data[offset..].len()` must be greater than 64 bytes. 37 | /// The data must be 64-byte aligned. 38 | #[inline(always)] 39 | pub fn load_aligned(data: &[u8], offset: usize) -> Self { 40 | unsafe { 41 | debug_assert!(data[offset..].len() >= 64); 42 | debug_assert!(data.as_ptr().add(offset) as usize % 64 == 0); 43 | Self(_mm512_load_si512( 44 | data.as_ptr().add(offset) as *const __m512i 45 | )) 46 | } 47 | } 48 | 49 | /// Load at most 64 bytes from the given slice into a vector 50 | /// by loading it into an intermediate buffer on the stack. 51 | #[inline(always)] 52 | pub fn load_unaligned_remainder(data: &[u8], offset: usize) -> Self { 53 | unsafe { 54 | let mut buf = Align64([0; 64]); 55 | buf.0[..data.len() - offset].copy_from_slice(&data[offset..]); 56 | 57 | Self(_mm512_load_si512(buf.0.as_ptr() as *const __m512i)) 58 | } 59 | } 60 | 61 | #[inline(always)] 62 | pub fn eq(self, byte: u8) -> Self { 63 | unsafe { Self(_mm512_cmpeq_epi8_mask(self.0, Self::fill(byte))) } 64 | } 65 | 66 | #[inline(always)] 67 | pub fn movemask(self) -> Mask { 68 | unsafe { Mask(_mm512_movepi8_mask(mask)) } 69 | } 70 | } 71 | 72 | #[derive(Clone, Copy)] 73 | #[repr(transparent)] 74 | pub struct Mask(u64); 75 | 76 | impl Mask { 77 | #[inline(always)] 78 | pub fn has_match(&self) -> bool { 79 | self.0 != 0 80 | } 81 | 82 | #[inline(always)] 83 | pub fn first_match(&self) -> Match { 84 | Match(self.0.trailing_zeros() as usize) 85 | } 86 | 87 | /// Clear all bits up to and including `m`. 88 | #[inline(always)] 89 | pub fn clear_to(&mut self, m: Match) { 90 | self.0 &= !(0xffff_ffff_ffff_ffff >> (63 - m.0)); 91 | } 92 | } 93 | 94 | #[derive(Clone, Copy)] 95 | #[repr(transparent)] 96 | pub struct Match(usize); 97 | 98 | impl Match { 99 | #[inline(always)] 100 | pub fn as_index(&self) -> usize { 101 | self.0 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/irc/wide/x86_64/sse2.rs: -------------------------------------------------------------------------------- 1 | use core::arch::x86_64::{ 2 | __m128i, _mm_cmpeq_epi8, _mm_load_si128, _mm_loadu_si128, _mm_movemask_epi8, 3 | }; 4 | 5 | #[repr(align(16))] 6 | struct Align16([u8; 16]); 7 | 8 | #[derive(Clone, Copy)] 9 | #[repr(transparent)] 10 | pub struct Vector(__m128i); 11 | 12 | impl Vector { 13 | /// Size in bytes. 14 | pub const SIZE: usize = 16; 15 | 16 | #[inline] 17 | pub const fn fill(v: u8) -> Self { 18 | Self(unsafe { core::mem::transmute::<[u8; 16], __m128i>([v; 16]) }) 19 | } 20 | 21 | /// Load 16 bytes from the given slice into a vector. 22 | /// 23 | /// `data[offset..].len()` must be greater than 16 bytes. 24 | #[inline(always)] 25 | pub fn load_unaligned(data: &[u8], offset: usize) -> Self { 26 | unsafe { 27 | debug_assert!(data[offset..].len() >= 16); 28 | Self(_mm_loadu_si128(data.as_ptr().add(offset) as *const __m128i)) 29 | } 30 | } 31 | 32 | /// Load 16 bytes from the given slice into a vector. 33 | /// 34 | /// `data[offset..].len()` must be greater than 16 bytes. 35 | /// The data must be 16-byte aligned. 36 | #[inline(always)] 37 | pub fn load_aligned(data: &[u8], offset: usize) -> Self { 38 | unsafe { 39 | debug_assert!(data[offset..].len() >= 16); 40 | debug_assert!(data.as_ptr().add(offset) as usize % 16 == 0); 41 | Self(_mm_load_si128(data.as_ptr().add(offset) as *const __m128i)) 42 | } 43 | } 44 | 45 | /// Load at most 16 bytes from the given slice into a vector 46 | /// by loading it into an intermediate buffer on the stack. 47 | #[inline(always)] 48 | pub fn load_unaligned_remainder(data: &[u8], offset: usize) -> Self { 49 | unsafe { 50 | let mut buf = Align16([0; 16]); 51 | buf.0[..data.len() - offset].copy_from_slice(&data[offset..]); 52 | 53 | Self(_mm_load_si128(buf.0.as_ptr() as *const __m128i)) 54 | } 55 | } 56 | 57 | /// Compare 16 8-bit elements in `self` against `other`, leaving a `1` in each 58 | #[inline(always)] 59 | pub fn eq(self, byte: u8) -> Self { 60 | unsafe { Self(_mm_cmpeq_epi8(self.0, Self::fill(byte).0)) } 61 | } 62 | 63 | #[inline(always)] 64 | pub fn movemask(self) -> Mask { 65 | unsafe { 66 | let value = std::mem::transmute::(_mm_movemask_epi8(self.0)); 67 | Mask(value) 68 | } 69 | } 70 | } 71 | 72 | #[derive(Clone, Copy)] 73 | #[repr(transparent)] 74 | pub struct Mask(u32); 75 | 76 | impl Mask { 77 | #[inline(always)] 78 | pub fn has_match(&self) -> bool { 79 | self.0 != 0 80 | } 81 | 82 | #[inline(always)] 83 | pub fn first_match(&self) -> Match { 84 | Match(self.0.trailing_zeros() as usize) 85 | } 86 | 87 | /// Clear all bits up to and including `m`. 88 | #[inline(always)] 89 | pub fn clear_to(&mut self, m: Match) { 90 | self.0 &= !(0xffff_ffff >> (31 - m.0)); 91 | } 92 | } 93 | 94 | #[derive(Clone, Copy)] 95 | #[repr(transparent)] 96 | pub struct Match(usize); 97 | 98 | impl Match { 99 | #[inline(always)] 100 | pub fn as_index(&self) -> usize { 101 | self.0 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![allow(clippy::needless_lifetimes)] 3 | 4 | #[cfg(feature = "client")] 5 | #[macro_use] 6 | extern crate tracing; 7 | 8 | pub(crate) const fn assert_sync() {} 9 | macro_rules! static_assert_sync { 10 | ($T:ty) => { 11 | const _: () = { 12 | let _ = $crate::assert_sync::<$T>; 13 | }; 14 | }; 15 | } 16 | 17 | pub(crate) const fn assert_send() {} 18 | macro_rules! static_assert_send { 19 | ($T:ty) => { 20 | const _: () = { 21 | let _ = $crate::assert_send::<$T>; 22 | }; 23 | }; 24 | } 25 | 26 | #[cfg(feature = "client")] 27 | pub mod client; 28 | 29 | #[cfg(feature = "client")] 30 | pub use client::{Client, Credentials}; 31 | 32 | #[cfg(feature = "message-types")] 33 | pub mod msg; 34 | #[cfg(feature = "message-types")] 35 | pub use msg::*; 36 | 37 | pub mod irc; 38 | pub use irc::*; 39 | 40 | pub mod common; 41 | 42 | use std::borrow::Cow; 43 | 44 | /// Checks if `value` needs to be unescaped by looking for escaped characters. 45 | /// 46 | /// If it must be unescaped, then it must reallocate and will return an owned string. 47 | /// Otherwise, it returns a borrow of the original `value`. 48 | pub fn maybe_unescape<'a>(value: impl Into>) -> Cow<'a, str> { 49 | let mut value: Cow<'_, str> = value.into(); 50 | for i in 0..value.len() { 51 | if value.as_bytes()[i] == b'\\' { 52 | value = Cow::Owned(actually_unescape(&value, i)); 53 | break; 54 | } 55 | } 56 | value 57 | } 58 | 59 | #[inline] 60 | fn actually_unescape(input: &str, start: usize) -> String { 61 | let mut out = String::with_capacity(input.len()); 62 | out.push_str(&input[..start]); 63 | 64 | let mut escape = false; 65 | for char in input[start..].chars() { 66 | match char { 67 | '\\' if escape => { 68 | out.push('\\'); 69 | escape = false; 70 | } 71 | '\\' => escape = true, 72 | ':' if escape => { 73 | out.push(';'); 74 | escape = false; 75 | } 76 | 's' if escape => { 77 | out.push(' '); 78 | escape = false; 79 | } 80 | 'r' if escape => { 81 | out.push('\r'); 82 | escape = false; 83 | } 84 | 'n' if escape => { 85 | out.push('\n'); 86 | escape = false; 87 | } 88 | '⸝' => out.push(','), 89 | c => out.push(c), 90 | } 91 | } 92 | 93 | out 94 | } 95 | -------------------------------------------------------------------------------- /src/msg/clear_chat.rs: -------------------------------------------------------------------------------- 1 | //! Sent when the chat is cleared of a batch of messages. 2 | 3 | use super::{maybe_clone, parse_duration, parse_timestamp, MessageParseError}; 4 | use crate::irc::{Command, IrcMessageRef, Tag}; 5 | use chrono::{DateTime, Utc}; 6 | use std::borrow::Cow; 7 | use std::time::Duration; 8 | 9 | /// Sent when the chat is cleared of a batch of messages. 10 | #[derive(Clone, Debug, PartialEq, Eq)] 11 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 12 | pub struct ClearChat<'src> { 13 | #[cfg_attr(feature = "serde", serde(borrow))] 14 | channel: Cow<'src, str>, 15 | 16 | #[cfg_attr(feature = "serde", serde(borrow))] 17 | channel_id: Cow<'src, str>, 18 | 19 | #[cfg_attr(feature = "serde", serde(borrow))] 20 | action: Action<'src>, 21 | 22 | timestamp: DateTime, 23 | } 24 | 25 | generate_getters! { 26 | <'src> for ClearChat<'src> as self { 27 | /// Name of the affected channel. 28 | channel -> &str = self.channel.as_ref(), 29 | 30 | /// ID of the affected channel. 31 | channel_id -> &str = self.channel_id.as_ref(), 32 | 33 | /// The specific kind of [`Action`] that this command represents. 34 | action -> &Action<'src> = &self.action, 35 | 36 | /// Time at which the [`ClearChat`] was executed on Twitch servers. 37 | timestamp -> DateTime, 38 | } 39 | } 40 | 41 | impl<'src> ClearChat<'src> { 42 | /// Get the target of this [`ClearChat`] command. 43 | /// 44 | /// This returns the user which was timed out or banned. 45 | #[inline] 46 | pub fn target(&self) -> Option<&str> { 47 | use Action as C; 48 | match &self.action { 49 | C::Clear => None, 50 | C::Ban(Ban { user, .. }) | C::TimeOut(TimeOut { user, .. }) => Some(user), 51 | } 52 | } 53 | } 54 | 55 | /// Represents the specific way in which the chat was cleared. 56 | #[derive(Clone, Debug, PartialEq, Eq)] 57 | #[cfg_attr( 58 | feature = "serde", 59 | derive(serde::Serialize, serde::Deserialize), 60 | serde(rename_all = "lowercase") 61 | )] 62 | pub enum Action<'src> { 63 | /// The entire chat was cleared. 64 | Clear, 65 | 66 | /// A single user was banned, clearing only their messages. 67 | #[cfg_attr(feature = "serde", serde(borrow))] 68 | Ban(Ban<'src>), 69 | 70 | /// A single user was timed out, clearing only their messages. 71 | #[cfg_attr(feature = "serde", serde(borrow))] 72 | TimeOut(TimeOut<'src>), 73 | } 74 | 75 | impl<'src> Action<'src> { 76 | /// Returns `true` if the clear chat action is [`Clear`]. 77 | /// 78 | /// [`Clear`]: Action::Clear 79 | #[inline] 80 | pub fn is_clear(&self) -> bool { 81 | matches!(self, Self::Clear) 82 | } 83 | 84 | /// Returns `true` if the clear chat action is [`Ban`]. 85 | /// 86 | /// [`Ban`]: Action::Ban 87 | #[inline] 88 | pub fn is_ban(&self) -> bool { 89 | matches!(self, Self::Ban(..)) 90 | } 91 | 92 | /// Returns `true` if the clear chat action is [`TimeOut`]. 93 | /// 94 | /// [`TimeOut`]: Action::TimeOut 95 | #[inline] 96 | pub fn is_time_out(&self) -> bool { 97 | matches!(self, Self::TimeOut(..)) 98 | } 99 | } 100 | 101 | /// A single user was banned. 102 | #[derive(Clone, Debug, PartialEq, Eq)] 103 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 104 | pub struct Ban<'src> { 105 | #[cfg_attr(feature = "serde", serde(borrow))] 106 | user: Cow<'src, str>, 107 | 108 | #[cfg_attr(feature = "serde", serde(borrow))] 109 | id: Cow<'src, str>, 110 | } 111 | 112 | generate_getters! { 113 | <'src> for Ban<'src> as self { 114 | /// Login of the banned user. 115 | user -> &str = self.user.as_ref(), 116 | 117 | /// ID of the banned user. 118 | id -> &str = self.id.as_ref(), 119 | } 120 | } 121 | 122 | /// A single user was timed out. 123 | #[derive(Clone, Debug, PartialEq, Eq)] 124 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 125 | pub struct TimeOut<'src> { 126 | #[cfg_attr(feature = "serde", serde(borrow))] 127 | user: Cow<'src, str>, 128 | 129 | #[cfg_attr(feature = "serde", serde(borrow))] 130 | id: Cow<'src, str>, 131 | 132 | duration: Duration, 133 | } 134 | 135 | generate_getters! { 136 | <'src> for TimeOut<'src> as self { 137 | /// Login of the timed out user. 138 | user -> &str = self.user.as_ref(), 139 | 140 | /// ID of the timed out user. 141 | id -> &str = self.id.as_ref(), 142 | 143 | /// Duration of the timeout. 144 | duration -> Duration, 145 | } 146 | } 147 | 148 | impl<'src> ClearChat<'src> { 149 | fn parse(message: IrcMessageRef<'src>) -> Option { 150 | if message.command() != Command::ClearChat { 151 | return None; 152 | } 153 | 154 | Some(ClearChat { 155 | channel: message.channel()?.into(), 156 | channel_id: message.tag(Tag::RoomId)?.into(), 157 | action: match ( 158 | message.text(), 159 | message.tag(Tag::BanDuration).and_then(parse_duration), 160 | ) { 161 | (Some(name), Some(duration)) => Action::TimeOut(TimeOut { 162 | user: name.into(), 163 | id: message.tag(Tag::TargetUserId)?.into(), 164 | duration, 165 | }), 166 | (Some(name), None) => Action::Ban(Ban { 167 | user: name.into(), 168 | id: message.tag(Tag::TargetUserId)?.into(), 169 | }), 170 | (None, _) => Action::Clear, 171 | }, 172 | timestamp: parse_timestamp(message.tag(Tag::TmiSentTs)?)?, 173 | }) 174 | } 175 | 176 | /// Clone data to give the value a `'static` lifetime. 177 | pub fn into_owned(self) -> ClearChat<'static> { 178 | ClearChat { 179 | channel: maybe_clone(self.channel), 180 | channel_id: maybe_clone(self.channel_id), 181 | action: self.action.into_owned(), 182 | timestamp: self.timestamp, 183 | } 184 | } 185 | } 186 | 187 | impl<'src> Action<'src> { 188 | /// Clone data to give the value a `'static` lifetime. 189 | pub fn into_owned(self) -> Action<'static> { 190 | match self { 191 | Action::Clear => Action::Clear, 192 | Action::Ban(Ban { user, id }) => Action::Ban(Ban { 193 | user: maybe_clone(user), 194 | id: maybe_clone(id), 195 | }), 196 | Action::TimeOut(TimeOut { user, id, duration }) => Action::TimeOut(TimeOut { 197 | user: maybe_clone(user), 198 | id: maybe_clone(id), 199 | duration, 200 | }), 201 | } 202 | } 203 | } 204 | 205 | impl<'src> super::FromIrc<'src> for ClearChat<'src> { 206 | #[inline] 207 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 208 | Self::parse(message).ok_or(MessageParseError) 209 | } 210 | } 211 | 212 | impl<'src> From> for super::Message<'src> { 213 | fn from(msg: ClearChat<'src>) -> Self { 214 | super::Message::ClearChat(msg) 215 | } 216 | } 217 | 218 | #[cfg(test)] 219 | mod tests { 220 | use super::*; 221 | 222 | #[test] 223 | fn parse_clearchat_timeout() { 224 | assert_irc_snapshot!(ClearChat, "@ban-duration=1;room-id=11148817;target-user-id=148973258;tmi-sent-ts=1594553828245 :tmi.twitch.tv CLEARCHAT #pajlada :fabzeef"); 225 | } 226 | 227 | #[test] 228 | fn parse_clearchat_ban() { 229 | assert_irc_snapshot!(ClearChat, "@room-id=11148817;target-user-id=70948394;tmi-sent-ts=1594561360331 :tmi.twitch.tv CLEARCHAT #pajlada :weeb123"); 230 | } 231 | 232 | #[test] 233 | fn parse_clearchat_clear() { 234 | assert_irc_snapshot!( 235 | ClearChat, 236 | "@room-id=40286300;tmi-sent-ts=1594561392337 :tmi.twitch.tv CLEARCHAT #randers" 237 | ); 238 | } 239 | 240 | #[cfg(feature = "serde")] 241 | #[test] 242 | fn roundtrip_clearchat_timeout() { 243 | assert_irc_roundtrip!(ClearChat, "@ban-duration=1;room-id=11148817;target-user-id=148973258;tmi-sent-ts=1594553828245 :tmi.twitch.tv CLEARCHAT #pajlada :fabzeef"); 244 | } 245 | 246 | #[cfg(feature = "serde")] 247 | #[test] 248 | fn roundtrip_clearchat_ban() { 249 | assert_irc_roundtrip!(ClearChat, "@room-id=11148817;target-user-id=70948394;tmi-sent-ts=1594561360331 :tmi.twitch.tv CLEARCHAT #pajlada :weeb123"); 250 | } 251 | 252 | #[cfg(feature = "serde")] 253 | #[test] 254 | fn roundtrip_clearchat_clear() { 255 | assert_irc_roundtrip!( 256 | ClearChat, 257 | "@room-id=40286300;tmi-sent-ts=1594561392337 :tmi.twitch.tv CLEARCHAT #randers" 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/msg/clear_msg.rs: -------------------------------------------------------------------------------- 1 | //! Sent when a single message is deleted. 2 | 3 | use super::{maybe_clone, parse_message_text, parse_timestamp, MessageParseError}; 4 | use crate::irc::{Command, IrcMessageRef, Tag}; 5 | use chrono::{DateTime, Utc}; 6 | use std::borrow::Cow; 7 | 8 | /// Sent when a single message is deleted. 9 | #[derive(Clone, Debug, PartialEq, Eq)] 10 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 11 | pub struct ClearMsg<'src> { 12 | #[cfg_attr(feature = "serde", serde(borrow))] 13 | channel: Cow<'src, str>, 14 | 15 | #[cfg_attr(feature = "serde", serde(borrow))] 16 | channel_id: Cow<'src, str>, 17 | 18 | #[cfg_attr(feature = "serde", serde(borrow))] 19 | sender: Cow<'src, str>, 20 | 21 | #[cfg_attr(feature = "serde", serde(borrow))] 22 | target_message_id: Cow<'src, str>, 23 | 24 | #[cfg_attr(feature = "serde", serde(borrow))] 25 | text: Cow<'src, str>, 26 | 27 | is_action: bool, 28 | 29 | timestamp: DateTime, 30 | } 31 | 32 | generate_getters! { 33 | <'src> for ClearMsg<'src> as self { 34 | /// Login of the channel in which the message was deleted. 35 | channel -> &str = self.channel.as_ref(), 36 | 37 | /// ID of the channel in which the message was deleted. 38 | channel_id -> &str = self.channel_id.as_ref(), 39 | 40 | /// Login of the user which sent the deleted message. 41 | sender -> &str = self.sender.as_ref(), 42 | 43 | /// Unique ID of the deleted message. 44 | target_message_id -> &str = self.target_message_id.as_ref(), 45 | 46 | /// Text of the deleted message. 47 | text -> &str = self.text.as_ref(), 48 | 49 | /// Whether the deleted message was sent with `/me`. 50 | is_action -> bool, 51 | 52 | /// Time at which the [`ClearMsg`] was executed on Twitch servers. 53 | timestamp -> DateTime, 54 | } 55 | } 56 | 57 | impl<'src> ClearMsg<'src> { 58 | fn parse(message: IrcMessageRef<'src>) -> Option { 59 | if message.command() != Command::ClearMsg { 60 | return None; 61 | } 62 | 63 | let (text, is_action) = parse_message_text(message.text()?); 64 | Some(ClearMsg { 65 | channel: message.channel()?.into(), 66 | channel_id: message.tag(Tag::RoomId)?.into(), 67 | sender: message.tag(Tag::Login)?.into(), 68 | target_message_id: message.tag(Tag::TargetMsgId)?.into(), 69 | text: text.into(), 70 | is_action, 71 | timestamp: parse_timestamp(message.tag(Tag::TmiSentTs)?)?, 72 | }) 73 | } 74 | 75 | /// Clone data to give the value a `'static` lifetime. 76 | pub fn into_owned(self) -> ClearMsg<'static> { 77 | ClearMsg { 78 | channel: maybe_clone(self.channel), 79 | channel_id: maybe_clone(self.channel_id), 80 | sender: maybe_clone(self.sender), 81 | target_message_id: maybe_clone(self.target_message_id), 82 | text: maybe_clone(self.text), 83 | is_action: self.is_action, 84 | timestamp: self.timestamp, 85 | } 86 | } 87 | } 88 | 89 | impl<'src> super::FromIrc<'src> for ClearMsg<'src> { 90 | #[inline] 91 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 92 | Self::parse(message).ok_or(MessageParseError) 93 | } 94 | } 95 | 96 | impl<'src> From> for super::Message<'src> { 97 | fn from(msg: ClearMsg<'src>) -> Self { 98 | super::Message::ClearMsg(msg) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::*; 105 | 106 | #[test] 107 | fn parse_clearmsg_basic() { 108 | assert_irc_snapshot!(ClearMsg, "@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :lole"); 109 | } 110 | 111 | #[test] 112 | fn parse_clearmsg_action() { 113 | assert_irc_snapshot!(ClearMsg, "@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :\u{0001}ACTION lole\u{0001}"); 114 | } 115 | 116 | #[cfg(feature = "serde")] 117 | #[test] 118 | fn roundtrip_clearmsg_basic() { 119 | assert_irc_roundtrip!(ClearMsg, "@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :lole"); 120 | } 121 | 122 | #[cfg(feature = "serde")] 123 | #[test] 124 | fn roundtrip_clearmsg_action() { 125 | assert_irc_roundtrip!(ClearMsg, "@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :\u{0001}ACTION lole\u{0001}"); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/msg/global_user_state.rs: -------------------------------------------------------------------------------- 1 | //! This command is sent once upon successful login to Twitch IRC. 2 | 3 | use super::{ 4 | is_not_empty, maybe_clone, maybe_unescape, parse_badges, split_comma, Badge, MessageParseError, 5 | }; 6 | use crate::irc::{Command, IrcMessageRef, Tag}; 7 | use std::borrow::Cow; 8 | 9 | /// This command is sent once upon successful login to Twitch IRC. 10 | #[derive(Clone, Debug, PartialEq, Eq)] 11 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 12 | pub struct GlobalUserState<'src> { 13 | #[cfg_attr(feature = "serde", serde(borrow))] 14 | id: Cow<'src, str>, 15 | 16 | #[cfg_attr(feature = "serde", serde(borrow))] 17 | name: Cow<'src, str>, 18 | 19 | #[cfg_attr(feature = "serde", serde(borrow))] 20 | badges: Vec>, 21 | 22 | #[cfg_attr(feature = "serde", serde(borrow))] 23 | emote_sets: Vec>, 24 | 25 | #[cfg_attr(feature = "serde", serde(borrow))] 26 | color: Option>, 27 | } 28 | 29 | generate_getters! { 30 | <'src> for GlobalUserState<'src> as self { 31 | /// ID of the logged in user. 32 | id -> &str = self.id.as_ref(), 33 | 34 | /// Display name of the logged in user. 35 | /// 36 | /// This is the name which appears in chat, and may contain arbitrary unicode characters. 37 | /// It is separate from the user login, which is always only ASCII. 38 | /// 39 | /// ⚠ This call will allocate and return a String if it needs to be unescaped. 40 | name -> Cow<'src, str> = maybe_unescape(self.name.clone()), 41 | 42 | /// Iterator over global badges. 43 | badges -> impl DoubleEndedIterator> + ExactSizeIterator 44 | = self.badges.iter(), 45 | 46 | /// Number of global badges. 47 | num_badges -> usize = self.badges.len(), 48 | 49 | /// Iterator over emote sets which are available globally. 50 | emote_sets -> impl DoubleEndedIterator + ExactSizeIterator 51 | = self.emote_sets.iter().map(|v| v.as_ref()), 52 | 53 | /// Number of emote sets which are available globally. 54 | num_emote_sets -> usize = self.emote_sets.len(), 55 | 56 | /// Chat name color. 57 | /// 58 | /// [`None`] means the user has not selected a color. 59 | /// To match the behavior of Twitch, users should be 60 | /// given a globally-consistent random color. 61 | color -> Option<&str> = self.color.as_deref(), 62 | } 63 | } 64 | 65 | impl<'src> GlobalUserState<'src> { 66 | fn parse(message: IrcMessageRef<'src>) -> Option { 67 | if message.command() != Command::GlobalUserState { 68 | return None; 69 | } 70 | 71 | Some(GlobalUserState { 72 | id: message.tag(Tag::UserId)?.into(), 73 | name: message.tag(Tag::DisplayName)?.into(), 74 | badges: message 75 | .tag(Tag::Badges) 76 | .zip(message.tag(Tag::BadgeInfo)) 77 | .map(|(badges, badge_info)| parse_badges(badges, badge_info)) 78 | .unwrap_or_default(), 79 | emote_sets: message 80 | .tag(Tag::EmoteSets) 81 | .map(split_comma) 82 | .map(|i| i.map(Cow::Borrowed)) 83 | .map(Iterator::collect) 84 | .unwrap_or_default(), 85 | color: message 86 | .tag(Tag::Color) 87 | .filter(is_not_empty) 88 | .map(Cow::Borrowed), 89 | }) 90 | } 91 | 92 | /// Clone data to give the value a `'static` lifetime. 93 | pub fn into_owned(self) -> GlobalUserState<'static> { 94 | GlobalUserState { 95 | id: maybe_clone(self.id), 96 | name: maybe_clone(self.name), 97 | badges: self.badges.into_iter().map(Badge::into_owned).collect(), 98 | emote_sets: self.emote_sets.into_iter().map(maybe_clone).collect(), 99 | color: self.color.map(maybe_clone), 100 | } 101 | } 102 | } 103 | 104 | impl<'src> super::FromIrc<'src> for GlobalUserState<'src> { 105 | #[inline] 106 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 107 | Self::parse(message).ok_or(MessageParseError) 108 | } 109 | } 110 | 111 | impl<'src> From> for super::Message<'src> { 112 | fn from(msg: GlobalUserState<'src>) -> Self { 113 | super::Message::GlobalUserState(msg) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::*; 120 | 121 | #[test] 122 | fn parse_globaluserstate() { 123 | assert_irc_snapshot!(GlobalUserState, "@badge-info=;badges=;color=;display-name=randers811;emote-sets=0;user-id=553170741;user-type= :tmi.twitch.tv GLOBALUSERSTATE"); 124 | } 125 | 126 | #[cfg(feature = "serde")] 127 | #[test] 128 | fn roundtrip_globaluserstate() { 129 | assert_irc_roundtrip!(GlobalUserState, "@badge-info=;badges=;color=;display-name=randers811;emote-sets=0;user-id=553170741;user-type= :tmi.twitch.tv GLOBALUSERSTATE"); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/msg/join.rs: -------------------------------------------------------------------------------- 1 | //! Sent when a user joins a channel. 2 | 3 | use super::{maybe_clone, MessageParseError}; 4 | use crate::irc::{Command, IrcMessageRef}; 5 | use std::borrow::Cow; 6 | 7 | /// Sent when a user joins a channel. 8 | #[derive(Clone, Debug, PartialEq, Eq)] 9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 10 | pub struct Join<'src> { 11 | #[cfg_attr(feature = "serde", serde(borrow))] 12 | channel: Cow<'src, str>, 13 | 14 | #[cfg_attr(feature = "serde", serde(borrow))] 15 | user: Cow<'src, str>, 16 | } 17 | 18 | generate_getters! { 19 | <'src> for Join<'src> as self { 20 | /// Joined channel name. 21 | channel -> &str = self.channel.as_ref(), 22 | 23 | /// Login of the user. 24 | user -> &str = self.user.as_ref(), 25 | } 26 | } 27 | 28 | impl<'src> Join<'src> { 29 | fn parse(message: IrcMessageRef<'src>) -> Option { 30 | if message.command() != Command::Join { 31 | return None; 32 | } 33 | 34 | Some(Join { 35 | channel: message.channel()?.into(), 36 | user: message 37 | .prefix() 38 | .and_then(|prefix| prefix.nick) 39 | .map(Cow::Borrowed)?, 40 | }) 41 | } 42 | 43 | /// Clone data to give the value a `'static` lifetime. 44 | pub fn into_owned(self) -> Join<'static> { 45 | Join { 46 | channel: maybe_clone(self.channel), 47 | user: maybe_clone(self.user), 48 | } 49 | } 50 | } 51 | 52 | impl<'src> super::FromIrc<'src> for Join<'src> { 53 | #[inline] 54 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 55 | Self::parse(message).ok_or(MessageParseError) 56 | } 57 | } 58 | 59 | impl<'src> From> for super::Message<'src> { 60 | fn from(msg: Join<'src>) -> Self { 61 | super::Message::Join(msg) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | 69 | #[test] 70 | fn parse_join() { 71 | assert_irc_snapshot!( 72 | Join, 73 | ":randers811!randers811@randers811.tmi.twitch.tv JOIN #pajlada" 74 | ); 75 | } 76 | 77 | #[cfg(feature = "serde")] 78 | #[test] 79 | fn roundtrip_join() { 80 | assert_irc_roundtrip!( 81 | Join, 82 | ":randers811!randers811@randers811.tmi.twitch.tv JOIN #pajlada" 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/msg/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! generate_getters { 2 | { 3 | $(<$L:lifetime>)? for $T:ty as $self:ident { 4 | $( 5 | $(#[$meta:meta])* 6 | $field:ident -> $R:ty $(= $e:expr)? 7 | ),* $(,)? 8 | } 9 | } => { 10 | impl$(<$L>)? $T { 11 | $( 12 | #[inline] 13 | $(#[$meta])* 14 | pub fn $field(&$self) -> $R { 15 | generate_getters!(@getter $self $field $($e)?) 16 | } 17 | )* 18 | } 19 | }; 20 | 21 | (@getter $self:ident $field:ident $e:expr) => ($e); 22 | (@getter $self:ident $field:ident) => ($self.$field.clone()); 23 | } 24 | 25 | #[cfg(test)] 26 | pub(crate) fn _parse_irc<'src, T: crate::msg::FromIrc<'src>>(input: &'src str) -> T { 27 | let raw = crate::irc::IrcMessageRef::parse(input).unwrap(); 28 | ::from_irc(raw).unwrap() 29 | } 30 | 31 | #[cfg(test)] 32 | macro_rules! assert_irc_snapshot { 33 | ($T:ty, $input:literal,) => { 34 | assert_irc_snapshot!($T, $input) 35 | }; 36 | ($T:ty, $input:literal) => {{ 37 | let f = $crate::msg::macros::_parse_irc::<$T>; 38 | ::insta::assert_debug_snapshot!(f($input)) 39 | }}; 40 | } 41 | 42 | #[cfg(all(test, feature = "serde"))] 43 | macro_rules! assert_irc_roundtrip { 44 | ($T:ty, $input:literal,) => { 45 | assert_irc_roundtrip!($T, $input) 46 | }; 47 | ($T:ty, $input:literal) => {{ 48 | let original = $crate::msg::macros::_parse_irc::<$T>($input); 49 | let serialized = ::serde_json::to_string(&original).expect("failed to serialize"); 50 | let deserialized = ::serde_json::from_str(&serialized).expect("failed to deserialize"); 51 | assert_eq!(original, deserialized, "roundtrip failed"); 52 | }}; 53 | } 54 | -------------------------------------------------------------------------------- /src/msg/notice.rs: -------------------------------------------------------------------------------- 1 | //! Sent by Twitch for various reasons to notify the client about something, 2 | //! usually in response to invalid actions. 3 | 4 | use super::{maybe_clone, MessageParseError}; 5 | use crate::irc::{Command, IrcMessageRef, Tag}; 6 | use std::borrow::Cow; 7 | 8 | /// Sent by TMI for various reasons to notify the client about something, 9 | /// usually in response to invalid actions. 10 | #[derive(Clone, Debug, PartialEq, Eq)] 11 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 12 | pub struct Notice<'src> { 13 | #[cfg_attr(feature = "serde", serde(borrow))] 14 | channel: Option>, 15 | 16 | #[cfg_attr(feature = "serde", serde(borrow))] 17 | text: Cow<'src, str>, 18 | 19 | #[cfg_attr(feature = "serde", serde(borrow))] 20 | id: Option>, 21 | } 22 | 23 | generate_getters! { 24 | <'src> for Notice<'src> as self { 25 | /// Target channel name. 26 | /// 27 | /// This may be empty before successful login. 28 | channel -> Option<&str> = self.channel.as_deref(), 29 | 30 | /// Notice message. 31 | text -> &str = self.text.as_ref(), 32 | 33 | /// Notice ID, see . 34 | /// 35 | /// This will only be empty before successful login. 36 | id -> Option<&str> = self.id.as_deref(), 37 | } 38 | } 39 | 40 | impl<'src> Notice<'src> { 41 | fn parse(message: IrcMessageRef<'src>) -> Option { 42 | if message.command() != Command::Notice { 43 | return None; 44 | } 45 | 46 | Some(Notice { 47 | channel: message.channel().map(Cow::Borrowed), 48 | text: message.text()?.into(), 49 | id: message.tag(Tag::MsgId).map(Cow::Borrowed), 50 | }) 51 | } 52 | 53 | /// Clone data to give the value a `'static` lifetime. 54 | pub fn into_owned(self) -> Notice<'static> { 55 | Notice { 56 | channel: self.channel.map(maybe_clone), 57 | text: maybe_clone(self.text), 58 | id: self.id.map(maybe_clone), 59 | } 60 | } 61 | } 62 | 63 | impl<'src> super::FromIrc<'src> for Notice<'src> { 64 | #[inline] 65 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 66 | Self::parse(message).ok_or(MessageParseError) 67 | } 68 | } 69 | 70 | impl<'src> From> for super::Message<'src> { 71 | fn from(msg: Notice<'src>) -> Self { 72 | super::Message::Notice(msg) 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | 80 | #[test] 81 | fn parse_notice_before_login() { 82 | assert_irc_snapshot!(Notice, ":tmi.twitch.tv NOTICE * :Improperly formatted auth"); 83 | } 84 | 85 | #[test] 86 | fn parse_notice_basic() { 87 | assert_irc_snapshot!(Notice, "@msg-id=msg_banned :tmi.twitch.tv NOTICE #forsen :You are permanently banned from talking in forsen."); 88 | } 89 | 90 | #[cfg(feature = "serde")] 91 | #[test] 92 | fn roundtrip_notice_before_login() { 93 | assert_irc_roundtrip!(Notice, ":tmi.twitch.tv NOTICE * :Improperly formatted auth"); 94 | } 95 | 96 | #[cfg(feature = "serde")] 97 | #[test] 98 | fn roundtrip_notice_basic() { 99 | assert_irc_roundtrip!(Notice, "@msg-id=msg_banned :tmi.twitch.tv NOTICE #forsen :You are permanently banned from talking in forsen."); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/msg/part.rs: -------------------------------------------------------------------------------- 1 | //! Sent when a user leaves a channel. 2 | 3 | use super::{maybe_clone, MessageParseError}; 4 | use crate::irc::{Command, IrcMessageRef}; 5 | use std::borrow::Cow; 6 | 7 | /// Sent when a user leaves a channel. 8 | #[derive(Clone, Debug, PartialEq, Eq)] 9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 10 | pub struct Part<'src> { 11 | #[cfg_attr(feature = "serde", serde(borrow))] 12 | channel: Cow<'src, str>, 13 | 14 | #[cfg_attr(feature = "serde", serde(borrow))] 15 | user: Cow<'src, str>, 16 | } 17 | 18 | generate_getters! { 19 | <'src> for Part<'src> as self { 20 | /// Parted channel name. 21 | channel -> &str = self.channel.as_ref(), 22 | 23 | /// Login of the user. 24 | user -> &str = self.user.as_ref(), 25 | } 26 | } 27 | 28 | impl<'src> Part<'src> { 29 | fn parse(message: IrcMessageRef<'src>) -> Option { 30 | if message.command() != Command::Part { 31 | return None; 32 | } 33 | 34 | Some(Part { 35 | channel: message.channel()?.into(), 36 | user: message 37 | .prefix() 38 | .and_then(|prefix| prefix.nick) 39 | .map(Cow::Borrowed)?, 40 | }) 41 | } 42 | 43 | /// Clone data to give the value a `'static` lifetime. 44 | pub fn into_owned(self) -> Part<'static> { 45 | Part { 46 | channel: maybe_clone(self.channel), 47 | user: maybe_clone(self.user), 48 | } 49 | } 50 | } 51 | 52 | impl<'src> super::FromIrc<'src> for Part<'src> { 53 | #[inline] 54 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 55 | Self::parse(message).ok_or(MessageParseError) 56 | } 57 | } 58 | 59 | impl<'src> From> for super::Message<'src> { 60 | fn from(msg: Part<'src>) -> Self { 61 | super::Message::Part(msg) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | 69 | #[test] 70 | fn parse_join() { 71 | assert_irc_snapshot!( 72 | Part, 73 | ":randers811!randers811@randers811.tmi.twitch.tv PART #pajlada" 74 | ); 75 | } 76 | 77 | #[cfg(feature = "serde")] 78 | #[test] 79 | fn roundtrip_join() { 80 | assert_irc_roundtrip!( 81 | Part, 82 | ":randers811!randers811@randers811.tmi.twitch.tv PART #pajlada" 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/msg/ping.rs: -------------------------------------------------------------------------------- 1 | //! Sent regularly by TMI to ensure clients are still live. 2 | //! You must respond to TMI pings with a [`Pong`][Pong]. 3 | //! 4 | //! TMI will also respond with a pong if you send it a ping, 5 | //! combined with the [`Ping::nonce`], this can be useful 6 | //! to measure round-trip latency. 7 | //! 8 | //! [Pong]: crate::msg::pong::Pong 9 | 10 | use super::{maybe_clone, MessageParseError}; 11 | use crate::irc::{Command, IrcMessageRef}; 12 | use std::borrow::Cow; 13 | 14 | /// Sent regularly by TMI to ensure clients are still live. 15 | /// You must respond to TMI pings with a [`Pong`][Pong]. 16 | /// 17 | /// TMI will also respond with a pong if you send it a ping, 18 | /// combined with the [`Ping::nonce`], this can be useful 19 | /// to measure round-trip latency. 20 | /// 21 | /// [Pong]: crate::msg::pong::Pong 22 | #[derive(Clone, Debug, PartialEq, Eq)] 23 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 24 | pub struct Ping<'src> { 25 | #[cfg_attr(feature = "serde", serde(borrow))] 26 | nonce: Option>, 27 | } 28 | 29 | generate_getters! { 30 | <'src> for Ping<'src> as self { 31 | /// Unique string sent with this ping. 32 | nonce -> Option<&str> = self.nonce.as_deref(), 33 | } 34 | } 35 | 36 | impl<'src> Ping<'src> { 37 | fn parse(message: IrcMessageRef<'src>) -> Option { 38 | if message.command() != Command::Ping { 39 | return None; 40 | } 41 | 42 | Some(Ping { 43 | nonce: message.text().map(Cow::Borrowed), 44 | }) 45 | } 46 | 47 | /// Clone data to give the value a `'static` lifetime. 48 | pub fn into_owned(self) -> Ping<'static> { 49 | Ping { 50 | nonce: self.nonce.map(maybe_clone), 51 | } 52 | } 53 | } 54 | 55 | impl<'src> super::FromIrc<'src> for Ping<'src> { 56 | #[inline] 57 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 58 | Self::parse(message).ok_or(MessageParseError) 59 | } 60 | } 61 | 62 | impl<'src> From> for super::Message<'src> { 63 | fn from(msg: Ping<'src>) -> Self { 64 | super::Message::Ping(msg) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::*; 71 | 72 | #[test] 73 | fn parse_ping() { 74 | assert_irc_snapshot!(Ping, ":tmi.twitch.tv PING"); 75 | } 76 | 77 | #[test] 78 | fn parse_ping_nonce() { 79 | assert_irc_snapshot!(Ping, ":tmi.twitch.tv PING :nonce"); 80 | } 81 | 82 | #[cfg(feature = "serde")] 83 | #[test] 84 | fn roundtrip_ping() { 85 | assert_irc_roundtrip!(Ping, ":tmi.twitch.tv PING"); 86 | } 87 | 88 | #[cfg(feature = "serde")] 89 | #[test] 90 | fn roundtrip_ping_nonce() { 91 | assert_irc_roundtrip!(Ping, ":tmi.twitch.tv PING :nonce"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/msg/pong.rs: -------------------------------------------------------------------------------- 1 | //! Sent by TMI as a response to a [`Ping`][Ping]. 2 | //! 3 | //! If the [`Ping`][Ping] contained a [`Ping::nonce`][nonce], 4 | //! the same nonce will be set to [`Pong::nonce`]. 5 | //! 6 | //! [Ping]: crate::msg::ping::Ping 7 | //! [nonce]: crate::msg::ping::Ping::nonce 8 | 9 | use super::{maybe_clone, MessageParseError}; 10 | use crate::irc::{Command, IrcMessageRef}; 11 | use std::borrow::Cow; 12 | 13 | /// Sent by TMI as a response to a [`Ping`][Ping]. 14 | /// 15 | /// If the [`Ping`][Ping] contained a [`Ping::nonce`][nonce], 16 | /// the same nonce will be set to [`Pong::nonce`]. 17 | /// 18 | /// [Ping]: crate::msg::ping::Ping 19 | /// [nonce]: crate::msg::ping::Ping::nonce 20 | #[derive(Clone, Debug, PartialEq, Eq)] 21 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 22 | pub struct Pong<'src> { 23 | #[cfg_attr(feature = "serde", serde(borrow))] 24 | nonce: Option>, 25 | } 26 | 27 | generate_getters! { 28 | <'src> for Pong<'src> as self { 29 | /// Unique string sent with this ping. 30 | nonce -> Option<&str> = self.nonce.as_deref(), 31 | } 32 | } 33 | 34 | impl<'src> Pong<'src> { 35 | fn parse(message: IrcMessageRef<'src>) -> Option { 36 | if message.command() != Command::Pong { 37 | return None; 38 | } 39 | 40 | Some(Pong { 41 | nonce: message.text().map(Cow::Borrowed), 42 | }) 43 | } 44 | 45 | /// Clone data to give the value a `'static` lifetime. 46 | pub fn into_owned(self) -> Pong<'static> { 47 | Pong { 48 | nonce: self.nonce.map(maybe_clone), 49 | } 50 | } 51 | } 52 | 53 | impl<'src> super::FromIrc<'src> for Pong<'src> { 54 | #[inline] 55 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 56 | Self::parse(message).ok_or(MessageParseError) 57 | } 58 | } 59 | 60 | impl<'src> From> for super::Message<'src> { 61 | fn from(msg: Pong<'src>) -> Self { 62 | super::Message::Pong(msg) 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | 70 | #[test] 71 | fn parse_ping() { 72 | assert_irc_snapshot!(Pong, ":tmi.twitch.tv PONG"); 73 | } 74 | 75 | #[test] 76 | fn parse_ping_nonce() { 77 | assert_irc_snapshot!(Pong, ":tmi.twitch.tv PONG :nonce"); 78 | } 79 | 80 | #[cfg(feature = "serde")] 81 | #[test] 82 | fn roundtrip_ping() { 83 | assert_irc_roundtrip!(Pong, ":tmi.twitch.tv PONG"); 84 | } 85 | 86 | #[cfg(feature = "serde")] 87 | #[test] 88 | fn roundtrip_ping_nonce() { 89 | assert_irc_roundtrip!(Pong, ":tmi.twitch.tv PONG :nonce"); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/msg/room_state.rs: -------------------------------------------------------------------------------- 1 | //! A partial update to the settings of some channel. 2 | 3 | use super::{maybe_clone, parse_bool, MessageParseError}; 4 | use crate::irc::{Command, IrcMessageRef, Tag}; 5 | use std::borrow::Cow; 6 | use std::time::Duration; 7 | 8 | /// A partial update to the settings of some channel. 9 | #[derive(Clone, Debug, PartialEq, Eq)] 10 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 11 | pub struct RoomState<'src> { 12 | #[cfg_attr(feature = "serde", serde(borrow))] 13 | channel: Cow<'src, str>, 14 | 15 | #[cfg_attr(feature = "serde", serde(borrow))] 16 | channel_id: Cow<'src, str>, 17 | 18 | emote_only: Option, 19 | 20 | followers_only: Option, 21 | 22 | r9k: Option, 23 | 24 | slow: Option, 25 | 26 | subs_only: Option, 27 | } 28 | 29 | generate_getters! { 30 | <'src> for RoomState<'src> as self { 31 | /// Login of the channel this state was applied to. 32 | channel -> &str = self.channel.as_ref(), 33 | 34 | /// ID of the channel this state was applied to. 35 | channel_id -> &str = self.channel_id.as_ref(), 36 | 37 | /// Whether the room is in emote-only mode. 38 | /// 39 | /// Chat messages may only contain emotes. 40 | /// 41 | /// - [`None`] means no change. 42 | /// - [`Some`] means enabled if `true`, and disabled if `false`. 43 | emote_only -> Option, 44 | 45 | /// Whether the room is in followers-only mode. 46 | /// 47 | /// Only followers (optionally with a minimum followage) can chat. 48 | /// 49 | /// - [`None`] means no change. 50 | /// - [`Some`] means some change, see [`FollowersOnly`] for more information about possible values. 51 | followers_only -> Option, 52 | 53 | /// Whether the room is in r9k mode. 54 | /// 55 | /// Only unique messages may be sent to chat. 56 | r9k -> Option, 57 | 58 | /// Whether the room is in slow mode. 59 | /// 60 | /// Users may only send messages with some minimum time between them. 61 | slow -> Option, 62 | 63 | /// Whether the room is in subcriber-only mode. 64 | /// 65 | /// Users may only send messages if they have an active subscription. 66 | subs_only -> Option, 67 | } 68 | } 69 | 70 | /// Followers-only mode configuration. 71 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 72 | #[cfg_attr( 73 | feature = "serde", 74 | derive(serde::Serialize, serde::Deserialize), 75 | serde(rename_all = "lowercase") 76 | )] 77 | pub enum FollowersOnly { 78 | /// Followers-only mode is disabled. 79 | /// 80 | /// Anyone can send chat messages within the bounds 81 | /// of the other chat settings. 82 | Disabled, 83 | 84 | /// Followers-only mode is enabled, with an optional duration. 85 | /// 86 | /// If the duration is [`None`], then all followers can chat. 87 | /// Otherwise, only followers which have a follow age of at 88 | /// least the set duration can chat. 89 | Enabled(Option), 90 | } 91 | 92 | impl<'src> RoomState<'src> { 93 | fn parse(message: IrcMessageRef<'src>) -> Option { 94 | if message.command() != Command::RoomState { 95 | return None; 96 | } 97 | 98 | Some(RoomState { 99 | channel: message.channel()?.into(), 100 | channel_id: message.tag(Tag::RoomId)?.into(), 101 | emote_only: message.tag(Tag::EmoteOnly).map(parse_bool), 102 | followers_only: message 103 | .tag(Tag::FollowersOnly) 104 | .and_then(|v| v.parse().ok()) 105 | .map(|n: i64| match n { 106 | n if n > 0 => FollowersOnly::Enabled(Some(Duration::from_secs((n * 60) as u64))), 107 | 0 => FollowersOnly::Enabled(None), 108 | _ => FollowersOnly::Disabled, 109 | }), 110 | r9k: message.tag(Tag::R9K).map(parse_bool), 111 | slow: message 112 | .tag(Tag::Slow) 113 | .and_then(|v| v.parse().ok()) 114 | .map(Duration::from_secs), 115 | subs_only: message.tag(Tag::SubsOnly).map(parse_bool), 116 | }) 117 | } 118 | 119 | /// Clone data to give the value a `'static` lifetime. 120 | pub fn into_owned(self) -> RoomState<'static> { 121 | RoomState { 122 | channel: maybe_clone(self.channel), 123 | channel_id: maybe_clone(self.channel_id), 124 | emote_only: self.emote_only, 125 | followers_only: self.followers_only, 126 | r9k: self.r9k, 127 | slow: self.slow, 128 | subs_only: self.subs_only, 129 | } 130 | } 131 | } 132 | 133 | impl<'src> super::FromIrc<'src> for RoomState<'src> { 134 | #[inline] 135 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 136 | Self::parse(message).ok_or(MessageParseError) 137 | } 138 | } 139 | 140 | impl<'src> From> for super::Message<'src> { 141 | fn from(msg: RoomState<'src>) -> Self { 142 | super::Message::RoomState(msg) 143 | } 144 | } 145 | 146 | #[cfg(test)] 147 | mod tests { 148 | use super::*; 149 | 150 | #[test] 151 | fn parse_room_state_basic_full() { 152 | assert_irc_snapshot!(RoomState, "@emote-only=0;followers-only=-1;r9k=0;rituals=0;room-id=40286300;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #randers"); 153 | } 154 | 155 | #[test] 156 | fn parse_room_state_basic_full2() { 157 | assert_irc_snapshot!(RoomState, "@emote-only=1;followers-only=0;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers"); 158 | } 159 | 160 | #[test] 161 | fn parse_room_state_followers_non_zero() { 162 | assert_irc_snapshot!(RoomState, "@emote-only=1;followers-only=10;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers"); 163 | } 164 | 165 | #[test] 166 | fn parse_room_state_partial_1() { 167 | assert_irc_snapshot!( 168 | RoomState, 169 | "@room-id=40286300;slow=5 :tmi.twitch.tv ROOMSTATE #randers" 170 | ); 171 | } 172 | 173 | #[test] 174 | fn parse_room_state_partial_2() { 175 | assert_irc_snapshot!( 176 | RoomState, 177 | "@emote-only=1;room-id=40286300 :tmi.twitch.tv ROOMSTATE #randers" 178 | ); 179 | } 180 | 181 | #[cfg(feature = "serde")] 182 | #[test] 183 | fn roundtrip_room_state_basic_full() { 184 | assert_irc_roundtrip!(RoomState, "@emote-only=0;followers-only=-1;r9k=0;rituals=0;room-id=40286300;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #randers"); 185 | } 186 | 187 | #[cfg(feature = "serde")] 188 | #[test] 189 | fn roundtrip_room_state_basic_full2() { 190 | assert_irc_roundtrip!(RoomState, "@emote-only=1;followers-only=0;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers"); 191 | } 192 | 193 | #[cfg(feature = "serde")] 194 | #[test] 195 | fn roundtrip_room_state_followers_non_zero() { 196 | assert_irc_roundtrip!(RoomState, "@emote-only=1;followers-only=10;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers"); 197 | } 198 | 199 | #[cfg(feature = "serde")] 200 | #[test] 201 | fn roundtrip_room_state_partial_1() { 202 | assert_irc_roundtrip!( 203 | RoomState, 204 | "@room-id=40286300;slow=5 :tmi.twitch.tv ROOMSTATE #randers" 205 | ); 206 | } 207 | 208 | #[cfg(feature = "serde")] 209 | #[test] 210 | fn roundtrip_room_state_partial_2() { 211 | assert_irc_roundtrip!( 212 | RoomState, 213 | "@emote-only=1;room-id=40286300 :tmi.twitch.tv ROOMSTATE #randers" 214 | ); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__clear_chat__tests__parse_clearchat_ban.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/clear_chat.rs 3 | expression: "f(\"@room-id=11148817;target-user-id=70948394;tmi-sent-ts=1594561360331 :tmi.twitch.tv CLEARCHAT #pajlada :weeb123\")" 4 | --- 5 | ClearChat { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | action: Ban( 9 | Ban { 10 | user: "weeb123", 11 | id: "70948394", 12 | }, 13 | ), 14 | timestamp: 2020-07-12T13:42:40.331Z, 15 | } 16 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__clear_chat__tests__parse_clearchat_clear.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/clear_chat.rs 3 | expression: "f(\"@room-id=40286300;tmi-sent-ts=1594561392337 :tmi.twitch.tv CLEARCHAT #randers\")" 4 | --- 5 | ClearChat { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | action: Clear, 9 | timestamp: 2020-07-12T13:43:12.337Z, 10 | } 11 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__clear_chat__tests__parse_clearchat_timeout.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/clear_chat.rs 3 | expression: "f(\"@ban-duration=1;room-id=11148817;target-user-id=148973258;tmi-sent-ts=1594553828245 :tmi.twitch.tv CLEARCHAT #pajlada :fabzeef\")" 4 | --- 5 | ClearChat { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | action: TimeOut( 9 | TimeOut { 10 | user: "fabzeef", 11 | id: "148973258", 12 | duration: 1s, 13 | }, 14 | ), 15 | timestamp: 2020-07-12T11:37:08.245Z, 16 | } 17 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__clear_msg__tests__parse_clearmsg_action.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/clear_msg.rs 3 | expression: "f(\"@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :\\u{0001}ACTION lole\\u{0001}\")" 4 | --- 5 | ClearMsg { 6 | channel: "#pajlada", 7 | channel_id: "", 8 | sender: "alazymeme", 9 | target_message_id: "3c92014f-340a-4dc3-a9c9-e5cf182f4a84", 10 | text: "lole", 11 | is_action: true, 12 | timestamp: 2020-07-12T13:52:35.611Z, 13 | } 14 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__clear_msg__tests__parse_clearmsg_basic.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/clear_msg.rs 3 | expression: "f(\"@login=alazymeme;room-id=;target-msg-id=3c92014f-340a-4dc3-a9c9-e5cf182f4a84;tmi-sent-ts=1594561955611 :tmi.twitch.tv CLEARMSG #pajlada :lole\")" 4 | --- 5 | ClearMsg { 6 | channel: "#pajlada", 7 | channel_id: "", 8 | sender: "alazymeme", 9 | target_message_id: "3c92014f-340a-4dc3-a9c9-e5cf182f4a84", 10 | text: "lole", 11 | is_action: false, 12 | timestamp: 2020-07-12T13:52:35.611Z, 13 | } 14 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__global_user_state__tests__parse_globaluserstate.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/global_user_state.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=randers811;emote-sets=0;user-id=553170741;user-type= :tmi.twitch.tv GLOBALUSERSTATE\")" 4 | --- 5 | GlobalUserState { 6 | id: "553170741", 7 | name: "randers811", 8 | badges: [], 9 | emote_sets: [ 10 | "0", 11 | ], 12 | color: None, 13 | } 14 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__join__tests__parse_join.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/join.rs 3 | expression: "f(\":randers811!randers811@randers811.tmi.twitch.tv JOIN #pajlada\")" 4 | --- 5 | Join { 6 | channel: "#pajlada", 7 | user: "randers811", 8 | } 9 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__notice__tests__parse_notice_basic.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/notice.rs 3 | expression: "f(\"@msg-id=msg_banned :tmi.twitch.tv NOTICE #forsen :You are permanently banned from talking in forsen.\")" 4 | --- 5 | Notice { 6 | channel: Some( 7 | "#forsen", 8 | ), 9 | text: "You are permanently banned from talking in forsen.", 10 | id: Some( 11 | "msg_banned", 12 | ), 13 | } 14 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__notice__tests__parse_notice_before_login.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/notice.rs 3 | expression: "f(\":tmi.twitch.tv NOTICE * :Improperly formatted auth\")" 4 | --- 5 | Notice { 6 | channel: None, 7 | text: "Improperly formatted auth", 8 | id: None, 9 | } 10 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__part__tests__parse_join.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/part.rs 3 | expression: "f(\":randers811!randers811@randers811.tmi.twitch.tv PART #pajlada\")" 4 | --- 5 | Part { 6 | channel: "#pajlada", 7 | user: "randers811", 8 | } 9 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__ping__tests__parse_ping.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/ping.rs 3 | expression: "f(\":tmi.twitch.tv PING\")" 4 | --- 5 | Ping { 6 | nonce: None, 7 | } 8 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__ping__tests__parse_ping_nonce.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/ping.rs 3 | expression: "f(\":tmi.twitch.tv PING :nonce\")" 4 | --- 5 | Ping { 6 | nonce: Some( 7 | "nonce", 8 | ), 9 | } 10 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__pong__tests__parse_ping.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/pong.rs 3 | expression: "f(\":tmi.twitch.tv PONG\")" 4 | --- 5 | Pong { 6 | nonce: None, 7 | } 8 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__pong__tests__parse_ping_nonce.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/pong.rs 3 | expression: "f(\":tmi.twitch.tv PONG :nonce\")" 4 | --- 5 | Pong { 6 | nonce: Some( 7 | "nonce", 8 | ), 9 | } 10 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_action_and_badges.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=subscriber/22;badges=moderator/1,subscriber/12;color=#19E6E6;display-name=randers;emotes=;flags=;id=d831d848-b7c7-4559-ae3a-2cb88f4dbfed;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1594555275886;turbo=0;user-id=40286300;user-type=mod :randers!randers@randers.tmi.twitch.tv PRIVMSG #pajlada :\u0001ACTION -tags\u0001\")" 4 | --- 5 | Privmsg { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | msg_id: None, 9 | id: "d831d848-b7c7-4559-ae3a-2cb88f4dbfed", 10 | sender: User { 11 | id: "40286300", 12 | login: "randers", 13 | name: "randers", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "-tags", 18 | is_action: true, 19 | badges: [ 20 | Moderator, 21 | Subscriber( 22 | Subscriber { 23 | version: "12", 24 | months: "22", 25 | months_n: 22, 26 | }, 27 | ), 28 | ], 29 | color: Some( 30 | "#19E6E6", 31 | ), 32 | custom_reward_id: None, 33 | bits: None, 34 | emotes: "", 35 | timestamp: 2020-07-12T12:01:15.886Z, 36 | } 37 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_basic_example.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada :dank cam\")" 4 | --- 5 | Privmsg { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | msg_id: None, 9 | id: "e9d998c3-36f1-430f-89ec-6b887c28af36", 10 | sender: User { 11 | id: "29803735", 12 | login: "jun1orrrr", 13 | name: "JuN1oRRRR", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "dank cam", 18 | is_action: false, 19 | badges: [], 20 | color: Some( 21 | "#0000FF", 22 | ), 23 | custom_reward_id: None, 24 | bits: None, 25 | emotes: "", 26 | timestamp: 2020-07-12T09:12:35.039Z, 27 | } 28 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_custom_reward_id.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=subscriber/1;badges=broadcaster/1,subscriber/0;color=#8A2BE2;custom-reward-id=be22f712-8fd9-426a-90df-c13eae6cc6dc;display-name=vesdeg;emotes=;first-msg=0;flags=;id=79828352-d979-4e49-bd5e-15c487d275e2;mod=0;returning-chatter=0;room-id=164774298;subscriber=1;tmi-sent-ts=1709298826724;turbo=0;user-id=164774298;user-type= :vesdeg!vesdeg@vesdeg.tmi.twitch.tv PRIVMSG #vesdeg :#00FF00\")" 4 | --- 5 | Privmsg { 6 | channel: "#vesdeg", 7 | channel_id: "164774298", 8 | msg_id: None, 9 | id: "79828352-d979-4e49-bd5e-15c487d275e2", 10 | sender: User { 11 | id: "164774298", 12 | login: "vesdeg", 13 | name: "vesdeg", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "#00FF00", 18 | is_action: false, 19 | badges: [ 20 | Broadcaster, 21 | Subscriber( 22 | Subscriber { 23 | version: "0", 24 | months: "1", 25 | months_n: 1, 26 | }, 27 | ), 28 | ], 29 | color: Some( 30 | "#8A2BE2", 31 | ), 32 | custom_reward_id: Some( 33 | "be22f712-8fd9-426a-90df-c13eae6cc6dc", 34 | ), 35 | bits: None, 36 | emotes: "", 37 | timestamp: 2024-03-01T13:13:46.724Z, 38 | } 39 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_display_name_with_middle_space.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=Riot\\\\sGames;emotes=;flags=;id=bdfa278e-11c4-484f-9491-0a61b16fab60;mod=1;room-id=36029255;subscriber=0;tmi-sent-ts=1593953876927;turbo=0;user-id=36029255;user-type= :riotgames!riotgames@riotgames.tmi.twitch.tv PRIVMSG #riotgames :test fake message\")" 4 | --- 5 | Privmsg { 6 | channel: "#riotgames", 7 | channel_id: "36029255", 8 | msg_id: None, 9 | id: "bdfa278e-11c4-484f-9491-0a61b16fab60", 10 | sender: User { 11 | id: "36029255", 12 | login: "riotgames", 13 | name: "Riot\\sGames", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "test fake message", 18 | is_action: false, 19 | badges: [], 20 | color: None, 21 | custom_reward_id: None, 22 | bits: None, 23 | emotes: "", 24 | timestamp: 2020-07-05T12:57:56.927Z, 25 | } 26 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_display_name_with_trailing_space.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@rm-received-ts=1594554085918;historical=1;badge-info=;badges=;client-nonce=815810609edecdf4537bd9586994182b;color=;display-name=CarvedTaleare\\\\s;emotes=;flags=;id=c9b941d9-a0ab-4534-9903-971768fcdf10;mod=0;room-id=22484632;subscriber=0;tmi-sent-ts=1594554085753;turbo=0;user-id=467684514;user-type= :carvedtaleare!carvedtaleare@carvedtaleare.tmi.twitch.tv PRIVMSG #forsen :NaM\")" 4 | --- 5 | Privmsg { 6 | channel: "#forsen", 7 | channel_id: "22484632", 8 | msg_id: None, 9 | id: "c9b941d9-a0ab-4534-9903-971768fcdf10", 10 | sender: User { 11 | id: "467684514", 12 | login: "carvedtaleare", 13 | name: "CarvedTaleare\\s", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "NaM", 18 | is_action: false, 19 | badges: [], 20 | color: None, 21 | custom_reward_id: None, 22 | bits: None, 23 | emotes: "", 24 | timestamp: 2020-07-12T11:41:25.753Z, 25 | } 26 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_emote_non_numeric_id.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=;client-nonce=245b864d508a69a685e25104204bd31b;color=#FF144A;display-name=AvianArtworks;emote-only=1;emotes=300196486_TK:0-7;flags=;id=21194e0d-f0fa-4a8f-a14f-3cbe89366ad9;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594552113129;turbo=0;user-id=39565465;user-type= :avianartworks!avianartworks@avianartworks.tmi.twitch.tv PRIVMSG #pajlada :pajaM_TK\")" 4 | --- 5 | Privmsg { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | msg_id: None, 9 | id: "21194e0d-f0fa-4a8f-a14f-3cbe89366ad9", 10 | sender: User { 11 | id: "39565465", 12 | login: "avianartworks", 13 | name: "AvianArtworks", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "pajaM_TK", 18 | is_action: false, 19 | badges: [], 20 | color: Some( 21 | "#FF144A", 22 | ), 23 | custom_reward_id: None, 24 | bits: None, 25 | emotes: "300196486_TK:0-7", 26 | timestamp: 2020-07-12T11:08:33.129Z, 27 | } 28 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_emotes_1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=moderator/1;client-nonce=fc4ebe0889105c8404a9be81cf9a9ad4;color=#FF0000;display-name=boring_nick;emotes=555555591:51-52/25:0-4,12-16,18-22/1902:6-10,29-33,35-39/1:45-46,48-49;first-msg=0;flags=;id=3d9540a0-04b6-4bea-baf9-9165b14160be;mod=1;returning-chatter=0;room-id=55203741;subscriber=0;tmi-sent-ts=1696093084212;turbo=0;user-id=111024753;user-type=mod :boring_nick!boring_nick@boring_nick.tmi.twitch.tv PRIVMSG #moscowwbish :Kappa Keepo Kappa Kappa test Keepo Keepo 123 :) :) :P\")" 4 | --- 5 | Privmsg { 6 | channel: "#moscowwbish", 7 | channel_id: "55203741", 8 | msg_id: None, 9 | id: "3d9540a0-04b6-4bea-baf9-9165b14160be", 10 | sender: User { 11 | id: "111024753", 12 | login: "boring_nick", 13 | name: "boring_nick", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "Kappa Keepo Kappa Kappa test Keepo Keepo 123 :) :) :P", 18 | is_action: false, 19 | badges: [ 20 | Moderator, 21 | ], 22 | color: Some( 23 | "#FF0000", 24 | ), 25 | custom_reward_id: None, 26 | bits: None, 27 | emotes: "555555591:51-52/25:0-4,12-16,18-22/1902:6-10,29-33,35-39/1:45-46,48-49", 28 | timestamp: 2023-09-30T16:58:04.212Z, 29 | } 30 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_korean_display_name.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=subscriber/35;badges=moderator/1,subscriber/3024;color=#FF0000;display-name=테스트계정420;emotes=;flags=;id=bdfa278e-11c4-484f-9491-0a61b16fab60;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1593953876927;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :@asd\")" 4 | --- 5 | Privmsg { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | msg_id: None, 9 | id: "bdfa278e-11c4-484f-9491-0a61b16fab60", 10 | sender: User { 11 | id: "117166826", 12 | login: "testaccount_420", 13 | name: "테스트계정420", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "@asd", 18 | is_action: false, 19 | badges: [ 20 | Moderator, 21 | Subscriber( 22 | Subscriber { 23 | version: "3024", 24 | months: "35", 25 | months_n: 35, 26 | }, 27 | ), 28 | ], 29 | color: Some( 30 | "#FF0000", 31 | ), 32 | custom_reward_id: None, 33 | bits: None, 34 | emotes: "", 35 | timestamp: 2020-07-05T12:57:56.927Z, 36 | } 37 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_message_with_bits.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=bits/100;bits=1;color=#004B49;display-name=TETYYS;emotes=;flags=;id=d7f03a35-f339-41ca-b4d4-7c0721438570;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594571566672;turbo=0;user-id=36175310;user-type= :tetyys!tetyys@tetyys.tmi.twitch.tv PRIVMSG #pajlada :trihard1\")" 4 | --- 5 | Privmsg { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | msg_id: None, 9 | id: "d7f03a35-f339-41ca-b4d4-7c0721438570", 10 | sender: User { 11 | id: "36175310", 12 | login: "tetyys", 13 | name: "TETYYS", 14 | }, 15 | reply_to: None, 16 | pinned_chat: None, 17 | text: "trihard1", 18 | is_action: false, 19 | badges: [ 20 | Other( 21 | BadgeData { 22 | name: "bits", 23 | version: "100", 24 | extra: None, 25 | }, 26 | ), 27 | ], 28 | color: Some( 29 | "#004B49", 30 | ), 31 | custom_reward_id: None, 32 | bits: Some( 33 | 1, 34 | ), 35 | emotes: "", 36 | timestamp: 2020-07-12T16:32:46.672Z, 37 | } 38 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_pinned_chat.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=glhf-pledge/1;color=;display-name=pajlada;emotes=;first-msg=0;flags=;id=f6fb34f8-562f-4b4d-b628-32113d0ef4b0;mod=0;pinned-chat-paid-amount=200;pinned-chat-paid-canonical-amount=200;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;pinned-chat-paid-is-system-message=0;pinned-chat-paid-level=ONE;returning-chatter=0;room-id=12345678;subscriber=0;tmi-sent-ts=1687471984306;turbo=0;user-id=12345678;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #channel :This is a pinned message\")" 4 | --- 5 | Privmsg { 6 | channel: "#channel", 7 | channel_id: "12345678", 8 | msg_id: None, 9 | id: "f6fb34f8-562f-4b4d-b628-32113d0ef4b0", 10 | sender: User { 11 | id: "12345678", 12 | login: "pajlada", 13 | name: "pajlada", 14 | }, 15 | reply_to: None, 16 | pinned_chat: Some( 17 | PinnedChat { 18 | paid_amount: 200, 19 | paid_currency: "USD", 20 | paid_exponent: 2, 21 | paid_level: ONE, 22 | is_system_message: false, 23 | }, 24 | ), 25 | text: "This is a pinned message", 26 | is_action: false, 27 | badges: [ 28 | Other( 29 | BadgeData { 30 | name: "glhf-pledge", 31 | version: "1", 32 | extra: None, 33 | }, 34 | ), 35 | ], 36 | color: None, 37 | custom_reward_id: None, 38 | bits: None, 39 | emotes: "", 40 | timestamp: 2023-06-22T22:13:04.306Z, 41 | } 42 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__privmsg__tests__parse_privmsg_reply_parent_included.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/privmsg.rs 3 | expression: "f(\"@badge-info=;badges=;client-nonce=cd56193132f934ac71b4d5ac488d4bd6;color=;display-name=LeftSwing;emotes=;first-msg=0;flags=;id=5b4f63a9-776f-4fce-bf3c-d9707f52e32d;mod=0;reply-parent-display-name=Retoon;reply-parent-msg-body=hello;reply-parent-msg-id=6b13e51b-7ecb-43b5-ba5b-2bb5288df696;reply-parent-user-id=37940952;reply-parent-user-login=retoon;reply-thread-parent-msg-id=6b13e51b-7ecb-43b5-ba5b-2bb5288df696;reply-thread-parent-user-login=retoon;returning-chatter=0;room-id=37940952;subscriber=0;tmi-sent-ts=1673925983585;turbo=0;user-id=133651738;user-type= :leftswing!leftswing@leftswing.tmi.twitch.tv PRIVMSG #retoon :@Retoon yes\")" 4 | --- 5 | Privmsg { 6 | channel: "#retoon", 7 | channel_id: "37940952", 8 | msg_id: None, 9 | id: "5b4f63a9-776f-4fce-bf3c-d9707f52e32d", 10 | sender: User { 11 | id: "133651738", 12 | login: "leftswing", 13 | name: "LeftSwing", 14 | }, 15 | reply_to: Some( 16 | Reply { 17 | thread_parent_message_id: "6b13e51b-7ecb-43b5-ba5b-2bb5288df696", 18 | thread_parent_user_login: "retoon", 19 | message_id: "6b13e51b-7ecb-43b5-ba5b-2bb5288df696", 20 | sender: User { 21 | id: "37940952", 22 | login: "retoon", 23 | name: "Retoon", 24 | }, 25 | text: "hello", 26 | }, 27 | ), 28 | pinned_chat: None, 29 | text: "@Retoon yes", 30 | is_action: false, 31 | badges: [], 32 | color: None, 33 | custom_reward_id: None, 34 | bits: None, 35 | emotes: "", 36 | timestamp: 2023-01-17T03:26:23.585Z, 37 | } 38 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__room_state__tests__parse_room_state_basic_full.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/room_state.rs 3 | expression: "f(\"@emote-only=0;followers-only=-1;r9k=0;rituals=0;room-id=40286300;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #randers\")" 4 | --- 5 | RoomState { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | emote_only: Some( 9 | false, 10 | ), 11 | followers_only: Some( 12 | Disabled, 13 | ), 14 | r9k: Some( 15 | false, 16 | ), 17 | slow: Some( 18 | 0ns, 19 | ), 20 | subs_only: Some( 21 | false, 22 | ), 23 | } 24 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__room_state__tests__parse_room_state_basic_full2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/room_state.rs 3 | expression: "f(\"@emote-only=1;followers-only=0;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers\")" 4 | --- 5 | RoomState { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | emote_only: Some( 9 | true, 10 | ), 11 | followers_only: Some( 12 | Enabled( 13 | None, 14 | ), 15 | ), 16 | r9k: Some( 17 | true, 18 | ), 19 | slow: Some( 20 | 5s, 21 | ), 22 | subs_only: Some( 23 | true, 24 | ), 25 | } 26 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__room_state__tests__parse_room_state_followers_non_zero.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/room_state.rs 3 | expression: "f(\"@emote-only=1;followers-only=10;r9k=1;rituals=0;room-id=40286300;slow=5;subs-only=1 :tmi.twitch.tv ROOMSTATE #randers\")" 4 | --- 5 | RoomState { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | emote_only: Some( 9 | true, 10 | ), 11 | followers_only: Some( 12 | Enabled( 13 | Some( 14 | 600s, 15 | ), 16 | ), 17 | ), 18 | r9k: Some( 19 | true, 20 | ), 21 | slow: Some( 22 | 5s, 23 | ), 24 | subs_only: Some( 25 | true, 26 | ), 27 | } 28 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__room_state__tests__parse_room_state_partial_1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/room_state.rs 3 | expression: "f(\"@room-id=40286300;slow=5 :tmi.twitch.tv ROOMSTATE #randers\")" 4 | --- 5 | RoomState { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | emote_only: None, 9 | followers_only: None, 10 | r9k: None, 11 | slow: Some( 12 | 5s, 13 | ), 14 | subs_only: None, 15 | } 16 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__room_state__tests__parse_room_state_partial_2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/room_state.rs 3 | expression: "f(\"@emote-only=1;room-id=40286300 :tmi.twitch.tv ROOMSTATE #randers\")" 4 | --- 5 | RoomState { 6 | channel: "#randers", 7 | channel_id: "40286300", 8 | emote_only: Some( 9 | true, 10 | ), 11 | followers_only: None, 12 | r9k: None, 13 | slow: None, 14 | subs_only: None, 15 | } 16 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_anongiftpaidupgrade_with_promo.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=subscriber/1;badges=subscriber/0,premium/1;color=#8A2BE2;display-name=samura1jack_ttv;emotes=;flags=;id=144ee636-0c1d-404e-8b29-35449a045a7e;msg-param-promo-name=TestSubtember2020;msg-param-promo-gift-total=4003;login=samura1jack_ttv;mod=0;msg-id=anongiftpaidupgrade;room-id=71092938;subscriber=1;system-msg=samura1jack_ttv\\\\sis\\\\scontinuing\\\\sthe\\\\sGift\\\\sSub\\\\sthey\\\\sgot\\\\sfrom\\\\san\\\\sanonymous\\\\suser!\\\\sbla\\\\sbla\\\\sbla\\\\sstuff\\\\sabout\\\\spromo\\\\shere;tmi-sent-ts=1594327421732;user-id=102707709;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "samura1jack_ttv\\sis\\scontinuing\\sthe\\sGift\\sSub\\sthey\\sgot\\sfrom\\san\\sanonymous\\suser!\\sbla\\sbla\\sbla\\sstuff\\sabout\\spromo\\shere", 12 | ), 13 | event: AnonGiftPaidUpgrade( 14 | AnonGiftPaidUpgrade { 15 | promotion: Some( 16 | SubGiftPromo { 17 | total_gifts: 4003, 18 | promo_name: "TestSubtember2020", 19 | }, 20 | ), 21 | }, 22 | ), 23 | event_id: "anongiftpaidupgrade", 24 | badges: [ 25 | Subscriber( 26 | Subscriber { 27 | version: "0", 28 | months: "1", 29 | months_n: 1, 30 | }, 31 | ), 32 | Other( 33 | BadgeData { 34 | name: "premium", 35 | version: "1", 36 | extra: None, 37 | }, 38 | ), 39 | ], 40 | emotes: "", 41 | color: Some( 42 | "#8A2BE2", 43 | ), 44 | message_id: "144ee636-0c1d-404e-8b29-35449a045a7e", 45 | timestamp: 2020-07-09T20:43:41.732Z, 46 | } 47 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_anonsubgift.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=xQcOW;emotes=;flags=;id=e21409b1-d25d-4a1a-b5cf-ef27d8b7030e;login=xqcow;mod=0;msg-id=anonsubgift;msg-param-gift-months=1;msg-param-months=2;msg-param-origin-id=da\\\\s39\\\\sa3\\\\see\\\\s5e\\\\s6b\\\\s4b\\\\s0d\\\\s32\\\\s55\\\\sbf\\\\sef\\\\s95\\\\s60\\\\s18\\\\s90\\\\saf\\\\sd8\\\\s07\\\\s09;msg-param-recipient-display-name=qatarking24xd;msg-param-recipient-id=236653628;msg-param-recipient-user-name=qatarking24xd;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=1000;room-id=71092938;subscriber=0;system-msg=An\\\\sanonymous\\\\sgifter\\\\sgifted\\\\sa\\\\sTier\\\\s1\\\\ssub\\\\sto\\\\sqatarking24xd!;tmi-sent-ts=1594583782376;user-id=71092938;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "An\\sanonymous\\sgifter\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sqatarking24xd!", 12 | ), 13 | event: SubGift( 14 | SubGift { 15 | cumulative_months: 2, 16 | recipient: User { 17 | id: "236653628", 18 | login: "qatarking24xd", 19 | name: "qatarking24xd", 20 | }, 21 | sub_plan: "1000", 22 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 23 | num_gifted_months: 1, 24 | }, 25 | ), 26 | event_id: "anonsubgift", 27 | badges: [], 28 | emotes: "", 29 | color: None, 30 | message_id: "e21409b1-d25d-4a1a-b5cf-ef27d8b7030e", 31 | timestamp: 2020-07-12T19:56:22.376Z, 32 | } 33 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_anonsubmysterygift.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=subscriber/2;badges=subscriber/2;color=#00FFF5;display-name=CrazyCrackAnimal;emotes=;flags=;id=7006f242-a45c-4e07-83b3-11f9c6d1ee28;login=crazycrackanimal;mod=0;msg-id=giftpaidupgrade;msg-param-sender-login=stridezgum;msg-param-sender-name=Stridezgum;room-id=71092938;subscriber=1;system-msg=CrazyCrackAnimal\\\\sis\\\\scontinuing\\\\sthe\\\\sGift\\\\sSub\\\\sthey\\\\sgot\\\\sfrom\\\\sStridezgum!;tmi-sent-ts=1594518849459;user-id=86082877;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "86082877", 11 | login: "crazycrackanimal", 12 | name: "CrazyCrackAnimal", 13 | }, 14 | ), 15 | text: None, 16 | system_message: Some( 17 | "CrazyCrackAnimal\\sis\\scontinuing\\sthe\\sGift\\sSub\\sthey\\sgot\\sfrom\\sStridezgum!", 18 | ), 19 | event: GiftPaidUpgrade( 20 | GiftPaidUpgrade { 21 | gifter_login: "stridezgum", 22 | gifter_name: "Stridezgum", 23 | promotion: None, 24 | }, 25 | ), 26 | event_id: "giftpaidupgrade", 27 | badges: [ 28 | Subscriber( 29 | Subscriber { 30 | version: "2", 31 | months: "2", 32 | months_n: 2, 33 | }, 34 | ), 35 | ], 36 | emotes: "", 37 | color: Some( 38 | "#00FFF5", 39 | ), 40 | message_id: "7006f242-a45c-4e07-83b3-11f9c6d1ee28", 41 | timestamp: 2020-07-12T01:54:09.459Z, 42 | } 43 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_bitsbadgetier.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=sub-gifter/50;color=;display-name=AdamAtReflectStudios;emotes=;flags=;id=7f1336e4-f84a-4510-809d-e57bf50af0cc;login=adamatreflectstudios;mod=0;msg-id=rewardgift;msg-param-domain=pride_megacommerce_2020;msg-param-selected-count=100;msg-param-total-reward-count=100;msg-param-trigger-amount=20;msg-param-trigger-type=SUBGIFT;room-id=71092938;subscriber=0;system-msg=AdamAtReflectStudios's\\\\sGift\\\\sshared\\\\srewards\\\\sto\\\\s100\\\\sothers\\\\sin\\\\sChat!;tmi-sent-ts=1594583778756;user-id=211711554;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "AdamAtReflectStudios's\\sGift\\sshared\\srewards\\sto\\s100\\sothers\\sin\\sChat!", 12 | ), 13 | event: Unknown, 14 | event_id: "rewardgift", 15 | badges: [ 16 | Other( 17 | BadgeData { 18 | name: "sub-gifter", 19 | version: "50", 20 | extra: None, 21 | }, 22 | ), 23 | ], 24 | emotes: "", 25 | color: None, 26 | message_id: "7f1336e4-f84a-4510-809d-e57bf50af0cc", 27 | timestamp: 2020-07-12T19:56:18.756Z, 28 | } 29 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_giftpaidupgrade_with_promo.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=subscriber/1;badges=subscriber/0,premium/1;color=#8A2BE2;display-name=samura1jack_ttv;emotes=;flags=;id=144ee636-0c1d-404e-8b29-35449a045a7e;login=samura1jack_ttv;mod=0;msg-id=anongiftpaidupgrade;room-id=71092938;subscriber=1;system-msg=samura1jack_ttv\\\\sis\\\\scontinuing\\\\sthe\\\\sGift\\\\sSub\\\\sthey\\\\sgot\\\\sfrom\\\\san\\\\sanonymous\\\\suser!;tmi-sent-ts=1594327421732;user-id=102707709;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "samura1jack_ttv\\sis\\scontinuing\\sthe\\sGift\\sSub\\sthey\\sgot\\sfrom\\san\\sanonymous\\suser!", 12 | ), 13 | event: AnonGiftPaidUpgrade( 14 | AnonGiftPaidUpgrade { 15 | promotion: None, 16 | }, 17 | ), 18 | event_id: "anongiftpaidupgrade", 19 | badges: [ 20 | Subscriber( 21 | Subscriber { 22 | version: "0", 23 | months: "1", 24 | months_n: 1, 25 | }, 26 | ), 27 | Other( 28 | BadgeData { 29 | name: "premium", 30 | version: "1", 31 | extra: None, 32 | }, 33 | ), 34 | ], 35 | emotes: "", 36 | color: Some( 37 | "#8A2BE2", 38 | ), 39 | message_id: "144ee636-0c1d-404e-8b29-35449a045a7e", 40 | timestamp: 2020-07-09T20:43:41.732Z, 41 | } 42 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_raid.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=sub-gifter/50;color=;display-name=AdamAtReflectStudios;emotes=;flags=;id=e21409b1-d25d-4a1a-b5cf-ef27d8b7030e;login=adamatreflectstudios;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=2;msg-param-origin-id=da\\\\s39\\\\sa3\\\\see\\\\s5e\\\\s6b\\\\s4b\\\\s0d\\\\s32\\\\s55\\\\sbf\\\\sef\\\\s95\\\\s60\\\\s18\\\\s90\\\\saf\\\\sd8\\\\s07\\\\s09;msg-param-recipient-display-name=qatarking24xd;msg-param-recipient-id=236653628;msg-param-recipient-user-name=qatarking24xd;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=1000;room-id=71092938;subscriber=0;system-msg=AdamAtReflectStudios\\\\sgifted\\\\sa\\\\sTier\\\\s1\\\\ssub\\\\sto\\\\sqatarking24xd!;tmi-sent-ts=1594583782376;user-id=211711554;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "211711554", 11 | login: "adamatreflectstudios", 12 | name: "AdamAtReflectStudios", 13 | }, 14 | ), 15 | text: None, 16 | system_message: Some( 17 | "AdamAtReflectStudios\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sqatarking24xd!", 18 | ), 19 | event: SubGift( 20 | SubGift { 21 | cumulative_months: 2, 22 | recipient: User { 23 | id: "236653628", 24 | login: "qatarking24xd", 25 | name: "qatarking24xd", 26 | }, 27 | sub_plan: "1000", 28 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 29 | num_gifted_months: 1, 30 | }, 31 | ), 32 | event_id: "subgift", 33 | badges: [ 34 | Other( 35 | BadgeData { 36 | name: "sub-gifter", 37 | version: "50", 38 | extra: None, 39 | }, 40 | ), 41 | ], 42 | emotes: "", 43 | color: None, 44 | message_id: "e21409b1-d25d-4a1a-b5cf-ef27d8b7030e", 45 | timestamp: 2020-07-12T19:56:22.376Z, 46 | } 47 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_resub.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=subscriber/2;badges=subscriber/0,battlerite_1/1;color=#0000FF;display-name=Gutrin;emotes=1035663:0-3;flags=;id=e0975c76-054c-4954-8cb0-91b8867ec1ca;login=gutrin;mod=0;msg-id=resub;msg-param-cumulative-months=2;msg-param-months=0;msg-param-should-share-streak=1;msg-param-streak-months=2;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=1000;room-id=71092938;subscriber=1;system-msg=Gutrin\\\\ssubscribed\\\\sat\\\\sTier\\\\s1.\\\\sThey've\\\\ssubscribed\\\\sfor\\\\s2\\\\smonths,\\\\scurrently\\\\son\\\\sa\\\\s2\\\\smonth\\\\sstreak!;tmi-sent-ts=1581713640019;user-id=21156217;user-type= :tmi.twitch.tv USERNOTICE #xqcow :xqcL\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "21156217", 11 | login: "gutrin", 12 | name: "Gutrin", 13 | }, 14 | ), 15 | text: Some( 16 | "xqcL", 17 | ), 18 | system_message: Some( 19 | "Gutrin\\ssubscribed\\sat\\sTier\\s1.\\sThey've\\ssubscribed\\sfor\\s2\\smonths,\\scurrently\\son\\sa\\s2\\smonth\\sstreak!", 20 | ), 21 | event: SubOrResub( 22 | SubOrResub { 23 | is_resub: true, 24 | cumulative_months: 2, 25 | streak_months: Some( 26 | 2, 27 | ), 28 | sub_plan: "1000", 29 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 30 | }, 31 | ), 32 | event_id: "resub", 33 | badges: [ 34 | Subscriber( 35 | Subscriber { 36 | version: "0", 37 | months: "2", 38 | months_n: 2, 39 | }, 40 | ), 41 | Other( 42 | BadgeData { 43 | name: "battlerite_1", 44 | version: "1", 45 | extra: None, 46 | }, 47 | ), 48 | ], 49 | emotes: "1035663:0-3", 50 | color: Some( 51 | "#0000FF", 52 | ), 53 | message_id: "e0975c76-054c-4954-8cb0-91b8867ec1ca", 54 | timestamp: 2020-02-14T20:54:00.019Z, 55 | } 56 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_resub_no_share_streak.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=premium/1;color=#8A2BE2;display-name=rene_rs;emotes=;flags=;id=ca1f02fb-77ec-487d-a9b3-bc4bfef2fe8b;login=rene_rs;mod=0;msg-id=resub;msg-param-cumulative-months=11;msg-param-months=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=Prime;room-id=71092938;subscriber=0;system-msg=rene_rs\\\\ssubscribed\\\\swith\\\\sTwitch\\\\sPrime.\\\\sThey've\\\\ssubscribed\\\\sfor\\\\s11\\\\smonths!;tmi-sent-ts=1590628650446;user-id=171356987;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "171356987", 11 | login: "rene_rs", 12 | name: "rene_rs", 13 | }, 14 | ), 15 | text: None, 16 | system_message: Some( 17 | "rene_rs\\ssubscribed\\swith\\sTwitch\\sPrime.\\sThey've\\ssubscribed\\sfor\\s11\\smonths!", 18 | ), 19 | event: SubOrResub( 20 | SubOrResub { 21 | is_resub: true, 22 | cumulative_months: 11, 23 | streak_months: None, 24 | sub_plan: "Prime", 25 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 26 | }, 27 | ), 28 | event_id: "resub", 29 | badges: [ 30 | Other( 31 | BadgeData { 32 | name: "premium", 33 | version: "1", 34 | extra: None, 35 | }, 36 | ), 37 | ], 38 | emotes: "", 39 | color: Some( 40 | "#8A2BE2", 41 | ), 42 | message_id: "ca1f02fb-77ec-487d-a9b3-bc4bfef2fe8b", 43 | timestamp: 2020-05-28T01:17:30.446Z, 44 | } 45 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_ritual.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=SevenTest1;emotes=30259:0-6;id=37feed0f-b9c7-4c3a-b475-21c6c6d21c3d;login=seventest1;mod=0;msg-id=ritual;msg-param-ritual-name=new_chatter;room-id=6316121;subscriber=0;system-msg=Seventoes\\\\sis\\\\snew\\\\shere!;tmi-sent-ts=1508363903826;turbo=0;user-id=131260580;user-type= :tmi.twitch.tv USERNOTICE #seventoes :HeyGuys\")" 4 | --- 5 | UserNotice { 6 | channel: "#seventoes", 7 | channel_id: "6316121", 8 | sender: Some( 9 | User { 10 | id: "131260580", 11 | login: "seventest1", 12 | name: "SevenTest1", 13 | }, 14 | ), 15 | text: Some( 16 | "HeyGuys", 17 | ), 18 | system_message: Some( 19 | "Seventoes\\sis\\snew\\shere!", 20 | ), 21 | event: Ritual( 22 | Ritual { 23 | name: "new_chatter", 24 | }, 25 | ), 26 | event_id: "ritual", 27 | badges: [], 28 | emotes: "30259:0-6", 29 | color: None, 30 | message_id: "37feed0f-b9c7-4c3a-b475-21c6c6d21c3d", 31 | timestamp: 2017-10-18T21:58:23.826Z, 32 | } 33 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_sub.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=subscriber/0;badges=subscriber/0,premium/1;color=;display-name=fallenseraphhh;emotes=;flags=;id=2a9bea11-a80a-49a0-a498-1642d457f775;login=fallenseraphhh;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=Prime;room-id=71092938;subscriber=1;system-msg=fallenseraphhh\\\\ssubscribed\\\\swith\\\\sTwitch\\\\sPrime.;tmi-sent-ts=1582685713242;user-id=224005980;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "224005980", 11 | login: "fallenseraphhh", 12 | name: "fallenseraphhh", 13 | }, 14 | ), 15 | text: None, 16 | system_message: Some( 17 | "fallenseraphhh\\ssubscribed\\swith\\sTwitch\\sPrime.", 18 | ), 19 | event: SubOrResub( 20 | SubOrResub { 21 | is_resub: false, 22 | cumulative_months: 1, 23 | streak_months: None, 24 | sub_plan: "Prime", 25 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 26 | }, 27 | ), 28 | event_id: "sub", 29 | badges: [ 30 | Subscriber( 31 | Subscriber { 32 | version: "0", 33 | months: "0", 34 | months_n: 0, 35 | }, 36 | ), 37 | Other( 38 | BadgeData { 39 | name: "premium", 40 | version: "1", 41 | extra: None, 42 | }, 43 | ), 44 | ], 45 | emotes: "", 46 | color: None, 47 | message_id: "2a9bea11-a80a-49a0-a498-1642d457f775", 48 | timestamp: 2020-02-26T02:55:13.242Z, 49 | } 50 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_subgift_ananonymousgifter.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=AnAnonymousGifter;emotes=;flags=;id=62c3fd39-84cc-452a-9096-628a5306633a;login=ananonymousgifter;mod=0;msg-id=subgift;msg-param-fun-string=FunStringThree;msg-param-gift-months=1;msg-param-months=13;msg-param-origin-id=da\\\\s39\\\\sa3\\\\see\\\\s5e\\\\s6b\\\\s4b\\\\s0d\\\\s32\\\\s55\\\\sbf\\\\sef\\\\s95\\\\s60\\\\s18\\\\s90\\\\saf\\\\sd8\\\\s07\\\\s09;msg-param-recipient-display-name=Dot0422;msg-param-recipient-id=151784015;msg-param-recipient-user-name=dot0422;msg-param-sub-plan-name=Channel\\\\sSubscription\\\\s(xqcow);msg-param-sub-plan=1000;room-id=71092938;subscriber=0;system-msg=An\\\\sanonymous\\\\suser\\\\sgifted\\\\sa\\\\sTier\\\\s1\\\\ssub\\\\sto\\\\sDot0422!\\\\s;tmi-sent-ts=1594495108936;user-id=274598607;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "An\\sanonymous\\suser\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sDot0422!\\s", 12 | ), 13 | event: SubGift( 14 | SubGift { 15 | cumulative_months: 13, 16 | recipient: User { 17 | id: "151784015", 18 | login: "dot0422", 19 | name: "Dot0422", 20 | }, 21 | sub_plan: "1000", 22 | sub_plan_name: "Channel\\sSubscription\\s(xqcow)", 23 | num_gifted_months: 1, 24 | }, 25 | ), 26 | event_id: "subgift", 27 | badges: [], 28 | emotes: "", 29 | color: None, 30 | message_id: "62c3fd39-84cc-452a-9096-628a5306633a", 31 | timestamp: 2020-07-11T19:18:28.936Z, 32 | } 33 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_submysterygift.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=sub-gifter/50;color=;display-name=AdamAtReflectStudios;emotes=;flags=;id=049e6371-7023-4fca-8605-7dec60e72e12;login=adamatreflectstudios;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=20;msg-param-origin-id=1f\\\\sbe\\\\sbb\\\\s4a\\\\s81\\\\s9a\\\\s65\\\\sd1\\\\s4b\\\\s77\\\\sf5\\\\s23\\\\s16\\\\s4a\\\\sd3\\\\s13\\\\s09\\\\se7\\\\sbe\\\\s55;msg-param-sender-count=100;msg-param-sub-plan=1000;room-id=71092938;subscriber=0;system-msg=AdamAtReflectStudios\\\\sis\\\\sgifting\\\\s20\\\\sTier\\\\s1\\\\sSubs\\\\sto\\\\sxQcOW's\\\\scommunity!\\\\sThey've\\\\sgifted\\\\sa\\\\stotal\\\\sof\\\\s100\\\\sin\\\\sthe\\\\schannel!;tmi-sent-ts=1594583777669;user-id=211711554;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: Some( 9 | User { 10 | id: "211711554", 11 | login: "adamatreflectstudios", 12 | name: "AdamAtReflectStudios", 13 | }, 14 | ), 15 | text: None, 16 | system_message: Some( 17 | "AdamAtReflectStudios\\sis\\sgifting\\s20\\sTier\\s1\\sSubs\\sto\\sxQcOW's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s100\\sin\\sthe\\schannel!", 18 | ), 19 | event: SubMysteryGift( 20 | SubMysteryGift { 21 | count: 20, 22 | sender_total_gifts: 100, 23 | sub_plan: "1000", 24 | }, 25 | ), 26 | event_id: "submysterygift", 27 | badges: [ 28 | Other( 29 | BadgeData { 30 | name: "sub-gifter", 31 | version: "50", 32 | extra: None, 33 | }, 34 | ), 35 | ], 36 | emotes: "", 37 | color: None, 38 | message_id: "049e6371-7023-4fca-8605-7dec60e72e12", 39 | timestamp: 2020-07-12T19:56:17.669Z, 40 | } 41 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_submysterygift_ananonymousgifter.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=;color=;display-name=AnAnonymousGifter;emotes=;flags=;id=8db97752-3dee-460b-9001-e925d0e2ba5b;login=ananonymousgifter;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=10;msg-param-origin-id=13\\\\s33\\\\sed\\\\sc0\\\\sef\\\\sa0\\\\s7b\\\\s9b\\\\s48\\\\s59\\\\scb\\\\scc\\\\se4\\\\s39\\\\s7b\\\\s90\\\\sf9\\\\s54\\\\s75\\\\s66;msg-param-sub-plan=1000;room-id=71092938;subscriber=0;system-msg=An\\\\sanonymous\\\\suser\\\\sis\\\\sgifting\\\\s10\\\\sTier\\\\s1\\\\sSubs\\\\sto\\\\sxQcOW's\\\\scommunity!;tmi-sent-ts=1585447099603;user-id=274598607;user-type= :tmi.twitch.tv USERNOTICE #xqcow\")" 4 | --- 5 | UserNotice { 6 | channel: "#xqcow", 7 | channel_id: "71092938", 8 | sender: None, 9 | text: None, 10 | system_message: Some( 11 | "An\\sanonymous\\suser\\sis\\sgifting\\s10\\sTier\\s1\\sSubs\\sto\\sxQcOW's\\scommunity!", 12 | ), 13 | event: AnonSubMysteryGift( 14 | AnonSubMysteryGift { 15 | count: 10, 16 | sub_plan: "1000", 17 | }, 18 | ), 19 | event_id: "submysterygift", 20 | badges: [], 21 | emotes: "", 22 | color: None, 23 | message_id: "8db97752-3dee-460b-9001-e925d0e2ba5b", 24 | timestamp: 2020-03-29T01:58:19.603Z, 25 | } 26 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_user_notice_announcement.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@emotes=;login=pajbot;vip=0;tmi-sent-ts=1695554663565;flags=;mod=1;subscriber=1;id=bb1bec25-8f26-4ba3-a084-a6a2ca332f00;badge-info=subscriber/93;system-msg=;user-id=82008718;user-type=mod;room-id=11148817;badges=moderator/1,subscriber/3072;msg-param-color=PRIMARY;msg-id=announcement;color=#2E8B57;display-name=pajbot :tmi.twitch.tv USERNOTICE #pajlada :$ping xd\")" 4 | --- 5 | UserNotice { 6 | channel: "#pajlada", 7 | channel_id: "11148817", 8 | sender: Some( 9 | User { 10 | id: "82008718", 11 | login: "pajbot", 12 | name: "pajbot", 13 | }, 14 | ), 15 | text: Some( 16 | "$ping xd", 17 | ), 18 | system_message: None, 19 | event: Announcement( 20 | Announcement { 21 | highlight_color: "PRIMARY", 22 | }, 23 | ), 24 | event_id: "announcement", 25 | badges: [ 26 | Moderator, 27 | Subscriber( 28 | Subscriber { 29 | version: "3072", 30 | months: "93", 31 | months_n: 93, 32 | }, 33 | ), 34 | ], 35 | emotes: "", 36 | color: Some( 37 | "#2E8B57", 38 | ), 39 | message_id: "bb1bec25-8f26-4ba3-a084-a6a2ca332f00", 40 | timestamp: 2023-09-24T11:24:23.565Z, 41 | } 42 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_notice__tests__parse_user_notice_announcement_no_color.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_notice.rs 3 | expression: "f(\"@badge-info=;badges=moderator/1;color=#1E90FF;display-name=vetricbot;emotes=;flags=;id=8c0fea53-7827-4165-8848-8e512b59beed;login=vetricbot;mod=1;msg-id=announcement;room-id=404660262;subscriber=0;system-msg=;tmi-sent-ts=1738762344755;user-id=955666532;user-type=mod;vip=0 :tmi.twitch.tv USERNOTICE #creepycode :sample\")" 4 | --- 5 | UserNotice { 6 | channel: "#creepycode", 7 | channel_id: "404660262", 8 | sender: Some( 9 | User { 10 | id: "955666532", 11 | login: "vetricbot", 12 | name: "vetricbot", 13 | }, 14 | ), 15 | text: Some( 16 | "sample", 17 | ), 18 | system_message: None, 19 | event: Announcement( 20 | Announcement { 21 | highlight_color: "PRIMARY", 22 | }, 23 | ), 24 | event_id: "announcement", 25 | badges: [ 26 | Moderator, 27 | ], 28 | emotes: "", 29 | color: Some( 30 | "#1E90FF", 31 | ), 32 | message_id: "8c0fea53-7827-4165-8848-8e512b59beed", 33 | timestamp: 2025-02-05T13:32:24.755Z, 34 | } 35 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_state__tests__parse_userstate.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_state.rs 3 | expression: "f(\"@badge-info=;badges=;color=#FF0000;display-name=TESTUSER;emote-sets=0;mod=0;subscriber=0;user-type= :tmi.twitch.tv USERSTATE #randers\")" 4 | --- 5 | UserState { 6 | channel: "#randers", 7 | user_name: "TESTUSER", 8 | badges: [], 9 | emote_sets: [ 10 | "0", 11 | ], 12 | color: Some( 13 | "#FF0000", 14 | ), 15 | } 16 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__user_state__tests__parse_userstate_uuid_emote_set_id.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/user_state.rs 3 | expression: "f(\"@badge-info=;badges=moderator/1;color=#8A2BE2;display-name=TESTUSER;emote-sets=0,75c09c7b-332a-43ec-8be8-1d4571706155;mod=1;subscriber=0;user-type=mod :tmi.twitch.tv USERSTATE #randers\")" 4 | --- 5 | UserState { 6 | channel: "#randers", 7 | user_name: "TESTUSER", 8 | badges: [ 9 | Moderator, 10 | ], 11 | emote_sets: [ 12 | "0", 13 | "75c09c7b-332a-43ec-8be8-1d4571706155", 14 | ], 15 | color: Some( 16 | "#8A2BE2", 17 | ), 18 | } 19 | -------------------------------------------------------------------------------- /src/msg/snapshots/tmi__msg__whisper__tests__parse_whisper.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/msg/whisper.rs 3 | expression: "f(\"@badges=;color=#19E6E6;display-name=randers;emotes=25:22-26;message-id=1;thread-id=40286300_553170741;turbo=0;user-id=40286300;user-type= :randers!randers@randers.tmi.twitch.tv WHISPER randers811 :hello, this is a test Kappa\")" 4 | --- 5 | Whisper { 6 | recipient: "randers811", 7 | sender: User { 8 | id: "40286300", 9 | login: "randers", 10 | name: "randers", 11 | }, 12 | text: "hello, this is a test Kappa", 13 | badges: [], 14 | emotes: "25:22-26", 15 | color: Some( 16 | "#19E6E6", 17 | ), 18 | } 19 | -------------------------------------------------------------------------------- /src/msg/user_state.rs: -------------------------------------------------------------------------------- 1 | //! Sent upon joining a channel, or upon successfully sending a `PRIVMSG` message to a channel. 2 | //! 3 | //! This is like [`GlobalUserState`][crate::msg::global_user_state::GlobalUserState], but 4 | //! carries channel-specific information. 5 | //! 6 | //! For example, [`UserState::badges`] may be different from [`GlobalUserState::badges`][crate::msg::global_user_state::GlobalUserState::badges]. 7 | 8 | use super::{is_not_empty, maybe_clone, parse_badges, split_comma, Badge, MessageParseError}; 9 | use crate::irc::{Command, IrcMessageRef, Tag}; 10 | use std::borrow::Cow; 11 | 12 | /// Sent upon joining a channel, or upon successfully sending a `PRIVMSG` message to a channel. 13 | /// 14 | /// This is like [`GlobalUserState`][crate::msg::global_user_state::GlobalUserState], but 15 | /// carries channel-specific information. 16 | /// 17 | /// For example, [`UserState::badges`] may be different from [`GlobalUserState::badges`][crate::msg::global_user_state::GlobalUserState::badges]. 18 | #[derive(Clone, Debug, PartialEq, Eq)] 19 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 20 | pub struct UserState<'src> { 21 | #[cfg_attr(feature = "serde", serde(borrow))] 22 | channel: Cow<'src, str>, 23 | 24 | #[cfg_attr(feature = "serde", serde(borrow))] 25 | user_name: Cow<'src, str>, 26 | 27 | #[cfg_attr(feature = "serde", serde(borrow))] 28 | badges: Vec>, 29 | 30 | #[cfg_attr(feature = "serde", serde(borrow))] 31 | emote_sets: Vec>, 32 | 33 | #[cfg_attr(feature = "serde", serde(borrow))] 34 | color: Option>, 35 | } 36 | 37 | generate_getters! { 38 | <'src> for UserState<'src> as self { 39 | /// Name of the channel in which this state applies to. 40 | channel -> &str = self.channel.as_ref(), 41 | 42 | /// Display name of the user. 43 | user_name -> &str = self.user_name.as_ref(), 44 | 45 | /// Iterator over channel-specific badges. 46 | badges -> impl DoubleEndedIterator> + ExactSizeIterator 47 | = self.badges.iter(), 48 | 49 | /// Number of channel-specific badges. 50 | num_badges -> usize = self.badges.len(), 51 | 52 | /// Iterator over the emote sets which are available in this channel. 53 | emote_sets -> impl DoubleEndedIterator + ExactSizeIterator 54 | = self.emote_sets.iter().map(|v| v.as_ref()), 55 | 56 | /// Number of emote sets which are avaialble in this channel. 57 | num_emote_sets -> usize = self.emote_sets.len(), 58 | 59 | /// The user's selected name color. 60 | /// 61 | /// [`None`] means the user has not selected a color. 62 | /// To match the behavior of Twitch, users should be 63 | /// given a globally-consistent random color. 64 | color -> Option<&str> = self.color.as_deref(), 65 | } 66 | } 67 | 68 | impl<'src> UserState<'src> { 69 | fn parse(message: IrcMessageRef<'src>) -> Option { 70 | if message.command() != Command::UserState { 71 | return None; 72 | } 73 | 74 | Some(UserState { 75 | channel: message.channel()?.into(), 76 | user_name: message.tag(Tag::DisplayName)?.into(), 77 | badges: message 78 | .tag(Tag::Badges) 79 | .zip(message.tag(Tag::BadgeInfo)) 80 | .map(|(badges, badge_info)| parse_badges(badges, badge_info)) 81 | .unwrap_or_default(), 82 | emote_sets: message 83 | .tag(Tag::EmoteSets) 84 | .map(split_comma) 85 | .map(|i| i.map(Cow::Borrowed)) 86 | .map(Iterator::collect) 87 | .unwrap_or_default(), 88 | color: message 89 | .tag(Tag::Color) 90 | .filter(is_not_empty) 91 | .map(|v| v.into()), 92 | }) 93 | } 94 | 95 | /// Clone data to give the value a `'static` lifetime. 96 | pub fn into_owned(self) -> UserState<'static> { 97 | UserState { 98 | channel: maybe_clone(self.channel), 99 | user_name: maybe_clone(self.user_name), 100 | badges: self.badges.into_iter().map(Badge::into_owned).collect(), 101 | emote_sets: self.emote_sets.into_iter().map(maybe_clone).collect(), 102 | color: self.color.map(maybe_clone), 103 | } 104 | } 105 | } 106 | 107 | impl<'src> super::FromIrc<'src> for UserState<'src> { 108 | #[inline] 109 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 110 | Self::parse(message).ok_or(MessageParseError) 111 | } 112 | } 113 | 114 | impl<'src> From> for super::Message<'src> { 115 | fn from(msg: UserState<'src>) -> Self { 116 | super::Message::UserState(msg) 117 | } 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | use super::*; 123 | 124 | #[test] 125 | fn parse_userstate() { 126 | assert_irc_snapshot!(UserState, "@badge-info=;badges=;color=#FF0000;display-name=TESTUSER;emote-sets=0;mod=0;subscriber=0;user-type= :tmi.twitch.tv USERSTATE #randers"); 127 | } 128 | 129 | #[test] 130 | fn parse_userstate_uuid_emote_set_id() { 131 | assert_irc_snapshot!(UserState, "@badge-info=;badges=moderator/1;color=#8A2BE2;display-name=TESTUSER;emote-sets=0,75c09c7b-332a-43ec-8be8-1d4571706155;mod=1;subscriber=0;user-type=mod :tmi.twitch.tv USERSTATE #randers"); 132 | } 133 | 134 | #[cfg(feature = "serde")] 135 | #[test] 136 | fn roundtrip_userstate() { 137 | assert_irc_roundtrip!(UserState, "@badge-info=;badges=;color=#FF0000;display-name=TESTUSER;emote-sets=0;mod=0;subscriber=0;user-type= :tmi.twitch.tv USERSTATE #randers"); 138 | } 139 | 140 | #[cfg(feature = "serde")] 141 | #[test] 142 | fn roundtrip_userstate_uuid_emote_set_id() { 143 | assert_irc_roundtrip!(UserState, "@badge-info=;badges=moderator/1;color=#8A2BE2;display-name=TESTUSER;emote-sets=0,75c09c7b-332a-43ec-8be8-1d4571706155;mod=1;subscriber=0;user-type=mod :tmi.twitch.tv USERSTATE #randers"); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/msg/whisper.rs: -------------------------------------------------------------------------------- 1 | //! A direct message between users. 2 | 3 | use super::{is_not_empty, maybe_clone, parse_badges, Badge, MessageParseError, User}; 4 | use crate::irc::{Command, IrcMessageRef, Tag}; 5 | use std::borrow::Cow; 6 | 7 | /// A direct message between users. 8 | #[derive(Clone, Debug, PartialEq, Eq)] 9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 10 | pub struct Whisper<'src> { 11 | #[cfg_attr(feature = "serde", serde(borrow))] 12 | recipient: Cow<'src, str>, 13 | 14 | #[cfg_attr(feature = "serde", serde(borrow))] 15 | sender: User<'src>, 16 | 17 | #[cfg_attr(feature = "serde", serde(borrow))] 18 | text: Cow<'src, str>, 19 | 20 | #[cfg_attr(feature = "serde", serde(borrow))] 21 | badges: Vec>, 22 | 23 | #[cfg_attr(feature = "serde", serde(borrow))] 24 | emotes: Cow<'src, str>, 25 | 26 | #[cfg_attr(feature = "serde", serde(borrow))] 27 | color: Option>, 28 | } 29 | 30 | generate_getters! { 31 | <'src> for Whisper<'src> as self { 32 | /// Login of the recipient. 33 | recipient -> &str = self.recipient.as_ref(), 34 | 35 | /// Login of the sender. 36 | sender -> User<'src>, 37 | 38 | /// Text content of the message. 39 | text -> &str = self.text.as_ref(), 40 | 41 | /// Iterator over the badges visible in the whisper window. 42 | badges -> impl DoubleEndedIterator> + ExactSizeIterator 43 | = self.badges.iter(), 44 | 45 | /// Number of badges visible in the whisper window. 46 | num_badges -> usize = self.badges.len(), 47 | 48 | /// The emote raw emote ranges present in this message. 49 | /// 50 | /// ⚠ Note: This is _hopelessly broken_ and should **never be used for any purpose whatsoever**, 51 | /// You should instead parse the emotes yourself out of the message according to the available emote sets. 52 | /// If for some reason you need it, here you go. 53 | raw_emotes -> &str = self.emotes.as_ref(), 54 | 55 | /// The [sender][`Whisper::sender`]'s selected name color. 56 | /// 57 | /// [`None`] means the user has not selected a color. 58 | /// To match the behavior of Twitch, users should be 59 | /// given a globally-consistent random color. 60 | color -> Option<&str> = self.color.as_deref(), 61 | } 62 | } 63 | 64 | impl<'src> Whisper<'src> { 65 | fn parse(message: IrcMessageRef<'src>) -> Option { 66 | if message.command() != Command::Whisper { 67 | return None; 68 | } 69 | 70 | let (recipient, text) = message.params()?.split_once(" :")?; 71 | 72 | Some(Whisper { 73 | recipient: recipient.into(), 74 | sender: User { 75 | id: message.tag(Tag::UserId)?.into(), 76 | login: message.prefix().and_then(|prefix| prefix.nick)?.into(), 77 | name: message.tag(Tag::DisplayName)?.into(), 78 | }, 79 | text: text.into(), 80 | color: message 81 | .tag(Tag::Color) 82 | .filter(is_not_empty) 83 | .map(|v| v.into()), 84 | badges: message 85 | .tag(Tag::Badges) 86 | .zip(message.tag(Tag::BadgeInfo)) 87 | .map(|(badges, badge_info)| parse_badges(badges, badge_info)) 88 | .unwrap_or_default(), 89 | emotes: message.tag(Tag::Emotes).unwrap_or_default().into(), 90 | }) 91 | } 92 | 93 | /// Convert this to a `'static` lifetime 94 | pub fn into_owned(self) -> Whisper<'static> { 95 | Whisper { 96 | recipient: maybe_clone(self.recipient), 97 | sender: self.sender.into_owned(), 98 | text: maybe_clone(self.text), 99 | badges: self.badges.into_iter().map(Badge::into_owned).collect(), 100 | emotes: maybe_clone(self.emotes), 101 | color: self.color.map(maybe_clone), 102 | } 103 | } 104 | } 105 | 106 | impl<'src> super::FromIrc<'src> for Whisper<'src> { 107 | #[inline] 108 | fn from_irc(message: IrcMessageRef<'src>) -> Result { 109 | Self::parse(message).ok_or(MessageParseError) 110 | } 111 | } 112 | 113 | impl<'src> From> for super::Message<'src> { 114 | fn from(msg: Whisper<'src>) -> Self { 115 | super::Message::Whisper(msg) 116 | } 117 | } 118 | 119 | #[cfg(test)] 120 | mod tests { 121 | use super::*; 122 | 123 | #[test] 124 | fn parse_whisper() { 125 | assert_irc_snapshot!(Whisper, "@badges=;color=#19E6E6;display-name=randers;emotes=25:22-26;message-id=1;thread-id=40286300_553170741;turbo=0;user-id=40286300;user-type= :randers!randers@randers.tmi.twitch.tv WHISPER randers811 :hello, this is a test Kappa"); 126 | } 127 | 128 | #[cfg(feature = "serde")] 129 | #[test] 130 | fn roundtrip_whisper() { 131 | assert_irc_roundtrip!(Whisper, "@badges=;color=#19E6E6;display-name=randers;emotes=25:22-26;message-id=1;thread-id=40286300_553170741;turbo=0;user-id=40286300;user-type= :randers!randers@randers.tmi.twitch.tv WHISPER randers811 :hello, this is a test Kappa"); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /xtask/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | bench/ 3 | tmp/ 4 | 5 | perf.* 6 | *.snap.new 7 | -------------------------------------------------------------------------------- /xtask/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "anyhow" 22 | version = "1.0.75" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" 25 | dependencies = [ 26 | "backtrace", 27 | ] 28 | 29 | [[package]] 30 | name = "argp" 31 | version = "0.3.0" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "84c16c577a1a3b720a90eb2127bd0ae61530a71064d1a6babaaaa87f6174b9f1" 34 | dependencies = [ 35 | "argp_derive", 36 | ] 37 | 38 | [[package]] 39 | name = "argp_derive" 40 | version = "0.3.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "fe3763c8b5e0ef2f7d0df26daa671808cc75e2d81547f63ccca96bf045e41799" 43 | dependencies = [ 44 | "proc-macro2", 45 | "pulldown-cmark", 46 | "quote", 47 | "syn", 48 | ] 49 | 50 | [[package]] 51 | name = "backtrace" 52 | version = "0.3.69" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 55 | dependencies = [ 56 | "addr2line", 57 | "cc", 58 | "cfg-if", 59 | "libc", 60 | "miniz_oxide", 61 | "object", 62 | "rustc-demangle", 63 | ] 64 | 65 | [[package]] 66 | name = "bitflags" 67 | version = "1.3.2" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 70 | 71 | [[package]] 72 | name = "cc" 73 | version = "1.0.83" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 76 | dependencies = [ 77 | "libc", 78 | ] 79 | 80 | [[package]] 81 | name = "cfg-if" 82 | version = "1.0.0" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 85 | 86 | [[package]] 87 | name = "getopts" 88 | version = "0.2.21" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" 91 | dependencies = [ 92 | "unicode-width", 93 | ] 94 | 95 | [[package]] 96 | name = "gimli" 97 | version = "0.28.0" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" 100 | 101 | [[package]] 102 | name = "libc" 103 | version = "0.2.148" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" 106 | 107 | [[package]] 108 | name = "memchr" 109 | version = "2.6.3" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" 112 | 113 | [[package]] 114 | name = "miniz_oxide" 115 | version = "0.7.1" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 118 | dependencies = [ 119 | "adler", 120 | ] 121 | 122 | [[package]] 123 | name = "object" 124 | version = "0.32.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" 127 | dependencies = [ 128 | "memchr", 129 | ] 130 | 131 | [[package]] 132 | name = "proc-macro2" 133 | version = "1.0.67" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" 136 | dependencies = [ 137 | "unicode-ident", 138 | ] 139 | 140 | [[package]] 141 | name = "pulldown-cmark" 142 | version = "0.9.3" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "77a1a2f1f0a7ecff9c31abbe177637be0e97a0aef46cf8738ece09327985d998" 145 | dependencies = [ 146 | "bitflags", 147 | "getopts", 148 | "memchr", 149 | "unicase", 150 | ] 151 | 152 | [[package]] 153 | name = "quote" 154 | version = "1.0.33" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 157 | dependencies = [ 158 | "proc-macro2", 159 | ] 160 | 161 | [[package]] 162 | name = "rustc-demangle" 163 | version = "0.1.23" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 166 | 167 | [[package]] 168 | name = "syn" 169 | version = "1.0.109" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 172 | dependencies = [ 173 | "proc-macro2", 174 | "quote", 175 | "unicode-ident", 176 | ] 177 | 178 | [[package]] 179 | name = "unicase" 180 | version = "2.7.0" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" 183 | dependencies = [ 184 | "version_check", 185 | ] 186 | 187 | [[package]] 188 | name = "unicode-ident" 189 | version = "1.0.12" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 192 | 193 | [[package]] 194 | name = "unicode-width" 195 | version = "0.1.11" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" 198 | 199 | [[package]] 200 | name = "version_check" 201 | version = "0.9.4" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 204 | 205 | [[package]] 206 | name = "xtask" 207 | version = "0.1.0" 208 | dependencies = [ 209 | "anyhow", 210 | "argp", 211 | ] 212 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "0.1.0" 4 | edition = "2021" 5 | repository = "https://github.com/jprochazk/twitch-rs" 6 | license = "MIT OR Apache-2.0" 7 | description = "xtask for twitch-rs" 8 | readme = "../README.md" 9 | publish = false 10 | 11 | [dependencies] 12 | anyhow = { version = "1.0.72", features = ["backtrace"] } 13 | argp = "0.3" 14 | 15 | [workspace] 16 | -------------------------------------------------------------------------------- /xtask/src/main.rs: -------------------------------------------------------------------------------- 1 | mod task; 2 | mod util; 3 | 4 | use self::task::Task; 5 | use argp::FromArgs; 6 | use std::io::{stderr, Write}; 7 | use std::process::ExitCode; 8 | 9 | pub type Result = anyhow::Result; 10 | 11 | #[derive(FromArgs)] 12 | #[argp(description = "Common tasks")] 13 | pub struct Cli { 14 | #[argp(subcommand)] 15 | pub task: Task, 16 | } 17 | 18 | fn try_main() -> Result { 19 | let args: Cli = argp::parse_args_or_exit(argp::DEFAULT); 20 | args.task.run() 21 | } 22 | 23 | fn main() -> ExitCode { 24 | match try_main() { 25 | Ok(()) => ExitCode::SUCCESS, 26 | Err(e) => { 27 | let _ = write!(stderr(), "{e}"); 28 | ExitCode::FAILURE 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /xtask/src/task.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use argp::FromArgs; 3 | 4 | mod changelog; 5 | mod setup; 6 | mod test; 7 | 8 | #[derive(FromArgs)] 9 | #[argp(subcommand)] 10 | pub enum Task { 11 | Setup(setup::Setup), 12 | Test(test::Test), 13 | Changelog(changelog::Changelog), 14 | } 15 | 16 | impl Task { 17 | pub fn run(self) -> Result { 18 | use Task as T; 19 | match self { 20 | T::Setup(task) => task.run(), 21 | T::Test(task) => task.run(), 22 | T::Changelog(task) => task.run(), 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /xtask/src/task/changelog.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{git, CommandExt}; 2 | use crate::Result; 3 | use argp::FromArgs; 4 | 5 | #[derive(FromArgs)] 6 | #[argp(subcommand, name = "changelog")] 7 | /// Generate a changelog 8 | pub struct Changelog { 9 | #[argp(option, description = "The tag to start from (inclusive)")] 10 | since: String, 11 | 12 | #[argp(option, description = "The tag to end at (inclusive)")] 13 | until: Option, 14 | } 15 | 16 | impl Changelog { 17 | pub fn run(self) -> Result { 18 | // git log 0.6.1..HEAD --pretty=format:"%h" 19 | let git_log = git("log") 20 | .with_args([ 21 | &format!( 22 | "{}..{}", 23 | self.since, 24 | self.until.as_deref().unwrap_or("HEAD") 25 | ), 26 | "--pretty=format:%h", 27 | ]) 28 | .run_with_output()?; 29 | let commits: Vec<&str> = git_log.trim().split('\n').collect(); 30 | 31 | let mut lines = vec![]; 32 | for commit in commits { 33 | let message = git("show") 34 | .with_args(["--quiet", "--pretty=format:%B", commit]) 35 | .run_with_output()?; 36 | let (first_line, remainder) = message.split_once('\n').unwrap_or((&message, "")); 37 | let url = format!("https://github.com/jprochazk/tmi-rs/commit/{}", commit); 38 | lines.push(format!("{} [{}]({})", first_line, commit, url,)); 39 | lines.push(remainder.to_string()); 40 | } 41 | 42 | let latest_commit = git("log") 43 | .with_args(["-1", "--pretty=format:%h"]) 44 | .run_with_output()?; 45 | let gh_commit_range = format!( 46 | "[{0}..{1}](https://github.com/jprochazk/tmi-rs/compare/{0}...{1})", 47 | self.since, latest_commit 48 | ); 49 | 50 | let existing_changelog = std::fs::read_to_string("CHANGELOG.md").unwrap(); 51 | 52 | std::fs::write( 53 | "CHANGELOG.md", 54 | format!( 55 | "{}\n\n{}\n\n{existing_changelog}", 56 | gh_commit_range, 57 | lines.join("\n") 58 | ), 59 | )?; 60 | 61 | Ok(()) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /xtask/src/task/setup.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{cargo, has_cargo_subcmd, rustup, CommandExt}; 2 | use crate::Result; 3 | use argp::FromArgs; 4 | 5 | const COMPONENTS: &[&str] = &["rustfmt", "clippy"]; 6 | const TOOLS: &[&str] = &["cargo-insta"]; 7 | 8 | #[derive(FromArgs)] 9 | #[argp(subcommand, name = "setup")] 10 | /// Install tooling 11 | pub struct Setup { 12 | #[argp(switch, description = "Install using `cargo-binstall` instead")] 13 | binary: bool, 14 | } 15 | 16 | impl Setup { 17 | pub fn run(self) -> Result { 18 | if self.binary { 19 | if !has_cargo_subcmd("binstall")? { 20 | cargo("install").with_arg("cargo-binstall").run()?; 21 | } 22 | 23 | cargo("binstall") 24 | .with_args(["--no-confirm", "--locked"]) 25 | .with_args(TOOLS) 26 | .run()?; 27 | } else { 28 | cargo("install") 29 | .with_args(["--locked"]) 30 | .with_args(TOOLS) 31 | .run()?; 32 | } 33 | 34 | rustup("component") 35 | .with_arg("add") 36 | .with_args(COMPONENTS) 37 | .run()?; 38 | 39 | Ok(()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /xtask/src/task/test.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{cargo, CommandExt}; 2 | use crate::Result; 3 | use argp::FromArgs; 4 | use std::process::Command; 5 | 6 | #[derive(FromArgs)] 7 | #[argp(subcommand, name = "test", description = "Run tests")] 8 | pub struct Test { 9 | /// Additional arguments for the test command 10 | #[argp(positional)] 11 | rest: Vec, 12 | } 13 | 14 | impl Test { 15 | pub fn run(self) -> Result { 16 | tests(&self.rest).run()?; 17 | 18 | Ok(()) 19 | } 20 | } 21 | 22 | fn tests(args: &[String]) -> Command { 23 | cargo("insta") 24 | .with_args(["test", "--all-features", "--lib", "--review"]) 25 | .with_args(args) 26 | } 27 | -------------------------------------------------------------------------------- /xtask/src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use anyhow::anyhow; 3 | use std::ffi::OsStr; 4 | use std::process::{Command, Stdio}; 5 | 6 | /* pub fn project_root() -> &'static Path { 7 | Path::new(env!("CARGO_MANIFEST_DIR")) 8 | } */ 9 | 10 | pub fn git(cmd: &str) -> Command { 11 | Command::new("git").with_arg(cmd) 12 | } 13 | 14 | pub fn rustup(cmd: &str) -> Command { 15 | Command::new("rustup").with_arg(cmd) 16 | } 17 | 18 | pub fn cargo(cmd: &str) -> Command { 19 | Command::new(env!("CARGO")).with_arg(cmd) 20 | } 21 | 22 | pub fn has_cargo_subcmd(cmd: &str) -> Result { 23 | Ok( 24 | cargo("--list") 25 | .run_with_output()? 26 | .split('\n') 27 | .filter(|v| v.starts_with(|c: char| c.is_ascii_whitespace())) 28 | .map(str::trim) 29 | .map(|v| v.split_ascii_whitespace().next().unwrap()) 30 | .any(|v| v == cmd), 31 | ) 32 | } 33 | 34 | pub trait CommandExt { 35 | fn with_arg(self, arg: S) -> Self 36 | where 37 | S: AsRef; 38 | 39 | fn with_args(self, args: I) -> Self 40 | where 41 | I: IntoIterator, 42 | S: AsRef; 43 | 44 | /* fn with_env(self, key: K, val: V) -> Self 45 | where 46 | K: AsRef, 47 | V: AsRef; */ 48 | 49 | fn run(self) -> Result; 50 | 51 | fn run_with_output(self) -> Result; 52 | } 53 | 54 | impl CommandExt for Command { 55 | fn with_arg(mut self, arg: S) -> Self 56 | where 57 | S: AsRef, 58 | { 59 | self.arg(arg); 60 | self 61 | } 62 | 63 | fn with_args(mut self, args: I) -> Self 64 | where 65 | I: IntoIterator, 66 | S: AsRef, 67 | { 68 | self.args(args); 69 | self 70 | } 71 | 72 | /* fn with_env(mut self, key: K, val: V) -> Self 73 | where 74 | K: AsRef, 75 | V: AsRef, 76 | { 77 | self.env(key, val); 78 | self 79 | } */ 80 | 81 | fn run(mut self) -> Result { 82 | self.spawn()?.wait()?.check() 83 | } 84 | 85 | fn run_with_output(mut self) -> Result { 86 | let output = self 87 | .stderr(Stdio::piped()) 88 | .stdout(Stdio::piped()) 89 | .spawn()? 90 | .wait_with_output()?; 91 | output.check()?; 92 | Ok(String::from_utf8(output.stdout)?) 93 | } 94 | } 95 | 96 | pub trait CheckStatus { 97 | fn check(&self) -> Result; 98 | } 99 | 100 | impl CheckStatus for std::process::ExitStatus { 101 | fn check(&self) -> Result { 102 | match self.success() { 103 | true => Ok(()), 104 | false => Err(anyhow!( 105 | "Process exited with error code {}", 106 | self.code().unwrap_or(-1) 107 | )), 108 | } 109 | } 110 | } 111 | 112 | impl CheckStatus for std::process::Output { 113 | fn check(&self) -> Result { 114 | self.status.check() 115 | } 116 | } 117 | --------------------------------------------------------------------------------