diff options
85 files changed, 11993 insertions, 6647 deletions
diff --git a/.env.example b/.env.example index b7c7c61..fe76c05 100644 --- a/.env.example +++ b/.env.example | |||
| @@ -5,15 +5,11 @@ TOKEN= | |||
| 5 | STATUS_CHANNEL_ID= | 5 | STATUS_CHANNEL_ID= |
| 6 | ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant | 6 | ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant |
| 7 | 7 | ||
| 8 | # Optional - File Server (for music /search commands) | 8 | # Optional - Copyparty File Server (for music /search commands) |
| 9 | COPYPARTY_URL=http://localhost:8080 | 9 | COPYPARTY_URL=http://localhost:8080 |
| 10 | COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads | 10 | COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads |
| 11 | COPYPARTY_PASSWORD= | 11 | COPYPARTY_PASSWORD= |
| 12 | 12 | ||
| 13 | # Optional - AI commands | 13 | # Optional - AI commands |
| 14 | # LLM_API_URL=http://localhost:8080/v1/chat/completions # llama.cpp server (use http://, no TLS) | 14 | OPENROUTER_API_KEY= |
| 15 | # LLM_API_KEY= # not needed for llama.cpp | 15 | OPENROUTER_MODEL= |
| 16 | # LLM_API_VERIFY=true # SSL verify (false for self-signed certs) | ||
| 17 | # LLM_MODEL= # model name for llama.cpp | ||
| 18 | OPENROUTER_API_KEY= # deprecated, use LLM_API_KEY instead | ||
| 19 | OPENROUTER_MODEL= # deprecated, use LLM_MODEL instead | ||
| @@ -1,7 +1,4 @@ | |||
| 1 | .env | 1 | .env |
| 2 | __pycache__/ | ||
| 3 | data/ | 2 | data/ |
| 4 | .idea/ | ||
| 5 | .venv/ | ||
| 6 | .zed/ | 3 | .zed/ |
| 7 | .pytest_cache/ | 4 | target |
| @@ -1,33 +1,47 @@ | |||
| 1 | # Agent Instructions for Senator Sperma | 1 | # senator-sperma-rust — Agent Guide |
| 2 | 2 | ||
| 3 | ## Required Setup | 3 | ## Project Map |
| 4 | - Requires **Python 3.14** | ||
| 5 | - System dependencies: `ffmpeg`, `zbar` library (installed system-wide) | ||
| 6 | - Bot token in `.env` file (copy from `.env.example`) | ||
| 7 | 4 | ||
| 8 | ## Commands | 5 | ``` |
| 9 | - Run bot: `.venv/bin/python main.py` | 6 | src/ |
| 10 | - Type check: `.venv/bin/basedpyright` | 7 | ├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity |
| 11 | - Test: `.venv/bin/pytest` | 8 | ├── config.rs — Config struct (env-driven), pub const TEMP_PATH |
| 9 | ├── error.rs — Unified Error enum + From impls, pub type Result<T> | ||
| 10 | ├── commands/ | ||
| 11 | │ ├── mod.rs — all_commands() aggregates every submodule | ||
| 12 | │ ├── ai.rs — /senator (OpenRouter chat + optional TTS) | ||
| 13 | │ ├── cron.rs — /cron group (6 subcommands) | ||
| 14 | │ ├── math.rs — /math (qalc subprocess) | ||
| 15 | │ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle | ||
| 16 | │ ├── qr.rs — /qr encode + decode | ||
| 17 | │ ├── trigger.rs — /trigger group (6 subcommands) | ||
| 18 | │ ├── tts.rs — /tts (Google TTS → file/vc), /log_volume | ||
| 19 | │ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say | ||
| 20 | │ └── web.rs — /web (DuckDuckGo scrape) | ||
| 21 | ├── copyparty/mod.rs — CopypartyClient (search, upload) | ||
| 22 | ├── database/mod.rs — DbPool, 21 query fns, 6 tables, migration | ||
| 23 | ├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message | ||
| 24 | ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) | ||
| 25 | ├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) | ||
| 26 | └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input | ||
| 27 | ``` | ||
| 12 | 28 | ||
| 13 | ## Architecture | 29 | ## Key Conventions |
| 14 | - Slash commands defined as cogs in `cogs/*.py` | ||
| 15 | - Discord API uses `discord.ApplicationContext` | ||
| 16 | - Music auto-complete queries copyparty server (`COPYPARTY_URL` config) | ||
| 17 | 30 | ||
| 18 | ## Environment | 31 | - **Framework**: poise 0.6 + serenity 0.12 + songbird 0.6 |
| 19 | - Copyparty URL for read-only access is COPYPARTY_URL | 32 | - **DB**: sqlx 0.9 async SQLite (pool stored in `Data.db_pool`) |
| 20 | - Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD | 33 | - **Errors**: `crate::error::Error` enum with `?` propagation; use `Error::Custom(string)` for ad-hoc |
| 21 | - Above should be accessed using COPYPARTY_PASSWORD as a cookie | 34 | - **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` |
| 22 | - Channel for vclogs and alike is STATUS_CHANNEL_ID | 35 | - **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>` |
| 23 | - Trigger word for assistant is ASSISTANT_KEYWORD | 36 | - **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`) |
| 24 | 37 | ||
| 25 | ## Testing | 38 | ## Stubs to Fill (see COMPARISON.md) |
| 26 | - Tests in `tests/cogs/test_*.py` | 39 | - `commands/music.rs` — `/play` is placeholder |
| 27 | - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` | 40 | - `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part |
| 28 | - `conftest.py` provides fixtures for mocking Discord objects | 41 | - `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action |
| 29 | - **Slow integration tests** marked with `@pytest.mark.slow` | 42 | |
| 30 | - Test real-world feature usage with longer execution times | 43 | ## Database Tables |
| 31 | - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses | 44 | `settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` |
| 32 | - Run only fast tests: `.venv/bin/pytest -m "not slow"` | 45 | |
| 33 | - Run only slow tests: `.venv/bin/pytest -m slow` | 46 | ## All Commands (26 total) |
| 47 | `/senator`, `/cron` (6), `/math`, `/play`, `/stop`, `/join`, `/loop_toggle`, `/qr` (2), `/trigger` (6), `/tts`, `/log_volume`, `/compress`, `/convert`, `/upload`, `/search`, `/download`, `/say`, `/web` | ||
diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9532efb --- /dev/null +++ b/Cargo.lock | |||
| @@ -0,0 +1,6967 @@ | |||
| 1 | # This file is automatically @generated by Cargo. | ||
| 2 | # It is not intended for manual editing. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "ab_glyph" | ||
| 7 | version = "0.2.32" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" | ||
| 10 | dependencies = [ | ||
| 11 | "ab_glyph_rasterizer", | ||
| 12 | "owned_ttf_parser", | ||
| 13 | ] | ||
| 14 | |||
| 15 | [[package]] | ||
| 16 | name = "ab_glyph_rasterizer" | ||
| 17 | version = "0.1.10" | ||
| 18 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 19 | checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" | ||
| 20 | |||
| 21 | [[package]] | ||
| 22 | name = "adler2" | ||
| 23 | version = "2.0.1" | ||
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 25 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | ||
| 26 | |||
| 27 | [[package]] | ||
| 28 | name = "aead" | ||
| 29 | version = "0.5.2" | ||
| 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 31 | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | ||
| 32 | dependencies = [ | ||
| 33 | "crypto-common 0.1.7", | ||
| 34 | "generic-array", | ||
| 35 | ] | ||
| 36 | |||
| 37 | [[package]] | ||
| 38 | name = "aes" | ||
| 39 | version = "0.8.4" | ||
| 40 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 41 | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | ||
| 42 | dependencies = [ | ||
| 43 | "cfg-if", | ||
| 44 | "cipher", | ||
| 45 | "cpufeatures 0.2.17", | ||
| 46 | ] | ||
| 47 | |||
| 48 | [[package]] | ||
| 49 | name = "aes-gcm" | ||
| 50 | version = "0.10.3" | ||
| 51 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 52 | checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | ||
| 53 | dependencies = [ | ||
| 54 | "aead", | ||
| 55 | "aes", | ||
| 56 | "cipher", | ||
| 57 | "ctr", | ||
| 58 | "ghash", | ||
| 59 | "subtle", | ||
| 60 | ] | ||
| 61 | |||
| 62 | [[package]] | ||
| 63 | name = "aho-corasick" | ||
| 64 | version = "1.1.4" | ||
| 65 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 66 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | ||
| 67 | dependencies = [ | ||
| 68 | "memchr", | ||
| 69 | ] | ||
| 70 | |||
| 71 | [[package]] | ||
| 72 | name = "aligned-vec" | ||
| 73 | version = "0.6.4" | ||
| 74 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 75 | checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" | ||
| 76 | dependencies = [ | ||
| 77 | "equator", | ||
| 78 | ] | ||
| 79 | |||
| 80 | [[package]] | ||
| 81 | name = "allocator-api2" | ||
| 82 | version = "0.2.21" | ||
| 83 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 84 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | ||
| 85 | |||
| 86 | [[package]] | ||
| 87 | name = "android_system_properties" | ||
| 88 | version = "0.1.5" | ||
| 89 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 90 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | ||
| 91 | dependencies = [ | ||
| 92 | "libc", | ||
| 93 | ] | ||
| 94 | |||
| 95 | [[package]] | ||
| 96 | name = "anyhow" | ||
| 97 | version = "1.0.102" | ||
| 98 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 99 | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | ||
| 100 | |||
| 101 | [[package]] | ||
| 102 | name = "approx" | ||
| 103 | version = "0.5.1" | ||
| 104 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 105 | checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" | ||
| 106 | dependencies = [ | ||
| 107 | "num-traits", | ||
| 108 | ] | ||
| 109 | |||
| 110 | [[package]] | ||
| 111 | name = "arbitrary" | ||
| 112 | version = "1.4.2" | ||
| 113 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 114 | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" | ||
| 115 | |||
| 116 | [[package]] | ||
| 117 | name = "arg_enum_proc_macro" | ||
| 118 | version = "0.3.4" | ||
| 119 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 120 | checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" | ||
| 121 | dependencies = [ | ||
| 122 | "proc-macro2", | ||
| 123 | "quote", | ||
| 124 | "syn 2.0.118", | ||
| 125 | ] | ||
| 126 | |||
| 127 | [[package]] | ||
| 128 | name = "arrayvec" | ||
| 129 | version = "0.7.6" | ||
| 130 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 131 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" | ||
| 132 | dependencies = [ | ||
| 133 | "serde", | ||
| 134 | ] | ||
| 135 | |||
| 136 | [[package]] | ||
| 137 | name = "async-trait" | ||
| 138 | version = "0.1.89" | ||
| 139 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 140 | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | ||
| 141 | dependencies = [ | ||
| 142 | "proc-macro2", | ||
| 143 | "quote", | ||
| 144 | "syn 2.0.118", | ||
| 145 | ] | ||
| 146 | |||
| 147 | [[package]] | ||
| 148 | name = "atoi" | ||
| 149 | version = "2.0.0" | ||
| 150 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 151 | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | ||
| 152 | dependencies = [ | ||
| 153 | "num-traits", | ||
| 154 | ] | ||
| 155 | |||
| 156 | [[package]] | ||
| 157 | name = "atomic-waker" | ||
| 158 | version = "1.1.2" | ||
| 159 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 160 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | ||
| 161 | |||
| 162 | [[package]] | ||
| 163 | name = "autocfg" | ||
| 164 | version = "1.5.1" | ||
| 165 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 166 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | ||
| 167 | |||
| 168 | [[package]] | ||
| 169 | name = "av1-grain" | ||
| 170 | version = "0.2.5" | ||
| 171 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 172 | checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" | ||
| 173 | dependencies = [ | ||
| 174 | "anyhow", | ||
| 175 | "arrayvec", | ||
| 176 | "log", | ||
| 177 | "nom", | ||
| 178 | "num-rational", | ||
| 179 | "v_frame", | ||
| 180 | ] | ||
| 181 | |||
| 182 | [[package]] | ||
| 183 | name = "avif-serialize" | ||
| 184 | version = "0.8.9" | ||
| 185 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 186 | checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" | ||
| 187 | dependencies = [ | ||
| 188 | "arrayvec", | ||
| 189 | ] | ||
| 190 | |||
| 191 | [[package]] | ||
| 192 | name = "aws-lc-rs" | ||
| 193 | version = "1.17.0" | ||
| 194 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 195 | checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" | ||
| 196 | dependencies = [ | ||
| 197 | "aws-lc-sys", | ||
| 198 | "zeroize", | ||
| 199 | ] | ||
| 200 | |||
| 201 | [[package]] | ||
| 202 | name = "aws-lc-sys" | ||
| 203 | version = "0.41.0" | ||
| 204 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 205 | checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" | ||
| 206 | dependencies = [ | ||
| 207 | "cc", | ||
| 208 | "cmake", | ||
| 209 | "dunce", | ||
| 210 | "fs_extra", | ||
| 211 | ] | ||
| 212 | |||
| 213 | [[package]] | ||
| 214 | name = "base16ct" | ||
| 215 | version = "0.2.0" | ||
| 216 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 217 | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | ||
| 218 | |||
| 219 | [[package]] | ||
| 220 | name = "base64" | ||
| 221 | version = "0.22.1" | ||
| 222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 223 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | ||
| 224 | |||
| 225 | [[package]] | ||
| 226 | name = "base64ct" | ||
| 227 | version = "1.8.3" | ||
| 228 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 229 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | ||
| 230 | |||
| 231 | [[package]] | ||
| 232 | name = "bit-set" | ||
| 233 | version = "0.8.0" | ||
| 234 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 235 | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | ||
| 236 | dependencies = [ | ||
| 237 | "bit-vec", | ||
| 238 | ] | ||
| 239 | |||
| 240 | [[package]] | ||
| 241 | name = "bit-vec" | ||
| 242 | version = "0.8.0" | ||
| 243 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 244 | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | ||
| 245 | |||
| 246 | [[package]] | ||
| 247 | name = "bit_field" | ||
| 248 | version = "0.10.3" | ||
| 249 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 250 | checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" | ||
| 251 | |||
| 252 | [[package]] | ||
| 253 | name = "bitflags" | ||
| 254 | version = "1.3.2" | ||
| 255 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 256 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | ||
| 257 | |||
| 258 | [[package]] | ||
| 259 | name = "bitflags" | ||
| 260 | version = "2.13.0" | ||
| 261 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 262 | checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | ||
| 263 | dependencies = [ | ||
| 264 | "serde_core", | ||
| 265 | ] | ||
| 266 | |||
| 267 | [[package]] | ||
| 268 | name = "bitstream-io" | ||
| 269 | version = "2.6.0" | ||
| 270 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 271 | checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" | ||
| 272 | |||
| 273 | [[package]] | ||
| 274 | name = "block-buffer" | ||
| 275 | version = "0.10.4" | ||
| 276 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 277 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | ||
| 278 | dependencies = [ | ||
| 279 | "generic-array", | ||
| 280 | ] | ||
| 281 | |||
| 282 | [[package]] | ||
| 283 | name = "block-buffer" | ||
| 284 | version = "0.12.1" | ||
| 285 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 286 | checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" | ||
| 287 | dependencies = [ | ||
| 288 | "hybrid-array", | ||
| 289 | ] | ||
| 290 | |||
| 291 | [[package]] | ||
| 292 | name = "built" | ||
| 293 | version = "0.7.7" | ||
| 294 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 295 | checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" | ||
| 296 | |||
| 297 | [[package]] | ||
| 298 | name = "bumpalo" | ||
| 299 | version = "3.20.3" | ||
| 300 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 301 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | ||
| 302 | |||
| 303 | [[package]] | ||
| 304 | name = "bytecount" | ||
| 305 | version = "0.6.9" | ||
| 306 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 307 | checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" | ||
| 308 | |||
| 309 | [[package]] | ||
| 310 | name = "bytemuck" | ||
| 311 | version = "1.25.0" | ||
| 312 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 313 | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | ||
| 314 | |||
| 315 | [[package]] | ||
| 316 | name = "byteorder" | ||
| 317 | version = "1.5.0" | ||
| 318 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 319 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | ||
| 320 | |||
| 321 | [[package]] | ||
| 322 | name = "byteorder-lite" | ||
| 323 | version = "0.1.0" | ||
| 324 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 325 | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | ||
| 326 | |||
| 327 | [[package]] | ||
| 328 | name = "bytes" | ||
| 329 | version = "1.11.1" | ||
| 330 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 331 | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | ||
| 332 | |||
| 333 | [[package]] | ||
| 334 | name = "camino" | ||
| 335 | version = "1.2.2" | ||
| 336 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 337 | checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" | ||
| 338 | dependencies = [ | ||
| 339 | "serde_core", | ||
| 340 | ] | ||
| 341 | |||
| 342 | [[package]] | ||
| 343 | name = "cargo-platform" | ||
| 344 | version = "0.1.9" | ||
| 345 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 346 | checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" | ||
| 347 | dependencies = [ | ||
| 348 | "serde", | ||
| 349 | ] | ||
| 350 | |||
| 351 | [[package]] | ||
| 352 | name = "cargo_metadata" | ||
| 353 | version = "0.14.2" | ||
| 354 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 355 | checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" | ||
| 356 | dependencies = [ | ||
| 357 | "camino", | ||
| 358 | "cargo-platform", | ||
| 359 | "semver", | ||
| 360 | "serde", | ||
| 361 | "serde_json", | ||
| 362 | ] | ||
| 363 | |||
| 364 | [[package]] | ||
| 365 | name = "cc" | ||
| 366 | version = "1.2.64" | ||
| 367 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 368 | checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" | ||
| 369 | dependencies = [ | ||
| 370 | "find-msvc-tools", | ||
| 371 | "jobserver", | ||
| 372 | "libc", | ||
| 373 | "shlex", | ||
| 374 | ] | ||
| 375 | |||
| 376 | [[package]] | ||
| 377 | name = "cfg-expr" | ||
| 378 | version = "0.15.8" | ||
| 379 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 380 | checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" | ||
| 381 | dependencies = [ | ||
| 382 | "smallvec", | ||
| 383 | "target-lexicon", | ||
| 384 | ] | ||
| 385 | |||
| 386 | [[package]] | ||
| 387 | name = "cfg-if" | ||
| 388 | version = "1.0.4" | ||
| 389 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 390 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | ||
| 391 | |||
| 392 | [[package]] | ||
| 393 | name = "cfg_aliases" | ||
| 394 | version = "0.2.1" | ||
| 395 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 396 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | ||
| 397 | |||
| 398 | [[package]] | ||
| 399 | name = "chacha20" | ||
| 400 | version = "0.9.1" | ||
| 401 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 402 | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | ||
| 403 | dependencies = [ | ||
| 404 | "cfg-if", | ||
| 405 | "cipher", | ||
| 406 | "cpufeatures 0.2.17", | ||
| 407 | ] | ||
| 408 | |||
| 409 | [[package]] | ||
| 410 | name = "chacha20" | ||
| 411 | version = "0.10.0" | ||
| 412 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 413 | checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" | ||
| 414 | dependencies = [ | ||
| 415 | "cfg-if", | ||
| 416 | "cpufeatures 0.3.0", | ||
| 417 | "rand_core 0.10.1", | ||
| 418 | ] | ||
| 419 | |||
| 420 | [[package]] | ||
| 421 | name = "chacha20poly1305" | ||
| 422 | version = "0.10.1" | ||
| 423 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 424 | checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" | ||
| 425 | dependencies = [ | ||
| 426 | "aead", | ||
| 427 | "chacha20 0.9.1", | ||
| 428 | "cipher", | ||
| 429 | "poly1305", | ||
| 430 | "zeroize", | ||
| 431 | ] | ||
| 432 | |||
| 433 | [[package]] | ||
| 434 | name = "chrono" | ||
| 435 | version = "0.4.45" | ||
| 436 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 437 | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | ||
| 438 | dependencies = [ | ||
| 439 | "iana-time-zone", | ||
| 440 | "js-sys", | ||
| 441 | "num-traits", | ||
| 442 | "serde", | ||
| 443 | "wasm-bindgen", | ||
| 444 | "windows-link", | ||
| 445 | ] | ||
| 446 | |||
| 447 | [[package]] | ||
| 448 | name = "chrono-tz" | ||
| 449 | version = "0.10.4" | ||
| 450 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 451 | checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" | ||
| 452 | dependencies = [ | ||
| 453 | "chrono", | ||
| 454 | "phf 0.12.1", | ||
| 455 | ] | ||
| 456 | |||
| 457 | [[package]] | ||
| 458 | name = "cipher" | ||
| 459 | version = "0.4.4" | ||
| 460 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 461 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | ||
| 462 | dependencies = [ | ||
| 463 | "crypto-common 0.1.7", | ||
| 464 | "inout", | ||
| 465 | "zeroize", | ||
| 466 | ] | ||
| 467 | |||
| 468 | [[package]] | ||
| 469 | name = "cmake" | ||
| 470 | version = "0.1.58" | ||
| 471 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 472 | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | ||
| 473 | dependencies = [ | ||
| 474 | "cc", | ||
| 475 | ] | ||
| 476 | |||
| 477 | [[package]] | ||
| 478 | name = "cmov" | ||
| 479 | version = "0.5.4" | ||
| 480 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 481 | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | ||
| 482 | |||
| 483 | [[package]] | ||
| 484 | name = "codepage-437" | ||
| 485 | version = "0.1.0" | ||
| 486 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 487 | checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" | ||
| 488 | dependencies = [ | ||
| 489 | "csv", | ||
| 490 | ] | ||
| 491 | |||
| 492 | [[package]] | ||
| 493 | name = "color_quant" | ||
| 494 | version = "1.1.0" | ||
| 495 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 496 | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" | ||
| 497 | |||
| 498 | [[package]] | ||
| 499 | name = "command_attr" | ||
| 500 | version = "0.5.4" | ||
| 501 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 502 | checksum = "8208103c5e25a091226dfa8d61d08d0561cc14f31b25691811ba37d4ec9b157b" | ||
| 503 | dependencies = [ | ||
| 504 | "proc-macro2", | ||
| 505 | "quote", | ||
| 506 | "syn 1.0.109", | ||
| 507 | ] | ||
| 508 | |||
| 509 | [[package]] | ||
| 510 | name = "concurrent-queue" | ||
| 511 | version = "2.5.0" | ||
| 512 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 513 | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | ||
| 514 | dependencies = [ | ||
| 515 | "crossbeam-utils", | ||
| 516 | ] | ||
| 517 | |||
| 518 | [[package]] | ||
| 519 | name = "const-oid" | ||
| 520 | version = "0.9.6" | ||
| 521 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 522 | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | ||
| 523 | |||
| 524 | [[package]] | ||
| 525 | name = "convert_case" | ||
| 526 | version = "0.4.0" | ||
| 527 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 528 | checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" | ||
| 529 | |||
| 530 | [[package]] | ||
| 531 | name = "core-foundation" | ||
| 532 | version = "0.9.4" | ||
| 533 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 534 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | ||
| 535 | dependencies = [ | ||
| 536 | "core-foundation-sys", | ||
| 537 | "libc", | ||
| 538 | ] | ||
| 539 | |||
| 540 | [[package]] | ||
| 541 | name = "core-foundation" | ||
| 542 | version = "0.10.1" | ||
| 543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 544 | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | ||
| 545 | dependencies = [ | ||
| 546 | "core-foundation-sys", | ||
| 547 | "libc", | ||
| 548 | ] | ||
| 549 | |||
| 550 | [[package]] | ||
| 551 | name = "core-foundation-sys" | ||
| 552 | version = "0.8.7" | ||
| 553 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 554 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | ||
| 555 | |||
| 556 | [[package]] | ||
| 557 | name = "core-models" | ||
| 558 | version = "0.0.5" | ||
| 559 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 560 | checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" | ||
| 561 | dependencies = [ | ||
| 562 | "hax-lib", | ||
| 563 | "pastey", | ||
| 564 | "rand 0.9.4", | ||
| 565 | ] | ||
| 566 | |||
| 567 | [[package]] | ||
| 568 | name = "cpufeatures" | ||
| 569 | version = "0.2.17" | ||
| 570 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 571 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | ||
| 572 | dependencies = [ | ||
| 573 | "libc", | ||
| 574 | ] | ||
| 575 | |||
| 576 | [[package]] | ||
| 577 | name = "cpufeatures" | ||
| 578 | version = "0.3.0" | ||
| 579 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 580 | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | ||
| 581 | dependencies = [ | ||
| 582 | "libc", | ||
| 583 | ] | ||
| 584 | |||
| 585 | [[package]] | ||
| 586 | name = "crc" | ||
| 587 | version = "3.4.0" | ||
| 588 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 589 | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | ||
| 590 | dependencies = [ | ||
| 591 | "crc-catalog", | ||
| 592 | ] | ||
| 593 | |||
| 594 | [[package]] | ||
| 595 | name = "crc-catalog" | ||
| 596 | version = "2.5.0" | ||
| 597 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 598 | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | ||
| 599 | |||
| 600 | [[package]] | ||
| 601 | name = "crc32fast" | ||
| 602 | version = "1.5.0" | ||
| 603 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 604 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | ||
| 605 | dependencies = [ | ||
| 606 | "cfg-if", | ||
| 607 | ] | ||
| 608 | |||
| 609 | [[package]] | ||
| 610 | name = "cron" | ||
| 611 | version = "0.17.0" | ||
| 612 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 613 | checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09" | ||
| 614 | dependencies = [ | ||
| 615 | "chrono", | ||
| 616 | "once_cell", | ||
| 617 | "phf 0.11.3", | ||
| 618 | "winnow", | ||
| 619 | ] | ||
| 620 | |||
| 621 | [[package]] | ||
| 622 | name = "crossbeam-channel" | ||
| 623 | version = "0.5.15" | ||
| 624 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 625 | checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" | ||
| 626 | dependencies = [ | ||
| 627 | "crossbeam-utils", | ||
| 628 | ] | ||
| 629 | |||
| 630 | [[package]] | ||
| 631 | name = "crossbeam-deque" | ||
| 632 | version = "0.8.6" | ||
| 633 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 634 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" | ||
| 635 | dependencies = [ | ||
| 636 | "crossbeam-epoch", | ||
| 637 | "crossbeam-utils", | ||
| 638 | ] | ||
| 639 | |||
| 640 | [[package]] | ||
| 641 | name = "crossbeam-epoch" | ||
| 642 | version = "0.9.18" | ||
| 643 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 644 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" | ||
| 645 | dependencies = [ | ||
| 646 | "crossbeam-utils", | ||
| 647 | ] | ||
| 648 | |||
| 649 | [[package]] | ||
| 650 | name = "crossbeam-queue" | ||
| 651 | version = "0.3.12" | ||
| 652 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 653 | checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" | ||
| 654 | dependencies = [ | ||
| 655 | "crossbeam-utils", | ||
| 656 | ] | ||
| 657 | |||
| 658 | [[package]] | ||
| 659 | name = "crossbeam-utils" | ||
| 660 | version = "0.8.21" | ||
| 661 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 662 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | ||
| 663 | |||
| 664 | [[package]] | ||
| 665 | name = "crunchy" | ||
| 666 | version = "0.2.4" | ||
| 667 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 668 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | ||
| 669 | |||
| 670 | [[package]] | ||
| 671 | name = "crypto-bigint" | ||
| 672 | version = "0.5.5" | ||
| 673 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 674 | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | ||
| 675 | dependencies = [ | ||
| 676 | "generic-array", | ||
| 677 | "rand_core 0.6.4", | ||
| 678 | "subtle", | ||
| 679 | "zeroize", | ||
| 680 | ] | ||
| 681 | |||
| 682 | [[package]] | ||
| 683 | name = "crypto-common" | ||
| 684 | version = "0.1.7" | ||
| 685 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 686 | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | ||
| 687 | dependencies = [ | ||
| 688 | "generic-array", | ||
| 689 | "rand_core 0.6.4", | ||
| 690 | "typenum", | ||
| 691 | ] | ||
| 692 | |||
| 693 | [[package]] | ||
| 694 | name = "crypto-common" | ||
| 695 | version = "0.2.2" | ||
| 696 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 697 | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" | ||
| 698 | dependencies = [ | ||
| 699 | "hybrid-array", | ||
| 700 | ] | ||
| 701 | |||
| 702 | [[package]] | ||
| 703 | name = "cssparser" | ||
| 704 | version = "0.37.0" | ||
| 705 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 706 | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | ||
| 707 | dependencies = [ | ||
| 708 | "cssparser-macros", | ||
| 709 | "dtoa-short", | ||
| 710 | "itoa", | ||
| 711 | "phf 0.13.1", | ||
| 712 | "smallvec", | ||
| 713 | ] | ||
| 714 | |||
| 715 | [[package]] | ||
| 716 | name = "cssparser-macros" | ||
| 717 | version = "0.7.0" | ||
| 718 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 719 | checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" | ||
| 720 | dependencies = [ | ||
| 721 | "quote", | ||
| 722 | "syn 2.0.118", | ||
| 723 | ] | ||
| 724 | |||
| 725 | [[package]] | ||
| 726 | name = "csv" | ||
| 727 | version = "1.4.0" | ||
| 728 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 729 | checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" | ||
| 730 | dependencies = [ | ||
| 731 | "csv-core", | ||
| 732 | "itoa", | ||
| 733 | "ryu", | ||
| 734 | "serde_core", | ||
| 735 | ] | ||
| 736 | |||
| 737 | [[package]] | ||
| 738 | name = "csv-core" | ||
| 739 | version = "0.1.13" | ||
| 740 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 741 | checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" | ||
| 742 | dependencies = [ | ||
| 743 | "memchr", | ||
| 744 | ] | ||
| 745 | |||
| 746 | [[package]] | ||
| 747 | name = "ctr" | ||
| 748 | version = "0.9.2" | ||
| 749 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 750 | checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" | ||
| 751 | dependencies = [ | ||
| 752 | "cipher", | ||
| 753 | ] | ||
| 754 | |||
| 755 | [[package]] | ||
| 756 | name = "ctutils" | ||
| 757 | version = "0.4.2" | ||
| 758 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 759 | checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" | ||
| 760 | dependencies = [ | ||
| 761 | "cmov", | ||
| 762 | ] | ||
| 763 | |||
| 764 | [[package]] | ||
| 765 | name = "curve25519-dalek" | ||
| 766 | version = "4.1.3" | ||
| 767 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 768 | checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" | ||
| 769 | dependencies = [ | ||
| 770 | "cfg-if", | ||
| 771 | "cpufeatures 0.2.17", | ||
| 772 | "curve25519-dalek-derive", | ||
| 773 | "digest 0.10.7", | ||
| 774 | "fiat-crypto", | ||
| 775 | "rustc_version", | ||
| 776 | "subtle", | ||
| 777 | "zeroize", | ||
| 778 | ] | ||
| 779 | |||
| 780 | [[package]] | ||
| 781 | name = "curve25519-dalek-derive" | ||
| 782 | version = "0.1.1" | ||
| 783 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 784 | checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | ||
| 785 | dependencies = [ | ||
| 786 | "proc-macro2", | ||
| 787 | "quote", | ||
| 788 | "syn 2.0.118", | ||
| 789 | ] | ||
| 790 | |||
| 791 | [[package]] | ||
| 792 | name = "darling" | ||
| 793 | version = "0.10.2" | ||
| 794 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 795 | checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" | ||
| 796 | dependencies = [ | ||
| 797 | "darling_core 0.10.2", | ||
| 798 | "darling_macro 0.10.2", | ||
| 799 | ] | ||
| 800 | |||
| 801 | [[package]] | ||
| 802 | name = "darling" | ||
| 803 | version = "0.20.11" | ||
| 804 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 805 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" | ||
| 806 | dependencies = [ | ||
| 807 | "darling_core 0.20.11", | ||
| 808 | "darling_macro 0.20.11", | ||
| 809 | ] | ||
| 810 | |||
| 811 | [[package]] | ||
| 812 | name = "darling_core" | ||
| 813 | version = "0.10.2" | ||
| 814 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 815 | checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" | ||
| 816 | dependencies = [ | ||
| 817 | "fnv", | ||
| 818 | "ident_case", | ||
| 819 | "proc-macro2", | ||
| 820 | "quote", | ||
| 821 | "strsim 0.9.3", | ||
| 822 | "syn 1.0.109", | ||
| 823 | ] | ||
| 824 | |||
| 825 | [[package]] | ||
| 826 | name = "darling_core" | ||
| 827 | version = "0.20.11" | ||
| 828 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 829 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" | ||
| 830 | dependencies = [ | ||
| 831 | "fnv", | ||
| 832 | "ident_case", | ||
| 833 | "proc-macro2", | ||
| 834 | "quote", | ||
| 835 | "strsim 0.11.1", | ||
| 836 | "syn 2.0.118", | ||
| 837 | ] | ||
| 838 | |||
| 839 | [[package]] | ||
| 840 | name = "darling_macro" | ||
| 841 | version = "0.10.2" | ||
| 842 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 843 | checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" | ||
| 844 | dependencies = [ | ||
| 845 | "darling_core 0.10.2", | ||
| 846 | "quote", | ||
| 847 | "syn 1.0.109", | ||
| 848 | ] | ||
| 849 | |||
| 850 | [[package]] | ||
| 851 | name = "darling_macro" | ||
| 852 | version = "0.20.11" | ||
| 853 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 854 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" | ||
| 855 | dependencies = [ | ||
| 856 | "darling_core 0.20.11", | ||
| 857 | "quote", | ||
| 858 | "syn 2.0.118", | ||
| 859 | ] | ||
| 860 | |||
| 861 | [[package]] | ||
| 862 | name = "dashmap" | ||
| 863 | version = "5.5.3" | ||
| 864 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 865 | checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" | ||
| 866 | dependencies = [ | ||
| 867 | "cfg-if", | ||
| 868 | "hashbrown 0.14.5", | ||
| 869 | "lock_api", | ||
| 870 | "once_cell", | ||
| 871 | "parking_lot_core 0.9.12", | ||
| 872 | "serde", | ||
| 873 | ] | ||
| 874 | |||
| 875 | [[package]] | ||
| 876 | name = "dashmap" | ||
| 877 | version = "6.2.1" | ||
| 878 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 879 | checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" | ||
| 880 | dependencies = [ | ||
| 881 | "cfg-if", | ||
| 882 | "crossbeam-utils", | ||
| 883 | "hashbrown 0.14.5", | ||
| 884 | "lock_api", | ||
| 885 | "once_cell", | ||
| 886 | "parking_lot_core 0.9.12", | ||
| 887 | ] | ||
| 888 | |||
| 889 | [[package]] | ||
| 890 | name = "data-encoding" | ||
| 891 | version = "2.11.0" | ||
| 892 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 893 | checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" | ||
| 894 | |||
| 895 | [[package]] | ||
| 896 | name = "davey" | ||
| 897 | version = "0.1.3" | ||
| 898 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 899 | checksum = "623f518197232b3e3d23697fd0060e0291e21b9ab02376b6a4c71a235bb9aced" | ||
| 900 | dependencies = [ | ||
| 901 | "aead", | ||
| 902 | "aes", | ||
| 903 | "cipher", | ||
| 904 | "ctr", | ||
| 905 | "ghash", | ||
| 906 | "hex-literal", | ||
| 907 | "hmac 0.12.1", | ||
| 908 | "num-derive", | ||
| 909 | "num-traits", | ||
| 910 | "openmls", | ||
| 911 | "openmls_basic_credential", | ||
| 912 | "openmls_rust_crypto", | ||
| 913 | "p256", | ||
| 914 | "rand 0.8.6", | ||
| 915 | "scrypt", | ||
| 916 | "sha2 0.10.9", | ||
| 917 | "subtle", | ||
| 918 | "thiserror 2.0.18", | ||
| 919 | "tracing", | ||
| 920 | ] | ||
| 921 | |||
| 922 | [[package]] | ||
| 923 | name = "der" | ||
| 924 | version = "0.7.10" | ||
| 925 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 926 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | ||
| 927 | dependencies = [ | ||
| 928 | "const-oid", | ||
| 929 | "pem-rfc7468", | ||
| 930 | "zeroize", | ||
| 931 | ] | ||
| 932 | |||
| 933 | [[package]] | ||
| 934 | name = "deranged" | ||
| 935 | version = "0.5.8" | ||
| 936 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 937 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | ||
| 938 | dependencies = [ | ||
| 939 | "serde_core", | ||
| 940 | ] | ||
| 941 | |||
| 942 | [[package]] | ||
| 943 | name = "derivative" | ||
| 944 | version = "2.2.0" | ||
| 945 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 946 | checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" | ||
| 947 | dependencies = [ | ||
| 948 | "proc-macro2", | ||
| 949 | "quote", | ||
| 950 | "syn 1.0.109", | ||
| 951 | ] | ||
| 952 | |||
| 953 | [[package]] | ||
| 954 | name = "derive_builder" | ||
| 955 | version = "0.9.0" | ||
| 956 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 957 | checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" | ||
| 958 | dependencies = [ | ||
| 959 | "darling 0.10.2", | ||
| 960 | "derive_builder_core", | ||
| 961 | "proc-macro2", | ||
| 962 | "quote", | ||
| 963 | "syn 1.0.109", | ||
| 964 | ] | ||
| 965 | |||
| 966 | [[package]] | ||
| 967 | name = "derive_builder_core" | ||
| 968 | version = "0.9.0" | ||
| 969 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 970 | checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" | ||
| 971 | dependencies = [ | ||
| 972 | "darling 0.10.2", | ||
| 973 | "proc-macro2", | ||
| 974 | "quote", | ||
| 975 | "syn 1.0.109", | ||
| 976 | ] | ||
| 977 | |||
| 978 | [[package]] | ||
| 979 | name = "derive_more" | ||
| 980 | version = "0.99.20" | ||
| 981 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 982 | checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" | ||
| 983 | dependencies = [ | ||
| 984 | "convert_case", | ||
| 985 | "proc-macro2", | ||
| 986 | "quote", | ||
| 987 | "rustc_version", | ||
| 988 | "syn 2.0.118", | ||
| 989 | ] | ||
| 990 | |||
| 991 | [[package]] | ||
| 992 | name = "derive_more" | ||
| 993 | version = "2.1.1" | ||
| 994 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 995 | checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" | ||
| 996 | dependencies = [ | ||
| 997 | "derive_more-impl", | ||
| 998 | ] | ||
| 999 | |||
| 1000 | [[package]] | ||
| 1001 | name = "derive_more-impl" | ||
| 1002 | version = "2.1.1" | ||
| 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1004 | checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" | ||
| 1005 | dependencies = [ | ||
| 1006 | "proc-macro2", | ||
| 1007 | "quote", | ||
| 1008 | "rustc_version", | ||
| 1009 | "syn 2.0.118", | ||
| 1010 | ] | ||
| 1011 | |||
| 1012 | [[package]] | ||
| 1013 | name = "digest" | ||
| 1014 | version = "0.10.7" | ||
| 1015 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1016 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | ||
| 1017 | dependencies = [ | ||
| 1018 | "block-buffer 0.10.4", | ||
| 1019 | "const-oid", | ||
| 1020 | "crypto-common 0.1.7", | ||
| 1021 | "subtle", | ||
| 1022 | ] | ||
| 1023 | |||
| 1024 | [[package]] | ||
| 1025 | name = "digest" | ||
| 1026 | version = "0.11.3" | ||
| 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1028 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | ||
| 1029 | dependencies = [ | ||
| 1030 | "block-buffer 0.12.1", | ||
| 1031 | "crypto-common 0.2.2", | ||
| 1032 | "ctutils", | ||
| 1033 | ] | ||
| 1034 | |||
| 1035 | [[package]] | ||
| 1036 | name = "discortp" | ||
| 1037 | version = "0.6.0" | ||
| 1038 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1039 | checksum = "5c793408a15d361754613fa68123ffa60424c2617fafdf82127b4bedf37d3f5d" | ||
| 1040 | dependencies = [ | ||
| 1041 | "pnet_macros", | ||
| 1042 | "pnet_macros_support", | ||
| 1043 | ] | ||
| 1044 | |||
| 1045 | [[package]] | ||
| 1046 | name = "displaydoc" | ||
| 1047 | version = "0.2.6" | ||
| 1048 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1049 | checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" | ||
| 1050 | dependencies = [ | ||
| 1051 | "proc-macro2", | ||
| 1052 | "quote", | ||
| 1053 | "syn 2.0.118", | ||
| 1054 | ] | ||
| 1055 | |||
| 1056 | [[package]] | ||
| 1057 | name = "dotenvy" | ||
| 1058 | version = "0.15.7" | ||
| 1059 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1060 | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" | ||
| 1061 | |||
| 1062 | [[package]] | ||
| 1063 | name = "dtoa" | ||
| 1064 | version = "1.0.11" | ||
| 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1066 | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" | ||
| 1067 | |||
| 1068 | [[package]] | ||
| 1069 | name = "dtoa-short" | ||
| 1070 | version = "0.3.5" | ||
| 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1072 | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" | ||
| 1073 | dependencies = [ | ||
| 1074 | "dtoa", | ||
| 1075 | ] | ||
| 1076 | |||
| 1077 | [[package]] | ||
| 1078 | name = "dunce" | ||
| 1079 | version = "1.0.5" | ||
| 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1081 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | ||
| 1082 | |||
| 1083 | [[package]] | ||
| 1084 | name = "ecdsa" | ||
| 1085 | version = "0.16.9" | ||
| 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1087 | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | ||
| 1088 | dependencies = [ | ||
| 1089 | "der", | ||
| 1090 | "digest 0.10.7", | ||
| 1091 | "elliptic-curve", | ||
| 1092 | "rfc6979", | ||
| 1093 | "signature", | ||
| 1094 | "spki", | ||
| 1095 | ] | ||
| 1096 | |||
| 1097 | [[package]] | ||
| 1098 | name = "ed25519" | ||
| 1099 | version = "2.2.3" | ||
| 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1101 | checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" | ||
| 1102 | dependencies = [ | ||
| 1103 | "pkcs8", | ||
| 1104 | "signature", | ||
| 1105 | ] | ||
| 1106 | |||
| 1107 | [[package]] | ||
| 1108 | name = "ed25519-dalek" | ||
| 1109 | version = "2.2.0" | ||
| 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1111 | checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" | ||
| 1112 | dependencies = [ | ||
| 1113 | "curve25519-dalek", | ||
| 1114 | "ed25519", | ||
| 1115 | "rand_core 0.6.4", | ||
| 1116 | "serde", | ||
| 1117 | "sha2 0.10.9", | ||
| 1118 | "subtle", | ||
| 1119 | "zeroize", | ||
| 1120 | ] | ||
| 1121 | |||
| 1122 | [[package]] | ||
| 1123 | name = "ego-tree" | ||
| 1124 | version = "0.11.0" | ||
| 1125 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1126 | checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" | ||
| 1127 | |||
| 1128 | [[package]] | ||
| 1129 | name = "either" | ||
| 1130 | version = "1.16.0" | ||
| 1131 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1132 | checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" | ||
| 1133 | dependencies = [ | ||
| 1134 | "serde", | ||
| 1135 | ] | ||
| 1136 | |||
| 1137 | [[package]] | ||
| 1138 | name = "elliptic-curve" | ||
| 1139 | version = "0.13.8" | ||
| 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1141 | checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" | ||
| 1142 | dependencies = [ | ||
| 1143 | "base16ct", | ||
| 1144 | "crypto-bigint", | ||
| 1145 | "digest 0.10.7", | ||
| 1146 | "ff", | ||
| 1147 | "generic-array", | ||
| 1148 | "group", | ||
| 1149 | "hkdf 0.12.4", | ||
| 1150 | "pem-rfc7468", | ||
| 1151 | "pkcs8", | ||
| 1152 | "rand_core 0.6.4", | ||
| 1153 | "sec1", | ||
| 1154 | "subtle", | ||
| 1155 | "zeroize", | ||
| 1156 | ] | ||
| 1157 | |||
| 1158 | [[package]] | ||
| 1159 | name = "encoding_rs" | ||
| 1160 | version = "0.8.35" | ||
| 1161 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1162 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" | ||
| 1163 | dependencies = [ | ||
| 1164 | "cfg-if", | ||
| 1165 | ] | ||
| 1166 | |||
| 1167 | [[package]] | ||
| 1168 | name = "enum-map" | ||
| 1169 | version = "2.7.3" | ||
| 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1171 | checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" | ||
| 1172 | dependencies = [ | ||
| 1173 | "enum-map-derive", | ||
| 1174 | ] | ||
| 1175 | |||
| 1176 | [[package]] | ||
| 1177 | name = "enum-map-derive" | ||
| 1178 | version = "0.17.0" | ||
| 1179 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1180 | checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" | ||
| 1181 | dependencies = [ | ||
| 1182 | "proc-macro2", | ||
| 1183 | "quote", | ||
| 1184 | "syn 2.0.118", | ||
| 1185 | ] | ||
| 1186 | |||
| 1187 | [[package]] | ||
| 1188 | name = "equator" | ||
| 1189 | version = "0.4.2" | ||
| 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1191 | checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" | ||
| 1192 | dependencies = [ | ||
| 1193 | "equator-macro", | ||
| 1194 | ] | ||
| 1195 | |||
| 1196 | [[package]] | ||
| 1197 | name = "equator-macro" | ||
| 1198 | version = "0.4.2" | ||
| 1199 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1200 | checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" | ||
| 1201 | dependencies = [ | ||
| 1202 | "proc-macro2", | ||
| 1203 | "quote", | ||
| 1204 | "syn 2.0.118", | ||
| 1205 | ] | ||
| 1206 | |||
| 1207 | [[package]] | ||
| 1208 | name = "equivalent" | ||
| 1209 | version = "1.0.2" | ||
| 1210 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1211 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | ||
| 1212 | |||
| 1213 | [[package]] | ||
| 1214 | name = "errno" | ||
| 1215 | version = "0.3.14" | ||
| 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1217 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | ||
| 1218 | dependencies = [ | ||
| 1219 | "libc", | ||
| 1220 | "windows-sys 0.61.2", | ||
| 1221 | ] | ||
| 1222 | |||
| 1223 | [[package]] | ||
| 1224 | name = "error-chain" | ||
| 1225 | version = "0.12.4" | ||
| 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1227 | checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" | ||
| 1228 | dependencies = [ | ||
| 1229 | "version_check", | ||
| 1230 | ] | ||
| 1231 | |||
| 1232 | [[package]] | ||
| 1233 | name = "etcetera" | ||
| 1234 | version = "0.11.0" | ||
| 1235 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1236 | checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" | ||
| 1237 | dependencies = [ | ||
| 1238 | "cfg-if", | ||
| 1239 | "windows-sys 0.61.2", | ||
| 1240 | ] | ||
| 1241 | |||
| 1242 | [[package]] | ||
| 1243 | name = "event-listener" | ||
| 1244 | version = "5.4.1" | ||
| 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1246 | checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" | ||
| 1247 | dependencies = [ | ||
| 1248 | "concurrent-queue", | ||
| 1249 | "parking", | ||
| 1250 | "pin-project-lite", | ||
| 1251 | ] | ||
| 1252 | |||
| 1253 | [[package]] | ||
| 1254 | name = "exr" | ||
| 1255 | version = "1.74.0" | ||
| 1256 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1257 | checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" | ||
| 1258 | dependencies = [ | ||
| 1259 | "bit_field", | ||
| 1260 | "half", | ||
| 1261 | "lebe", | ||
| 1262 | "miniz_oxide", | ||
| 1263 | "rayon-core", | ||
| 1264 | "smallvec", | ||
| 1265 | "zune-inflate", | ||
| 1266 | ] | ||
| 1267 | |||
| 1268 | [[package]] | ||
| 1269 | name = "extended" | ||
| 1270 | version = "0.1.0" | ||
| 1271 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1272 | checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" | ||
| 1273 | |||
| 1274 | [[package]] | ||
| 1275 | name = "fancy-regex" | ||
| 1276 | version = "0.17.0" | ||
| 1277 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1278 | checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" | ||
| 1279 | dependencies = [ | ||
| 1280 | "bit-set", | ||
| 1281 | "regex-automata", | ||
| 1282 | "regex-syntax", | ||
| 1283 | ] | ||
| 1284 | |||
| 1285 | [[package]] | ||
| 1286 | name = "fastrand" | ||
| 1287 | version = "2.4.1" | ||
| 1288 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1289 | checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | ||
| 1290 | |||
| 1291 | [[package]] | ||
| 1292 | name = "fax" | ||
| 1293 | version = "0.2.7" | ||
| 1294 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1295 | checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" | ||
| 1296 | |||
| 1297 | [[package]] | ||
| 1298 | name = "fdeflate" | ||
| 1299 | version = "0.3.7" | ||
| 1300 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1301 | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | ||
| 1302 | dependencies = [ | ||
| 1303 | "simd-adler32", | ||
| 1304 | ] | ||
| 1305 | |||
| 1306 | [[package]] | ||
| 1307 | name = "ff" | ||
| 1308 | version = "0.13.1" | ||
| 1309 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1310 | checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" | ||
| 1311 | dependencies = [ | ||
| 1312 | "rand_core 0.6.4", | ||
| 1313 | "subtle", | ||
| 1314 | ] | ||
| 1315 | |||
| 1316 | [[package]] | ||
| 1317 | name = "fiat-crypto" | ||
| 1318 | version = "0.2.9" | ||
| 1319 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1320 | checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" | ||
| 1321 | |||
| 1322 | [[package]] | ||
| 1323 | name = "find-msvc-tools" | ||
| 1324 | version = "0.1.9" | ||
| 1325 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1326 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | ||
| 1327 | |||
| 1328 | [[package]] | ||
| 1329 | name = "flate2" | ||
| 1330 | version = "1.1.9" | ||
| 1331 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1332 | checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" | ||
| 1333 | dependencies = [ | ||
| 1334 | "crc32fast", | ||
| 1335 | "miniz_oxide", | ||
| 1336 | ] | ||
| 1337 | |||
| 1338 | [[package]] | ||
| 1339 | name = "flume" | ||
| 1340 | version = "0.11.1" | ||
| 1341 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1342 | checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" | ||
| 1343 | dependencies = [ | ||
| 1344 | "futures-core", | ||
| 1345 | "futures-sink", | ||
| 1346 | "nanorand", | ||
| 1347 | "spin", | ||
| 1348 | ] | ||
| 1349 | |||
| 1350 | [[package]] | ||
| 1351 | name = "flume" | ||
| 1352 | version = "0.12.0" | ||
| 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1354 | checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" | ||
| 1355 | dependencies = [ | ||
| 1356 | "futures-core", | ||
| 1357 | "futures-sink", | ||
| 1358 | "spin", | ||
| 1359 | ] | ||
| 1360 | |||
| 1361 | [[package]] | ||
| 1362 | name = "fluvio-wasm-timer" | ||
| 1363 | version = "0.2.5" | ||
| 1364 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1365 | checksum = "b768c170dc045fa587a8f948c91f9bcfb87f774930477c6215addf54317f137f" | ||
| 1366 | dependencies = [ | ||
| 1367 | "futures", | ||
| 1368 | "js-sys", | ||
| 1369 | "parking_lot 0.11.2", | ||
| 1370 | "pin-utils", | ||
| 1371 | "wasm-bindgen", | ||
| 1372 | "wasm-bindgen-futures", | ||
| 1373 | "web-sys", | ||
| 1374 | ] | ||
| 1375 | |||
| 1376 | [[package]] | ||
| 1377 | name = "fnv" | ||
| 1378 | version = "1.0.7" | ||
| 1379 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1380 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | ||
| 1381 | |||
| 1382 | [[package]] | ||
| 1383 | name = "foldhash" | ||
| 1384 | version = "0.2.0" | ||
| 1385 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1386 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | ||
| 1387 | |||
| 1388 | [[package]] | ||
| 1389 | name = "foreign-types" | ||
| 1390 | version = "0.3.2" | ||
| 1391 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1392 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | ||
| 1393 | dependencies = [ | ||
| 1394 | "foreign-types-shared", | ||
| 1395 | ] | ||
| 1396 | |||
| 1397 | [[package]] | ||
| 1398 | name = "foreign-types-shared" | ||
| 1399 | version = "0.1.1" | ||
| 1400 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1401 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | ||
| 1402 | |||
| 1403 | [[package]] | ||
| 1404 | name = "form_urlencoded" | ||
| 1405 | version = "1.2.2" | ||
| 1406 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1407 | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | ||
| 1408 | dependencies = [ | ||
| 1409 | "percent-encoding", | ||
| 1410 | ] | ||
| 1411 | |||
| 1412 | [[package]] | ||
| 1413 | name = "from_map" | ||
| 1414 | version = "0.1.0" | ||
| 1415 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1416 | checksum = "99f31122ab0445ff8cee420b805f24e07683073815de1dd276ee7d588d301700" | ||
| 1417 | dependencies = [ | ||
| 1418 | "hashmap_derive", | ||
| 1419 | ] | ||
| 1420 | |||
| 1421 | [[package]] | ||
| 1422 | name = "fs_extra" | ||
| 1423 | version = "1.3.0" | ||
| 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1425 | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" | ||
| 1426 | |||
| 1427 | [[package]] | ||
| 1428 | name = "futures" | ||
| 1429 | version = "0.3.32" | ||
| 1430 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1431 | checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" | ||
| 1432 | dependencies = [ | ||
| 1433 | "futures-channel", | ||
| 1434 | "futures-core", | ||
| 1435 | "futures-executor", | ||
| 1436 | "futures-io", | ||
| 1437 | "futures-sink", | ||
| 1438 | "futures-task", | ||
| 1439 | "futures-util", | ||
| 1440 | ] | ||
| 1441 | |||
| 1442 | [[package]] | ||
| 1443 | name = "futures-channel" | ||
| 1444 | version = "0.3.32" | ||
| 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1446 | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" | ||
| 1447 | dependencies = [ | ||
| 1448 | "futures-core", | ||
| 1449 | "futures-sink", | ||
| 1450 | ] | ||
| 1451 | |||
| 1452 | [[package]] | ||
| 1453 | name = "futures-core" | ||
| 1454 | version = "0.3.32" | ||
| 1455 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1456 | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | ||
| 1457 | |||
| 1458 | [[package]] | ||
| 1459 | name = "futures-executor" | ||
| 1460 | version = "0.3.32" | ||
| 1461 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1462 | checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" | ||
| 1463 | dependencies = [ | ||
| 1464 | "futures-core", | ||
| 1465 | "futures-task", | ||
| 1466 | "futures-util", | ||
| 1467 | ] | ||
| 1468 | |||
| 1469 | [[package]] | ||
| 1470 | name = "futures-intrusive" | ||
| 1471 | version = "0.5.0" | ||
| 1472 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1473 | checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" | ||
| 1474 | dependencies = [ | ||
| 1475 | "futures-core", | ||
| 1476 | "lock_api", | ||
| 1477 | "parking_lot 0.12.5", | ||
| 1478 | ] | ||
| 1479 | |||
| 1480 | [[package]] | ||
| 1481 | name = "futures-io" | ||
| 1482 | version = "0.3.32" | ||
| 1483 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1484 | checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" | ||
| 1485 | |||
| 1486 | [[package]] | ||
| 1487 | name = "futures-macro" | ||
| 1488 | version = "0.3.32" | ||
| 1489 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1490 | checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" | ||
| 1491 | dependencies = [ | ||
| 1492 | "proc-macro2", | ||
| 1493 | "quote", | ||
| 1494 | "syn 2.0.118", | ||
| 1495 | ] | ||
| 1496 | |||
| 1497 | [[package]] | ||
| 1498 | name = "futures-sink" | ||
| 1499 | version = "0.3.32" | ||
| 1500 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1501 | checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" | ||
| 1502 | |||
| 1503 | [[package]] | ||
| 1504 | name = "futures-task" | ||
| 1505 | version = "0.3.32" | ||
| 1506 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1507 | checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" | ||
| 1508 | |||
| 1509 | [[package]] | ||
| 1510 | name = "futures-util" | ||
| 1511 | version = "0.3.32" | ||
| 1512 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1513 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | ||
| 1514 | dependencies = [ | ||
| 1515 | "futures-channel", | ||
| 1516 | "futures-core", | ||
| 1517 | "futures-io", | ||
| 1518 | "futures-macro", | ||
| 1519 | "futures-sink", | ||
| 1520 | "futures-task", | ||
| 1521 | "memchr", | ||
| 1522 | "pin-project-lite", | ||
| 1523 | "slab", | ||
| 1524 | ] | ||
| 1525 | |||
| 1526 | [[package]] | ||
| 1527 | name = "generator" | ||
| 1528 | version = "0.7.5" | ||
| 1529 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1530 | checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" | ||
| 1531 | dependencies = [ | ||
| 1532 | "cc", | ||
| 1533 | "libc", | ||
| 1534 | "log", | ||
| 1535 | "rustversion", | ||
| 1536 | "windows", | ||
| 1537 | ] | ||
| 1538 | |||
| 1539 | [[package]] | ||
| 1540 | name = "generic-array" | ||
| 1541 | version = "0.14.7" | ||
| 1542 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1543 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | ||
| 1544 | dependencies = [ | ||
| 1545 | "typenum", | ||
| 1546 | "version_check", | ||
| 1547 | "zeroize", | ||
| 1548 | ] | ||
| 1549 | |||
| 1550 | [[package]] | ||
| 1551 | name = "getopts" | ||
| 1552 | version = "0.2.24" | ||
| 1553 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1554 | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" | ||
| 1555 | dependencies = [ | ||
| 1556 | "unicode-width", | ||
| 1557 | ] | ||
| 1558 | |||
| 1559 | [[package]] | ||
| 1560 | name = "getrandom" | ||
| 1561 | version = "0.2.17" | ||
| 1562 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1563 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | ||
| 1564 | dependencies = [ | ||
| 1565 | "cfg-if", | ||
| 1566 | "js-sys", | ||
| 1567 | "libc", | ||
| 1568 | "wasi", | ||
| 1569 | "wasm-bindgen", | ||
| 1570 | ] | ||
| 1571 | |||
| 1572 | [[package]] | ||
| 1573 | name = "getrandom" | ||
| 1574 | version = "0.3.4" | ||
| 1575 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1576 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | ||
| 1577 | dependencies = [ | ||
| 1578 | "cfg-if", | ||
| 1579 | "js-sys", | ||
| 1580 | "libc", | ||
| 1581 | "r-efi 5.3.0", | ||
| 1582 | "wasip2", | ||
| 1583 | "wasm-bindgen", | ||
| 1584 | ] | ||
| 1585 | |||
| 1586 | [[package]] | ||
| 1587 | name = "getrandom" | ||
| 1588 | version = "0.4.3" | ||
| 1589 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1590 | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | ||
| 1591 | dependencies = [ | ||
| 1592 | "cfg-if", | ||
| 1593 | "libc", | ||
| 1594 | "r-efi 6.0.0", | ||
| 1595 | "rand_core 0.10.1", | ||
| 1596 | ] | ||
| 1597 | |||
| 1598 | [[package]] | ||
| 1599 | name = "ghash" | ||
| 1600 | version = "0.5.1" | ||
| 1601 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1602 | checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" | ||
| 1603 | dependencies = [ | ||
| 1604 | "opaque-debug", | ||
| 1605 | "polyval", | ||
| 1606 | ] | ||
| 1607 | |||
| 1608 | [[package]] | ||
| 1609 | name = "gif" | ||
| 1610 | version = "0.13.3" | ||
| 1611 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1612 | checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" | ||
| 1613 | dependencies = [ | ||
| 1614 | "color_quant", | ||
| 1615 | "weezl", | ||
| 1616 | ] | ||
| 1617 | |||
| 1618 | [[package]] | ||
| 1619 | name = "glob" | ||
| 1620 | version = "0.3.3" | ||
| 1621 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1622 | checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" | ||
| 1623 | |||
| 1624 | [[package]] | ||
| 1625 | name = "group" | ||
| 1626 | version = "0.13.0" | ||
| 1627 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1628 | checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" | ||
| 1629 | dependencies = [ | ||
| 1630 | "ff", | ||
| 1631 | "rand_core 0.6.4", | ||
| 1632 | "subtle", | ||
| 1633 | ] | ||
| 1634 | |||
| 1635 | [[package]] | ||
| 1636 | name = "h2" | ||
| 1637 | version = "0.4.15" | ||
| 1638 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1639 | checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" | ||
| 1640 | dependencies = [ | ||
| 1641 | "atomic-waker", | ||
| 1642 | "bytes", | ||
| 1643 | "fnv", | ||
| 1644 | "futures-core", | ||
| 1645 | "futures-sink", | ||
| 1646 | "http", | ||
| 1647 | "indexmap", | ||
| 1648 | "slab", | ||
| 1649 | "tokio", | ||
| 1650 | "tokio-util", | ||
| 1651 | "tracing", | ||
| 1652 | ] | ||
| 1653 | |||
| 1654 | [[package]] | ||
| 1655 | name = "half" | ||
| 1656 | version = "2.7.1" | ||
| 1657 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1658 | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | ||
| 1659 | dependencies = [ | ||
| 1660 | "cfg-if", | ||
| 1661 | "crunchy", | ||
| 1662 | "zerocopy", | ||
| 1663 | ] | ||
| 1664 | |||
| 1665 | [[package]] | ||
| 1666 | name = "hashbrown" | ||
| 1667 | version = "0.14.5" | ||
| 1668 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1669 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | ||
| 1670 | |||
| 1671 | [[package]] | ||
| 1672 | name = "hashbrown" | ||
| 1673 | version = "0.16.1" | ||
| 1674 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1675 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | ||
| 1676 | dependencies = [ | ||
| 1677 | "allocator-api2", | ||
| 1678 | "equivalent", | ||
| 1679 | "foldhash", | ||
| 1680 | ] | ||
| 1681 | |||
| 1682 | [[package]] | ||
| 1683 | name = "hashbrown" | ||
| 1684 | version = "0.17.1" | ||
| 1685 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1686 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | ||
| 1687 | |||
| 1688 | [[package]] | ||
| 1689 | name = "hashlink" | ||
| 1690 | version = "0.11.1" | ||
| 1691 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1692 | checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" | ||
| 1693 | dependencies = [ | ||
| 1694 | "hashbrown 0.16.1", | ||
| 1695 | ] | ||
| 1696 | |||
| 1697 | [[package]] | ||
| 1698 | name = "hashmap_derive" | ||
| 1699 | version = "0.1.0" | ||
| 1700 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1701 | checksum = "fb30bf173e72cc31b5265dac095423ca14e7789ff7c3b0e6096a37a996f12883" | ||
| 1702 | dependencies = [ | ||
| 1703 | "proc-macro2", | ||
| 1704 | "quote", | ||
| 1705 | "syn 1.0.109", | ||
| 1706 | ] | ||
| 1707 | |||
| 1708 | [[package]] | ||
| 1709 | name = "hax-lib" | ||
| 1710 | version = "0.3.6" | ||
| 1711 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1712 | checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" | ||
| 1713 | dependencies = [ | ||
| 1714 | "hax-lib-macros", | ||
| 1715 | "num-bigint", | ||
| 1716 | "num-traits", | ||
| 1717 | ] | ||
| 1718 | |||
| 1719 | [[package]] | ||
| 1720 | name = "hax-lib-macros" | ||
| 1721 | version = "0.3.6" | ||
| 1722 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1723 | checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" | ||
| 1724 | dependencies = [ | ||
| 1725 | "hax-lib-macros-types", | ||
| 1726 | "proc-macro-error2", | ||
| 1727 | "proc-macro2", | ||
| 1728 | "quote", | ||
| 1729 | "syn 2.0.118", | ||
| 1730 | ] | ||
| 1731 | |||
| 1732 | [[package]] | ||
| 1733 | name = "hax-lib-macros-types" | ||
| 1734 | version = "0.3.6" | ||
| 1735 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1736 | checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" | ||
| 1737 | dependencies = [ | ||
| 1738 | "proc-macro2", | ||
| 1739 | "quote", | ||
| 1740 | "serde", | ||
| 1741 | "serde_json", | ||
| 1742 | "uuid", | ||
| 1743 | ] | ||
| 1744 | |||
| 1745 | [[package]] | ||
| 1746 | name = "heck" | ||
| 1747 | version = "0.3.3" | ||
| 1748 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1749 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" | ||
| 1750 | dependencies = [ | ||
| 1751 | "unicode-segmentation", | ||
| 1752 | ] | ||
| 1753 | |||
| 1754 | [[package]] | ||
| 1755 | name = "heck" | ||
| 1756 | version = "0.5.0" | ||
| 1757 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1758 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | ||
| 1759 | |||
| 1760 | [[package]] | ||
| 1761 | name = "hermit-abi" | ||
| 1762 | version = "0.5.2" | ||
| 1763 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1764 | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | ||
| 1765 | |||
| 1766 | [[package]] | ||
| 1767 | name = "hex" | ||
| 1768 | version = "0.4.3" | ||
| 1769 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1770 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | ||
| 1771 | |||
| 1772 | [[package]] | ||
| 1773 | name = "hex-literal" | ||
| 1774 | version = "0.3.4" | ||
| 1775 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1776 | checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" | ||
| 1777 | |||
| 1778 | [[package]] | ||
| 1779 | name = "hkdf" | ||
| 1780 | version = "0.12.4" | ||
| 1781 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1782 | checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" | ||
| 1783 | dependencies = [ | ||
| 1784 | "hmac 0.12.1", | ||
| 1785 | ] | ||
| 1786 | |||
| 1787 | [[package]] | ||
| 1788 | name = "hkdf" | ||
| 1789 | version = "0.13.0" | ||
| 1790 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1791 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | ||
| 1792 | dependencies = [ | ||
| 1793 | "hmac 0.13.0", | ||
| 1794 | ] | ||
| 1795 | |||
| 1796 | [[package]] | ||
| 1797 | name = "hls_m3u8" | ||
| 1798 | version = "0.4.2" | ||
| 1799 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1800 | checksum = "b906521a5b0e6d2ec07ea0bb855d92a1db30b48812744a645a3b2a1405cb8159" | ||
| 1801 | dependencies = [ | ||
| 1802 | "derive_builder", | ||
| 1803 | "derive_more 0.99.20", | ||
| 1804 | "hex", | ||
| 1805 | "shorthand", | ||
| 1806 | "stable-vec", | ||
| 1807 | "strum", | ||
| 1808 | "thiserror 1.0.69", | ||
| 1809 | ] | ||
| 1810 | |||
| 1811 | [[package]] | ||
| 1812 | name = "hmac" | ||
| 1813 | version = "0.12.1" | ||
| 1814 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1815 | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | ||
| 1816 | dependencies = [ | ||
| 1817 | "digest 0.10.7", | ||
| 1818 | ] | ||
| 1819 | |||
| 1820 | [[package]] | ||
| 1821 | name = "hmac" | ||
| 1822 | version = "0.13.0" | ||
| 1823 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1824 | checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" | ||
| 1825 | dependencies = [ | ||
| 1826 | "digest 0.11.3", | ||
| 1827 | ] | ||
| 1828 | |||
| 1829 | [[package]] | ||
| 1830 | name = "hpke-rs" | ||
| 1831 | version = "0.6.1" | ||
| 1832 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1833 | checksum = "b6ad6a58eb3e0ee30be8bfc7a9770ae98adcfa1d9bc820a5847732ce84f70837" | ||
| 1834 | dependencies = [ | ||
| 1835 | "hpke-rs-crypto", | ||
| 1836 | "hpke-rs-libcrux", | ||
| 1837 | "hpke-rs-rust-crypto", | ||
| 1838 | "libcrux-sha3", | ||
| 1839 | "log", | ||
| 1840 | "rand_core 0.9.5", | ||
| 1841 | "serde", | ||
| 1842 | "subtle", | ||
| 1843 | "tls_codec", | ||
| 1844 | "zeroize", | ||
| 1845 | ] | ||
| 1846 | |||
| 1847 | [[package]] | ||
| 1848 | name = "hpke-rs-crypto" | ||
| 1849 | version = "0.6.1" | ||
| 1850 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1851 | checksum = "0a73a99d9008010d73289f41335a3f6e14fb8c04eaf60e9111b450463b1bbc7f" | ||
| 1852 | dependencies = [ | ||
| 1853 | "rand_core 0.9.5", | ||
| 1854 | "zeroize", | ||
| 1855 | ] | ||
| 1856 | |||
| 1857 | [[package]] | ||
| 1858 | name = "hpke-rs-libcrux" | ||
| 1859 | version = "0.6.1" | ||
| 1860 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1861 | checksum = "c0ce6b7e54aebe540faee869c67ee253bede44ea6cb67c6e72c7847d6c59f1df" | ||
| 1862 | dependencies = [ | ||
| 1863 | "hpke-rs-crypto", | ||
| 1864 | "libcrux-aead", | ||
| 1865 | "libcrux-ecdh", | ||
| 1866 | "libcrux-hkdf", | ||
| 1867 | "libcrux-kem", | ||
| 1868 | "libcrux-traits", | ||
| 1869 | "rand 0.10.1", | ||
| 1870 | "rand_chacha 0.10.0", | ||
| 1871 | "rand_core 0.10.1", | ||
| 1872 | "zeroize", | ||
| 1873 | ] | ||
| 1874 | |||
| 1875 | [[package]] | ||
| 1876 | name = "hpke-rs-rust-crypto" | ||
| 1877 | version = "0.6.1" | ||
| 1878 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1879 | checksum = "14b28be6cba9081c7feda2651d51c2a900029798e78b4c1e093e792f4571a870" | ||
| 1880 | dependencies = [ | ||
| 1881 | "aes-gcm", | ||
| 1882 | "chacha20poly1305", | ||
| 1883 | "hkdf 0.12.4", | ||
| 1884 | "hpke-rs-crypto", | ||
| 1885 | "k256", | ||
| 1886 | "p256", | ||
| 1887 | "p384", | ||
| 1888 | "rand 0.8.6", | ||
| 1889 | "rand_chacha 0.3.1", | ||
| 1890 | "rand_core 0.10.1", | ||
| 1891 | "rand_core 0.6.4", | ||
| 1892 | "sha2 0.10.9", | ||
| 1893 | "subtle", | ||
| 1894 | "x25519-dalek", | ||
| 1895 | "zeroize", | ||
| 1896 | ] | ||
| 1897 | |||
| 1898 | [[package]] | ||
| 1899 | name = "html5ever" | ||
| 1900 | version = "0.39.0" | ||
| 1901 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1902 | checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" | ||
| 1903 | dependencies = [ | ||
| 1904 | "log", | ||
| 1905 | "markup5ever", | ||
| 1906 | ] | ||
| 1907 | |||
| 1908 | [[package]] | ||
| 1909 | name = "http" | ||
| 1910 | version = "1.4.2" | ||
| 1911 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1912 | checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" | ||
| 1913 | dependencies = [ | ||
| 1914 | "bytes", | ||
| 1915 | "itoa", | ||
| 1916 | ] | ||
| 1917 | |||
| 1918 | [[package]] | ||
| 1919 | name = "http-body" | ||
| 1920 | version = "1.0.1" | ||
| 1921 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1922 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" | ||
| 1923 | dependencies = [ | ||
| 1924 | "bytes", | ||
| 1925 | "http", | ||
| 1926 | ] | ||
| 1927 | |||
| 1928 | [[package]] | ||
| 1929 | name = "http-body-util" | ||
| 1930 | version = "0.1.3" | ||
| 1931 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1932 | checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" | ||
| 1933 | dependencies = [ | ||
| 1934 | "bytes", | ||
| 1935 | "futures-core", | ||
| 1936 | "http", | ||
| 1937 | "http-body", | ||
| 1938 | "pin-project-lite", | ||
| 1939 | ] | ||
| 1940 | |||
| 1941 | [[package]] | ||
| 1942 | name = "httparse" | ||
| 1943 | version = "1.10.1" | ||
| 1944 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1945 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | ||
| 1946 | |||
| 1947 | [[package]] | ||
| 1948 | name = "hybrid-array" | ||
| 1949 | version = "0.4.12" | ||
| 1950 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1951 | checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" | ||
| 1952 | dependencies = [ | ||
| 1953 | "typenum", | ||
| 1954 | ] | ||
| 1955 | |||
| 1956 | [[package]] | ||
| 1957 | name = "hyper" | ||
| 1958 | version = "1.10.1" | ||
| 1959 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1960 | checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" | ||
| 1961 | dependencies = [ | ||
| 1962 | "atomic-waker", | ||
| 1963 | "bytes", | ||
| 1964 | "futures-channel", | ||
| 1965 | "futures-core", | ||
| 1966 | "h2", | ||
| 1967 | "http", | ||
| 1968 | "http-body", | ||
| 1969 | "httparse", | ||
| 1970 | "itoa", | ||
| 1971 | "pin-project-lite", | ||
| 1972 | "smallvec", | ||
| 1973 | "tokio", | ||
| 1974 | "want", | ||
| 1975 | ] | ||
| 1976 | |||
| 1977 | [[package]] | ||
| 1978 | name = "hyper-rustls" | ||
| 1979 | version = "0.27.9" | ||
| 1980 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1981 | checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" | ||
| 1982 | dependencies = [ | ||
| 1983 | "http", | ||
| 1984 | "hyper", | ||
| 1985 | "hyper-util", | ||
| 1986 | "rustls 0.23.40", | ||
| 1987 | "tokio", | ||
| 1988 | "tokio-rustls 0.26.4", | ||
| 1989 | "tower-service", | ||
| 1990 | "webpki-roots 1.0.8", | ||
| 1991 | ] | ||
| 1992 | |||
| 1993 | [[package]] | ||
| 1994 | name = "hyper-tls" | ||
| 1995 | version = "0.6.0" | ||
| 1996 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1997 | checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" | ||
| 1998 | dependencies = [ | ||
| 1999 | "bytes", | ||
| 2000 | "http-body-util", | ||
| 2001 | "hyper", | ||
| 2002 | "hyper-util", | ||
| 2003 | "native-tls", | ||
| 2004 | "tokio", | ||
| 2005 | "tokio-native-tls", | ||
| 2006 | "tower-service", | ||
| 2007 | ] | ||
| 2008 | |||
| 2009 | [[package]] | ||
| 2010 | name = "hyper-util" | ||
| 2011 | version = "0.1.20" | ||
| 2012 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2013 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | ||
| 2014 | dependencies = [ | ||
| 2015 | "base64", | ||
| 2016 | "bytes", | ||
| 2017 | "futures-channel", | ||
| 2018 | "futures-util", | ||
| 2019 | "http", | ||
| 2020 | "http-body", | ||
| 2021 | "hyper", | ||
| 2022 | "ipnet", | ||
| 2023 | "libc", | ||
| 2024 | "percent-encoding", | ||
| 2025 | "pin-project-lite", | ||
| 2026 | "socket2 0.6.4", | ||
| 2027 | "system-configuration", | ||
| 2028 | "tokio", | ||
| 2029 | "tower-service", | ||
| 2030 | "tracing", | ||
| 2031 | "windows-registry", | ||
| 2032 | ] | ||
| 2033 | |||
| 2034 | [[package]] | ||
| 2035 | name = "iana-time-zone" | ||
| 2036 | version = "0.1.65" | ||
| 2037 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2038 | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | ||
| 2039 | dependencies = [ | ||
| 2040 | "android_system_properties", | ||
| 2041 | "core-foundation-sys", | ||
| 2042 | "iana-time-zone-haiku", | ||
| 2043 | "js-sys", | ||
| 2044 | "log", | ||
| 2045 | "wasm-bindgen", | ||
| 2046 | "windows-core", | ||
| 2047 | ] | ||
| 2048 | |||
| 2049 | [[package]] | ||
| 2050 | name = "iana-time-zone-haiku" | ||
| 2051 | version = "0.1.2" | ||
| 2052 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2053 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | ||
| 2054 | dependencies = [ | ||
| 2055 | "cc", | ||
| 2056 | ] | ||
| 2057 | |||
| 2058 | [[package]] | ||
| 2059 | name = "icu_collections" | ||
| 2060 | version = "2.2.0" | ||
| 2061 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2062 | checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" | ||
| 2063 | dependencies = [ | ||
| 2064 | "displaydoc", | ||
| 2065 | "potential_utf", | ||
| 2066 | "utf8_iter", | ||
| 2067 | "yoke", | ||
| 2068 | "zerofrom", | ||
| 2069 | "zerovec", | ||
| 2070 | ] | ||
| 2071 | |||
| 2072 | [[package]] | ||
| 2073 | name = "icu_locale_core" | ||
| 2074 | version = "2.2.0" | ||
| 2075 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2076 | checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" | ||
| 2077 | dependencies = [ | ||
| 2078 | "displaydoc", | ||
| 2079 | "litemap", | ||
| 2080 | "tinystr", | ||
| 2081 | "writeable", | ||
| 2082 | "zerovec", | ||
| 2083 | ] | ||
| 2084 | |||
| 2085 | [[package]] | ||
| 2086 | name = "icu_normalizer" | ||
| 2087 | version = "2.2.0" | ||
| 2088 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2089 | checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" | ||
| 2090 | dependencies = [ | ||
| 2091 | "icu_collections", | ||
| 2092 | "icu_normalizer_data", | ||
| 2093 | "icu_properties", | ||
| 2094 | "icu_provider", | ||
| 2095 | "smallvec", | ||
| 2096 | "zerovec", | ||
| 2097 | ] | ||
| 2098 | |||
| 2099 | [[package]] | ||
| 2100 | name = "icu_normalizer_data" | ||
| 2101 | version = "2.2.0" | ||
| 2102 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2103 | checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" | ||
| 2104 | |||
| 2105 | [[package]] | ||
| 2106 | name = "icu_properties" | ||
| 2107 | version = "2.2.0" | ||
| 2108 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2109 | checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" | ||
| 2110 | dependencies = [ | ||
| 2111 | "icu_collections", | ||
| 2112 | "icu_locale_core", | ||
| 2113 | "icu_properties_data", | ||
| 2114 | "icu_provider", | ||
| 2115 | "zerotrie", | ||
| 2116 | "zerovec", | ||
| 2117 | ] | ||
| 2118 | |||
| 2119 | [[package]] | ||
| 2120 | name = "icu_properties_data" | ||
| 2121 | version = "2.2.0" | ||
| 2122 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2123 | checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" | ||
| 2124 | |||
| 2125 | [[package]] | ||
| 2126 | name = "icu_provider" | ||
| 2127 | version = "2.2.0" | ||
| 2128 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2129 | checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" | ||
| 2130 | dependencies = [ | ||
| 2131 | "displaydoc", | ||
| 2132 | "icu_locale_core", | ||
| 2133 | "writeable", | ||
| 2134 | "yoke", | ||
| 2135 | "zerofrom", | ||
| 2136 | "zerotrie", | ||
| 2137 | "zerovec", | ||
| 2138 | ] | ||
| 2139 | |||
| 2140 | [[package]] | ||
| 2141 | name = "ident_case" | ||
| 2142 | version = "1.0.1" | ||
| 2143 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2144 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | ||
| 2145 | |||
| 2146 | [[package]] | ||
| 2147 | name = "idna" | ||
| 2148 | version = "1.1.0" | ||
| 2149 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2150 | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | ||
| 2151 | dependencies = [ | ||
| 2152 | "idna_adapter", | ||
| 2153 | "smallvec", | ||
| 2154 | "utf8_iter", | ||
| 2155 | ] | ||
| 2156 | |||
| 2157 | [[package]] | ||
| 2158 | name = "idna_adapter" | ||
| 2159 | version = "1.2.2" | ||
| 2160 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2161 | checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" | ||
| 2162 | dependencies = [ | ||
| 2163 | "icu_normalizer", | ||
| 2164 | "icu_properties", | ||
| 2165 | ] | ||
| 2166 | |||
| 2167 | [[package]] | ||
| 2168 | name = "image" | ||
| 2169 | version = "0.25.8" | ||
| 2170 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2171 | checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" | ||
| 2172 | dependencies = [ | ||
| 2173 | "bytemuck", | ||
| 2174 | "byteorder-lite", | ||
| 2175 | "color_quant", | ||
| 2176 | "exr", | ||
| 2177 | "gif", | ||
| 2178 | "image-webp", | ||
| 2179 | "moxcms", | ||
| 2180 | "num-traits", | ||
| 2181 | "png", | ||
| 2182 | "qoi", | ||
| 2183 | "ravif", | ||
| 2184 | "rayon", | ||
| 2185 | "rgb", | ||
| 2186 | "tiff", | ||
| 2187 | "zune-core", | ||
| 2188 | "zune-jpeg", | ||
| 2189 | ] | ||
| 2190 | |||
| 2191 | [[package]] | ||
| 2192 | name = "image-webp" | ||
| 2193 | version = "0.2.4" | ||
| 2194 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2195 | checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" | ||
| 2196 | dependencies = [ | ||
| 2197 | "byteorder-lite", | ||
| 2198 | "quick-error", | ||
| 2199 | ] | ||
| 2200 | |||
| 2201 | [[package]] | ||
| 2202 | name = "imageproc" | ||
| 2203 | version = "0.25.1" | ||
| 2204 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2205 | checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" | ||
| 2206 | dependencies = [ | ||
| 2207 | "ab_glyph", | ||
| 2208 | "approx", | ||
| 2209 | "getrandom 0.2.17", | ||
| 2210 | "image", | ||
| 2211 | "itertools", | ||
| 2212 | "nalgebra", | ||
| 2213 | "num", | ||
| 2214 | "rand 0.8.6", | ||
| 2215 | "rand_distr", | ||
| 2216 | "rayon", | ||
| 2217 | ] | ||
| 2218 | |||
| 2219 | [[package]] | ||
| 2220 | name = "imgref" | ||
| 2221 | version = "1.12.2" | ||
| 2222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2223 | checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" | ||
| 2224 | |||
| 2225 | [[package]] | ||
| 2226 | name = "indexmap" | ||
| 2227 | version = "2.14.0" | ||
| 2228 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2229 | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | ||
| 2230 | dependencies = [ | ||
| 2231 | "equivalent", | ||
| 2232 | "hashbrown 0.17.1", | ||
| 2233 | ] | ||
| 2234 | |||
| 2235 | [[package]] | ||
| 2236 | name = "inout" | ||
| 2237 | version = "0.1.4" | ||
| 2238 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2239 | checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" | ||
| 2240 | dependencies = [ | ||
| 2241 | "generic-array", | ||
| 2242 | ] | ||
| 2243 | |||
| 2244 | [[package]] | ||
| 2245 | name = "instant" | ||
| 2246 | version = "0.1.13" | ||
| 2247 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2248 | checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" | ||
| 2249 | dependencies = [ | ||
| 2250 | "cfg-if", | ||
| 2251 | "js-sys", | ||
| 2252 | "wasm-bindgen", | ||
| 2253 | "web-sys", | ||
| 2254 | ] | ||
| 2255 | |||
| 2256 | [[package]] | ||
| 2257 | name = "interpolate_name" | ||
| 2258 | version = "0.2.4" | ||
| 2259 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2260 | checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" | ||
| 2261 | dependencies = [ | ||
| 2262 | "proc-macro2", | ||
| 2263 | "quote", | ||
| 2264 | "syn 2.0.118", | ||
| 2265 | ] | ||
| 2266 | |||
| 2267 | [[package]] | ||
| 2268 | name = "ipnet" | ||
| 2269 | version = "2.12.0" | ||
| 2270 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2271 | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | ||
| 2272 | |||
| 2273 | [[package]] | ||
| 2274 | name = "itertools" | ||
| 2275 | version = "0.12.1" | ||
| 2276 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2277 | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" | ||
| 2278 | dependencies = [ | ||
| 2279 | "either", | ||
| 2280 | ] | ||
| 2281 | |||
| 2282 | [[package]] | ||
| 2283 | name = "itoa" | ||
| 2284 | version = "1.0.18" | ||
| 2285 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2286 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | ||
| 2287 | |||
| 2288 | [[package]] | ||
| 2289 | name = "jobserver" | ||
| 2290 | version = "0.1.34" | ||
| 2291 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2292 | checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" | ||
| 2293 | dependencies = [ | ||
| 2294 | "getrandom 0.3.4", | ||
| 2295 | "libc", | ||
| 2296 | ] | ||
| 2297 | |||
| 2298 | [[package]] | ||
| 2299 | name = "js-sys" | ||
| 2300 | version = "0.3.102" | ||
| 2301 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2302 | checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" | ||
| 2303 | dependencies = [ | ||
| 2304 | "cfg-if", | ||
| 2305 | "futures-util", | ||
| 2306 | "wasm-bindgen", | ||
| 2307 | ] | ||
| 2308 | |||
| 2309 | [[package]] | ||
| 2310 | name = "k256" | ||
| 2311 | version = "0.13.4" | ||
| 2312 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2313 | checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" | ||
| 2314 | dependencies = [ | ||
| 2315 | "cfg-if", | ||
| 2316 | "elliptic-curve", | ||
| 2317 | ] | ||
| 2318 | |||
| 2319 | [[package]] | ||
| 2320 | name = "lazy_static" | ||
| 2321 | version = "1.5.0" | ||
| 2322 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2323 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | ||
| 2324 | |||
| 2325 | [[package]] | ||
| 2326 | name = "lebe" | ||
| 2327 | version = "0.5.3" | ||
| 2328 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2329 | checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" | ||
| 2330 | |||
| 2331 | [[package]] | ||
| 2332 | name = "levenshtein" | ||
| 2333 | version = "1.0.5" | ||
| 2334 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2335 | checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" | ||
| 2336 | |||
| 2337 | [[package]] | ||
| 2338 | name = "libc" | ||
| 2339 | version = "0.2.186" | ||
| 2340 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2341 | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | ||
| 2342 | |||
| 2343 | [[package]] | ||
| 2344 | name = "libcrux-aead" | ||
| 2345 | version = "0.0.7" | ||
| 2346 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2347 | checksum = "13297ce29869a5c0edab0378837b0fc5f88bf99a843712d9201c3b1150b3b476" | ||
| 2348 | dependencies = [ | ||
| 2349 | "libcrux-aesgcm", | ||
| 2350 | "libcrux-chacha20poly1305", | ||
| 2351 | "libcrux-secrets", | ||
| 2352 | "libcrux-traits", | ||
| 2353 | ] | ||
| 2354 | |||
| 2355 | [[package]] | ||
| 2356 | name = "libcrux-aesgcm" | ||
| 2357 | version = "0.0.7" | ||
| 2358 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2359 | checksum = "99f2a019dab4097585a7d4f5b9deebe46cd1e628b16a5bc4cb0ce35e1da334e6" | ||
| 2360 | dependencies = [ | ||
| 2361 | "libcrux-intrinsics", | ||
| 2362 | "libcrux-platform", | ||
| 2363 | "libcrux-secrets", | ||
| 2364 | "libcrux-traits", | ||
| 2365 | ] | ||
| 2366 | |||
| 2367 | [[package]] | ||
| 2368 | name = "libcrux-chacha20poly1305" | ||
| 2369 | version = "0.0.7" | ||
| 2370 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2371 | checksum = "cc08d044676af21343b32b988411fa98dbb5cf65a03c9df478ced221bbdfdb1b" | ||
| 2372 | dependencies = [ | ||
| 2373 | "libcrux-hacl-rs", | ||
| 2374 | "libcrux-macros", | ||
| 2375 | "libcrux-poly1305", | ||
| 2376 | "libcrux-secrets", | ||
| 2377 | "libcrux-traits", | ||
| 2378 | ] | ||
| 2379 | |||
| 2380 | [[package]] | ||
| 2381 | name = "libcrux-curve25519" | ||
| 2382 | version = "0.0.6" | ||
| 2383 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2384 | checksum = "bb1e5fd8476a6ed609d24ef42aee5ab6f99f7c65d054f92412da9f499e423299" | ||
| 2385 | dependencies = [ | ||
| 2386 | "libcrux-hacl-rs", | ||
| 2387 | "libcrux-macros", | ||
| 2388 | "libcrux-secrets", | ||
| 2389 | "libcrux-traits", | ||
| 2390 | ] | ||
| 2391 | |||
| 2392 | [[package]] | ||
| 2393 | name = "libcrux-ecdh" | ||
| 2394 | version = "0.0.6" | ||
| 2395 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2396 | checksum = "b65f73ce79337c762eb38bbac91e4c9b9e60cf318e8501b812750c640814d45e" | ||
| 2397 | dependencies = [ | ||
| 2398 | "libcrux-curve25519", | ||
| 2399 | "libcrux-p256", | ||
| 2400 | "rand 0.9.4", | ||
| 2401 | ] | ||
| 2402 | |||
| 2403 | [[package]] | ||
| 2404 | name = "libcrux-hacl-rs" | ||
| 2405 | version = "0.0.4" | ||
| 2406 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2407 | checksum = "2637dc87d158e1f1b550fd9b226443e84153fded4de69028d897b534d16d22e6" | ||
| 2408 | dependencies = [ | ||
| 2409 | "libcrux-macros", | ||
| 2410 | ] | ||
| 2411 | |||
| 2412 | [[package]] | ||
| 2413 | name = "libcrux-hkdf" | ||
| 2414 | version = "0.0.6" | ||
| 2415 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2416 | checksum = "8c1a89ca0c89be3a268a921e47105fb7873badf7267f5e3ebf4ea46baedd73ef" | ||
| 2417 | dependencies = [ | ||
| 2418 | "libcrux-hacl-rs", | ||
| 2419 | "libcrux-hmac", | ||
| 2420 | "libcrux-secrets", | ||
| 2421 | ] | ||
| 2422 | |||
| 2423 | [[package]] | ||
| 2424 | name = "libcrux-hmac" | ||
| 2425 | version = "0.0.6" | ||
| 2426 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2427 | checksum = "8a7a242707d65960770bd7e14e4f18a92bdf0b967777dd404887db8d087a643b" | ||
| 2428 | dependencies = [ | ||
| 2429 | "libcrux-hacl-rs", | ||
| 2430 | "libcrux-macros", | ||
| 2431 | "libcrux-sha2", | ||
| 2432 | ] | ||
| 2433 | |||
| 2434 | [[package]] | ||
| 2435 | name = "libcrux-intrinsics" | ||
| 2436 | version = "0.0.6" | ||
| 2437 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2438 | checksum = "b1b5db005ff8001e026b73a6842ee81bbef8ec5ff0e1915a67ae65fd2a9fafa5" | ||
| 2439 | dependencies = [ | ||
| 2440 | "core-models", | ||
| 2441 | "hax-lib", | ||
| 2442 | ] | ||
| 2443 | |||
| 2444 | [[package]] | ||
| 2445 | name = "libcrux-kem" | ||
| 2446 | version = "0.0.7" | ||
| 2447 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2448 | checksum = "12631592f491d22fd1a176d32b2c6edfb673998fd3987e9d95f8fa79ad2a737b" | ||
| 2449 | dependencies = [ | ||
| 2450 | "libcrux-curve25519", | ||
| 2451 | "libcrux-ecdh", | ||
| 2452 | "libcrux-ml-kem", | ||
| 2453 | "libcrux-p256", | ||
| 2454 | "libcrux-sha3", | ||
| 2455 | "libcrux-traits", | ||
| 2456 | "rand 0.9.4", | ||
| 2457 | ] | ||
| 2458 | |||
| 2459 | [[package]] | ||
| 2460 | name = "libcrux-macros" | ||
| 2461 | version = "0.0.3" | ||
| 2462 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2463 | checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" | ||
| 2464 | dependencies = [ | ||
| 2465 | "quote", | ||
| 2466 | "syn 2.0.118", | ||
| 2467 | ] | ||
| 2468 | |||
| 2469 | [[package]] | ||
| 2470 | name = "libcrux-ml-kem" | ||
| 2471 | version = "0.0.8" | ||
| 2472 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2473 | checksum = "a14ab3e477de9df6ee1273a114018ff62c4996ca9220070c4e5cb1743f94a67d" | ||
| 2474 | dependencies = [ | ||
| 2475 | "hax-lib", | ||
| 2476 | "libcrux-intrinsics", | ||
| 2477 | "libcrux-platform", | ||
| 2478 | "libcrux-secrets", | ||
| 2479 | "libcrux-sha3", | ||
| 2480 | "libcrux-traits", | ||
| 2481 | "rand 0.9.4", | ||
| 2482 | "tls_codec", | ||
| 2483 | ] | ||
| 2484 | |||
| 2485 | [[package]] | ||
| 2486 | name = "libcrux-p256" | ||
| 2487 | version = "0.0.6" | ||
| 2488 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2489 | checksum = "f4778ba25cb08bb8a96bd100e19ed9aecf78337198fd176036e21042b2dd99bc" | ||
| 2490 | dependencies = [ | ||
| 2491 | "libcrux-hacl-rs", | ||
| 2492 | "libcrux-macros", | ||
| 2493 | "libcrux-secrets", | ||
| 2494 | "libcrux-sha2", | ||
| 2495 | "libcrux-traits", | ||
| 2496 | ] | ||
| 2497 | |||
| 2498 | [[package]] | ||
| 2499 | name = "libcrux-platform" | ||
| 2500 | version = "0.0.3" | ||
| 2501 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2502 | checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" | ||
| 2503 | dependencies = [ | ||
| 2504 | "libc", | ||
| 2505 | ] | ||
| 2506 | |||
| 2507 | [[package]] | ||
| 2508 | name = "libcrux-poly1305" | ||
| 2509 | version = "0.0.5" | ||
| 2510 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2511 | checksum = "02491808ee5b9db8cb65fad64ae0be812db64beef179d945c00c7787dc7dfcf9" | ||
| 2512 | dependencies = [ | ||
| 2513 | "libcrux-hacl-rs", | ||
| 2514 | "libcrux-macros", | ||
| 2515 | ] | ||
| 2516 | |||
| 2517 | [[package]] | ||
| 2518 | name = "libcrux-secrets" | ||
| 2519 | version = "0.0.5" | ||
| 2520 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2521 | checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" | ||
| 2522 | dependencies = [ | ||
| 2523 | "hax-lib", | ||
| 2524 | ] | ||
| 2525 | |||
| 2526 | [[package]] | ||
| 2527 | name = "libcrux-sha2" | ||
| 2528 | version = "0.0.6" | ||
| 2529 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2530 | checksum = "e9d253473f259fc74a280c43f29c464f7e374abdf28b4942234dc707f529d4b7" | ||
| 2531 | dependencies = [ | ||
| 2532 | "libcrux-hacl-rs", | ||
| 2533 | "libcrux-macros", | ||
| 2534 | "libcrux-traits", | ||
| 2535 | ] | ||
| 2536 | |||
| 2537 | [[package]] | ||
| 2538 | name = "libcrux-sha3" | ||
| 2539 | version = "0.0.8" | ||
| 2540 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2541 | checksum = "b1ae0b7d0e1cc4793a609fd0ff2ca3b3a3fabae523770c619a3d4bc86417b0d7" | ||
| 2542 | dependencies = [ | ||
| 2543 | "hax-lib", | ||
| 2544 | "libcrux-intrinsics", | ||
| 2545 | "libcrux-platform", | ||
| 2546 | "libcrux-traits", | ||
| 2547 | ] | ||
| 2548 | |||
| 2549 | [[package]] | ||
| 2550 | name = "libcrux-traits" | ||
| 2551 | version = "0.0.6" | ||
| 2552 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2553 | checksum = "812e4fa89f3f5e34b47f928b22b1b78395a0d4ec23b1f583db635f128159d65f" | ||
| 2554 | dependencies = [ | ||
| 2555 | "libcrux-secrets", | ||
| 2556 | "rand 0.9.4", | ||
| 2557 | ] | ||
| 2558 | |||
| 2559 | [[package]] | ||
| 2560 | name = "libfuzzer-sys" | ||
| 2561 | version = "0.4.13" | ||
| 2562 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2563 | checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" | ||
| 2564 | dependencies = [ | ||
| 2565 | "arbitrary", | ||
| 2566 | "cc", | ||
| 2567 | ] | ||
| 2568 | |||
| 2569 | [[package]] | ||
| 2570 | name = "libm" | ||
| 2571 | version = "0.2.16" | ||
| 2572 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2573 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | ||
| 2574 | |||
| 2575 | [[package]] | ||
| 2576 | name = "libopus_sys" | ||
| 2577 | version = "0.3.3" | ||
| 2578 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2579 | checksum = "b81c32f233fb2507347a93f97b9919493be9db7ec2249ce2c3ed0c89ad8edf60" | ||
| 2580 | dependencies = [ | ||
| 2581 | "cmake", | ||
| 2582 | "log", | ||
| 2583 | "pkg-config", | ||
| 2584 | ] | ||
| 2585 | |||
| 2586 | [[package]] | ||
| 2587 | name = "libsqlite3-sys" | ||
| 2588 | version = "0.30.1" | ||
| 2589 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2590 | checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" | ||
| 2591 | dependencies = [ | ||
| 2592 | "cc", | ||
| 2593 | "pkg-config", | ||
| 2594 | "vcpkg", | ||
| 2595 | ] | ||
| 2596 | |||
| 2597 | [[package]] | ||
| 2598 | name = "linux-raw-sys" | ||
| 2599 | version = "0.12.1" | ||
| 2600 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2601 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | ||
| 2602 | |||
| 2603 | [[package]] | ||
| 2604 | name = "litemap" | ||
| 2605 | version = "0.8.2" | ||
| 2606 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2607 | checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" | ||
| 2608 | |||
| 2609 | [[package]] | ||
| 2610 | name = "lock_api" | ||
| 2611 | version = "0.4.14" | ||
| 2612 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2613 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | ||
| 2614 | dependencies = [ | ||
| 2615 | "scopeguard", | ||
| 2616 | ] | ||
| 2617 | |||
| 2618 | [[package]] | ||
| 2619 | name = "log" | ||
| 2620 | version = "0.4.32" | ||
| 2621 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2622 | checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" | ||
| 2623 | |||
| 2624 | [[package]] | ||
| 2625 | name = "loom" | ||
| 2626 | version = "0.5.6" | ||
| 2627 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2628 | checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" | ||
| 2629 | dependencies = [ | ||
| 2630 | "cfg-if", | ||
| 2631 | "generator", | ||
| 2632 | "scoped-tls", | ||
| 2633 | "serde", | ||
| 2634 | "serde_json", | ||
| 2635 | "tracing", | ||
| 2636 | "tracing-subscriber", | ||
| 2637 | ] | ||
| 2638 | |||
| 2639 | [[package]] | ||
| 2640 | name = "loop9" | ||
| 2641 | version = "0.1.5" | ||
| 2642 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2643 | checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" | ||
| 2644 | dependencies = [ | ||
| 2645 | "imgref", | ||
| 2646 | ] | ||
| 2647 | |||
| 2648 | [[package]] | ||
| 2649 | name = "lru-slab" | ||
| 2650 | version = "0.1.2" | ||
| 2651 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2652 | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" | ||
| 2653 | |||
| 2654 | [[package]] | ||
| 2655 | name = "markup5ever" | ||
| 2656 | version = "0.39.0" | ||
| 2657 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2658 | checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" | ||
| 2659 | dependencies = [ | ||
| 2660 | "log", | ||
| 2661 | "tendril", | ||
| 2662 | "web_atoms", | ||
| 2663 | ] | ||
| 2664 | |||
| 2665 | [[package]] | ||
| 2666 | name = "matchers" | ||
| 2667 | version = "0.2.0" | ||
| 2668 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2669 | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" | ||
| 2670 | dependencies = [ | ||
| 2671 | "regex-automata", | ||
| 2672 | ] | ||
| 2673 | |||
| 2674 | [[package]] | ||
| 2675 | name = "matrixmultiply" | ||
| 2676 | version = "0.3.10" | ||
| 2677 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2678 | checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" | ||
| 2679 | dependencies = [ | ||
| 2680 | "autocfg", | ||
| 2681 | "rawpointer", | ||
| 2682 | ] | ||
| 2683 | |||
| 2684 | [[package]] | ||
| 2685 | name = "maybe-rayon" | ||
| 2686 | version = "0.1.1" | ||
| 2687 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2688 | checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" | ||
| 2689 | dependencies = [ | ||
| 2690 | "cfg-if", | ||
| 2691 | "rayon", | ||
| 2692 | ] | ||
| 2693 | |||
| 2694 | [[package]] | ||
| 2695 | name = "md-5" | ||
| 2696 | version = "0.11.0" | ||
| 2697 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2698 | checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" | ||
| 2699 | dependencies = [ | ||
| 2700 | "cfg-if", | ||
| 2701 | "digest 0.11.3", | ||
| 2702 | ] | ||
| 2703 | |||
| 2704 | [[package]] | ||
| 2705 | name = "memchr" | ||
| 2706 | version = "2.8.2" | ||
| 2707 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2708 | checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" | ||
| 2709 | |||
| 2710 | [[package]] | ||
| 2711 | name = "mime" | ||
| 2712 | version = "0.3.17" | ||
| 2713 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2714 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | ||
| 2715 | |||
| 2716 | [[package]] | ||
| 2717 | name = "mime_guess" | ||
| 2718 | version = "2.0.5" | ||
| 2719 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2720 | checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" | ||
| 2721 | dependencies = [ | ||
| 2722 | "mime", | ||
| 2723 | "unicase", | ||
| 2724 | ] | ||
| 2725 | |||
| 2726 | [[package]] | ||
| 2727 | name = "mini-moka" | ||
| 2728 | version = "0.10.3" | ||
| 2729 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2730 | checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" | ||
| 2731 | dependencies = [ | ||
| 2732 | "crossbeam-channel", | ||
| 2733 | "crossbeam-utils", | ||
| 2734 | "dashmap 5.5.3", | ||
| 2735 | "skeptic", | ||
| 2736 | "smallvec", | ||
| 2737 | "tagptr", | ||
| 2738 | "triomphe", | ||
| 2739 | ] | ||
| 2740 | |||
| 2741 | [[package]] | ||
| 2742 | name = "miniz_oxide" | ||
| 2743 | version = "0.8.9" | ||
| 2744 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2745 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | ||
| 2746 | dependencies = [ | ||
| 2747 | "adler2", | ||
| 2748 | "simd-adler32", | ||
| 2749 | ] | ||
| 2750 | |||
| 2751 | [[package]] | ||
| 2752 | name = "mio" | ||
| 2753 | version = "1.2.1" | ||
| 2754 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2755 | checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" | ||
| 2756 | dependencies = [ | ||
| 2757 | "libc", | ||
| 2758 | "wasi", | ||
| 2759 | "windows-sys 0.61.2", | ||
| 2760 | ] | ||
| 2761 | |||
| 2762 | [[package]] | ||
| 2763 | name = "moxcms" | ||
| 2764 | version = "0.7.11" | ||
| 2765 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2766 | checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" | ||
| 2767 | dependencies = [ | ||
| 2768 | "num-traits", | ||
| 2769 | "pxfm", | ||
| 2770 | ] | ||
| 2771 | |||
| 2772 | [[package]] | ||
| 2773 | name = "multimap" | ||
| 2774 | version = "0.10.1" | ||
| 2775 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2776 | checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" | ||
| 2777 | dependencies = [ | ||
| 2778 | "serde", | ||
| 2779 | ] | ||
| 2780 | |||
| 2781 | [[package]] | ||
| 2782 | name = "nalgebra" | ||
| 2783 | version = "0.32.6" | ||
| 2784 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2785 | checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" | ||
| 2786 | dependencies = [ | ||
| 2787 | "approx", | ||
| 2788 | "matrixmultiply", | ||
| 2789 | "num-complex", | ||
| 2790 | "num-rational", | ||
| 2791 | "num-traits", | ||
| 2792 | "simba", | ||
| 2793 | "typenum", | ||
| 2794 | ] | ||
| 2795 | |||
| 2796 | [[package]] | ||
| 2797 | name = "nanorand" | ||
| 2798 | version = "0.7.0" | ||
| 2799 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2800 | checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" | ||
| 2801 | dependencies = [ | ||
| 2802 | "getrandom 0.2.17", | ||
| 2803 | ] | ||
| 2804 | |||
| 2805 | [[package]] | ||
| 2806 | name = "native-tls" | ||
| 2807 | version = "0.2.18" | ||
| 2808 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2809 | checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" | ||
| 2810 | dependencies = [ | ||
| 2811 | "libc", | ||
| 2812 | "log", | ||
| 2813 | "openssl", | ||
| 2814 | "openssl-probe", | ||
| 2815 | "openssl-sys", | ||
| 2816 | "schannel", | ||
| 2817 | "security-framework", | ||
| 2818 | "security-framework-sys", | ||
| 2819 | "tempfile", | ||
| 2820 | ] | ||
| 2821 | |||
| 2822 | [[package]] | ||
| 2823 | name = "new_debug_unreachable" | ||
| 2824 | version = "1.0.6" | ||
| 2825 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2826 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | ||
| 2827 | |||
| 2828 | [[package]] | ||
| 2829 | name = "no-std-net" | ||
| 2830 | version = "0.6.0" | ||
| 2831 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2832 | checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" | ||
| 2833 | |||
| 2834 | [[package]] | ||
| 2835 | name = "nohash-hasher" | ||
| 2836 | version = "0.2.0" | ||
| 2837 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2838 | checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" | ||
| 2839 | |||
| 2840 | [[package]] | ||
| 2841 | name = "nom" | ||
| 2842 | version = "8.0.0" | ||
| 2843 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2844 | checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" | ||
| 2845 | dependencies = [ | ||
| 2846 | "memchr", | ||
| 2847 | ] | ||
| 2848 | |||
| 2849 | [[package]] | ||
| 2850 | name = "nonmax" | ||
| 2851 | version = "0.5.5" | ||
| 2852 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2853 | checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" | ||
| 2854 | |||
| 2855 | [[package]] | ||
| 2856 | name = "noop_proc_macro" | ||
| 2857 | version = "0.3.0" | ||
| 2858 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2859 | checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" | ||
| 2860 | |||
| 2861 | [[package]] | ||
| 2862 | name = "nu-ansi-term" | ||
| 2863 | version = "0.50.3" | ||
| 2864 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2865 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | ||
| 2866 | dependencies = [ | ||
| 2867 | "windows-sys 0.61.2", | ||
| 2868 | ] | ||
| 2869 | |||
| 2870 | [[package]] | ||
| 2871 | name = "num" | ||
| 2872 | version = "0.4.3" | ||
| 2873 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2874 | checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" | ||
| 2875 | dependencies = [ | ||
| 2876 | "num-bigint", | ||
| 2877 | "num-complex", | ||
| 2878 | "num-integer", | ||
| 2879 | "num-iter", | ||
| 2880 | "num-rational", | ||
| 2881 | "num-traits", | ||
| 2882 | ] | ||
| 2883 | |||
| 2884 | [[package]] | ||
| 2885 | name = "num-bigint" | ||
| 2886 | version = "0.4.6" | ||
| 2887 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2888 | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" | ||
| 2889 | dependencies = [ | ||
| 2890 | "num-integer", | ||
| 2891 | "num-traits", | ||
| 2892 | ] | ||
| 2893 | |||
| 2894 | [[package]] | ||
| 2895 | name = "num-complex" | ||
| 2896 | version = "0.4.6" | ||
| 2897 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2898 | checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" | ||
| 2899 | dependencies = [ | ||
| 2900 | "num-traits", | ||
| 2901 | ] | ||
| 2902 | |||
| 2903 | [[package]] | ||
| 2904 | name = "num-conv" | ||
| 2905 | version = "0.2.2" | ||
| 2906 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2907 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | ||
| 2908 | |||
| 2909 | [[package]] | ||
| 2910 | name = "num-derive" | ||
| 2911 | version = "0.4.2" | ||
| 2912 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2913 | checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" | ||
| 2914 | dependencies = [ | ||
| 2915 | "proc-macro2", | ||
| 2916 | "quote", | ||
| 2917 | "syn 2.0.118", | ||
| 2918 | ] | ||
| 2919 | |||
| 2920 | [[package]] | ||
| 2921 | name = "num-integer" | ||
| 2922 | version = "0.1.46" | ||
| 2923 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2924 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" | ||
| 2925 | dependencies = [ | ||
| 2926 | "num-traits", | ||
| 2927 | ] | ||
| 2928 | |||
| 2929 | [[package]] | ||
| 2930 | name = "num-iter" | ||
| 2931 | version = "0.1.45" | ||
| 2932 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2933 | checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" | ||
| 2934 | dependencies = [ | ||
| 2935 | "autocfg", | ||
| 2936 | "num-integer", | ||
| 2937 | "num-traits", | ||
| 2938 | ] | ||
| 2939 | |||
| 2940 | [[package]] | ||
| 2941 | name = "num-rational" | ||
| 2942 | version = "0.4.2" | ||
| 2943 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2944 | checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" | ||
| 2945 | dependencies = [ | ||
| 2946 | "num-bigint", | ||
| 2947 | "num-integer", | ||
| 2948 | "num-traits", | ||
| 2949 | ] | ||
| 2950 | |||
| 2951 | [[package]] | ||
| 2952 | name = "num-traits" | ||
| 2953 | version = "0.2.19" | ||
| 2954 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2955 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | ||
| 2956 | dependencies = [ | ||
| 2957 | "autocfg", | ||
| 2958 | "libm", | ||
| 2959 | ] | ||
| 2960 | |||
| 2961 | [[package]] | ||
| 2962 | name = "num_cpus" | ||
| 2963 | version = "1.17.0" | ||
| 2964 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2965 | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | ||
| 2966 | dependencies = [ | ||
| 2967 | "hermit-abi", | ||
| 2968 | "libc", | ||
| 2969 | ] | ||
| 2970 | |||
| 2971 | [[package]] | ||
| 2972 | name = "once_cell" | ||
| 2973 | version = "1.21.4" | ||
| 2974 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2975 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | ||
| 2976 | |||
| 2977 | [[package]] | ||
| 2978 | name = "opaque-debug" | ||
| 2979 | version = "0.3.1" | ||
| 2980 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2981 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | ||
| 2982 | |||
| 2983 | [[package]] | ||
| 2984 | name = "openmls" | ||
| 2985 | version = "0.8.1" | ||
| 2986 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2987 | checksum = "dcb512bfe6a55777518853ea535c6241f069cb0e8984678c117151d2a1e7e903" | ||
| 2988 | dependencies = [ | ||
| 2989 | "fluvio-wasm-timer", | ||
| 2990 | "getrandom 0.3.4", | ||
| 2991 | "log", | ||
| 2992 | "openmls_traits", | ||
| 2993 | "rayon", | ||
| 2994 | "serde", | ||
| 2995 | "serde_bytes", | ||
| 2996 | "thiserror 2.0.18", | ||
| 2997 | "tls_codec", | ||
| 2998 | "zeroize", | ||
| 2999 | ] | ||
| 3000 | |||
| 3001 | [[package]] | ||
| 3002 | name = "openmls_basic_credential" | ||
| 3003 | version = "0.5.0" | ||
| 3004 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3005 | checksum = "983e8be1457dd6f316f409292cec334af3b57b49a19deadc925c83c3c35e15b6" | ||
| 3006 | dependencies = [ | ||
| 3007 | "ed25519-dalek", | ||
| 3008 | "openmls_traits", | ||
| 3009 | "p256", | ||
| 3010 | "rand 0.8.6", | ||
| 3011 | "serde", | ||
| 3012 | "tls_codec", | ||
| 3013 | ] | ||
| 3014 | |||
| 3015 | [[package]] | ||
| 3016 | name = "openmls_memory_storage" | ||
| 3017 | version = "0.5.0" | ||
| 3018 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3019 | checksum = "1a52c927ddb9940acb96d51aebd54b8b9c601c7119e6609622fb3f2cbe16abe3" | ||
| 3020 | dependencies = [ | ||
| 3021 | "log", | ||
| 3022 | "openmls_traits", | ||
| 3023 | "serde", | ||
| 3024 | "serde_json", | ||
| 3025 | "thiserror 2.0.18", | ||
| 3026 | ] | ||
| 3027 | |||
| 3028 | [[package]] | ||
| 3029 | name = "openmls_rust_crypto" | ||
| 3030 | version = "0.5.1" | ||
| 3031 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3032 | checksum = "fafcc8a3552b10fbb3ab757cccaf1a34081e826ca819f49aa7e6645b1d95c00f" | ||
| 3033 | dependencies = [ | ||
| 3034 | "aes-gcm", | ||
| 3035 | "chacha20poly1305", | ||
| 3036 | "ed25519-dalek", | ||
| 3037 | "hkdf 0.12.4", | ||
| 3038 | "hmac 0.12.1", | ||
| 3039 | "hpke-rs", | ||
| 3040 | "hpke-rs-crypto", | ||
| 3041 | "hpke-rs-rust-crypto", | ||
| 3042 | "openmls_memory_storage", | ||
| 3043 | "openmls_traits", | ||
| 3044 | "p256", | ||
| 3045 | "rand 0.8.6", | ||
| 3046 | "rand_chacha 0.3.1", | ||
| 3047 | "serde", | ||
| 3048 | "sha2 0.10.9", | ||
| 3049 | "thiserror 2.0.18", | ||
| 3050 | "tls_codec", | ||
| 3051 | ] | ||
| 3052 | |||
| 3053 | [[package]] | ||
| 3054 | name = "openmls_traits" | ||
| 3055 | version = "0.5.0" | ||
| 3056 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3057 | checksum = "4f88ccdd53448dfdbfa5b8da8ba4e527c418fdb966418172bace2e3b41eedd56" | ||
| 3058 | dependencies = [ | ||
| 3059 | "serde", | ||
| 3060 | "tls_codec", | ||
| 3061 | ] | ||
| 3062 | |||
| 3063 | [[package]] | ||
| 3064 | name = "openssl" | ||
| 3065 | version = "0.10.81" | ||
| 3066 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3067 | checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" | ||
| 3068 | dependencies = [ | ||
| 3069 | "bitflags 2.13.0", | ||
| 3070 | "cfg-if", | ||
| 3071 | "foreign-types", | ||
| 3072 | "libc", | ||
| 3073 | "openssl-macros", | ||
| 3074 | "openssl-sys", | ||
| 3075 | ] | ||
| 3076 | |||
| 3077 | [[package]] | ||
| 3078 | name = "openssl-macros" | ||
| 3079 | version = "0.1.1" | ||
| 3080 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3081 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | ||
| 3082 | dependencies = [ | ||
| 3083 | "proc-macro2", | ||
| 3084 | "quote", | ||
| 3085 | "syn 2.0.118", | ||
| 3086 | ] | ||
| 3087 | |||
| 3088 | [[package]] | ||
| 3089 | name = "openssl-probe" | ||
| 3090 | version = "0.2.1" | ||
| 3091 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3092 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | ||
| 3093 | |||
| 3094 | [[package]] | ||
| 3095 | name = "openssl-sys" | ||
| 3096 | version = "0.9.117" | ||
| 3097 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3098 | checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" | ||
| 3099 | dependencies = [ | ||
| 3100 | "cc", | ||
| 3101 | "libc", | ||
| 3102 | "pkg-config", | ||
| 3103 | "vcpkg", | ||
| 3104 | ] | ||
| 3105 | |||
| 3106 | [[package]] | ||
| 3107 | name = "opus2" | ||
| 3108 | version = "0.4.0" | ||
| 3109 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3110 | checksum = "49521e33fbf825d2abc8d696506c278cb8469d0c6cc05d3785bef5c5f7ee957b" | ||
| 3111 | dependencies = [ | ||
| 3112 | "libopus_sys", | ||
| 3113 | ] | ||
| 3114 | |||
| 3115 | [[package]] | ||
| 3116 | name = "ordered-float" | ||
| 3117 | version = "2.10.1" | ||
| 3118 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3119 | checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" | ||
| 3120 | dependencies = [ | ||
| 3121 | "num-traits", | ||
| 3122 | ] | ||
| 3123 | |||
| 3124 | [[package]] | ||
| 3125 | name = "owned_ttf_parser" | ||
| 3126 | version = "0.25.1" | ||
| 3127 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3128 | checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" | ||
| 3129 | dependencies = [ | ||
| 3130 | "ttf-parser", | ||
| 3131 | ] | ||
| 3132 | |||
| 3133 | [[package]] | ||
| 3134 | name = "p256" | ||
| 3135 | version = "0.13.2" | ||
| 3136 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3137 | checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" | ||
| 3138 | dependencies = [ | ||
| 3139 | "ecdsa", | ||
| 3140 | "elliptic-curve", | ||
| 3141 | "primeorder", | ||
| 3142 | "sha2 0.10.9", | ||
| 3143 | ] | ||
| 3144 | |||
| 3145 | [[package]] | ||
| 3146 | name = "p384" | ||
| 3147 | version = "0.13.1" | ||
| 3148 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3149 | checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" | ||
| 3150 | dependencies = [ | ||
| 3151 | "elliptic-curve", | ||
| 3152 | "primeorder", | ||
| 3153 | ] | ||
| 3154 | |||
| 3155 | [[package]] | ||
| 3156 | name = "parking" | ||
| 3157 | version = "2.2.1" | ||
| 3158 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3159 | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" | ||
| 3160 | |||
| 3161 | [[package]] | ||
| 3162 | name = "parking_lot" | ||
| 3163 | version = "0.11.2" | ||
| 3164 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3165 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" | ||
| 3166 | dependencies = [ | ||
| 3167 | "instant", | ||
| 3168 | "lock_api", | ||
| 3169 | "parking_lot_core 0.8.6", | ||
| 3170 | ] | ||
| 3171 | |||
| 3172 | [[package]] | ||
| 3173 | name = "parking_lot" | ||
| 3174 | version = "0.12.5" | ||
| 3175 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3176 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | ||
| 3177 | dependencies = [ | ||
| 3178 | "lock_api", | ||
| 3179 | "parking_lot_core 0.9.12", | ||
| 3180 | ] | ||
| 3181 | |||
| 3182 | [[package]] | ||
| 3183 | name = "parking_lot_core" | ||
| 3184 | version = "0.8.6" | ||
| 3185 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3186 | checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" | ||
| 3187 | dependencies = [ | ||
| 3188 | "cfg-if", | ||
| 3189 | "instant", | ||
| 3190 | "libc", | ||
| 3191 | "redox_syscall 0.2.16", | ||
| 3192 | "smallvec", | ||
| 3193 | "winapi", | ||
| 3194 | ] | ||
| 3195 | |||
| 3196 | [[package]] | ||
| 3197 | name = "parking_lot_core" | ||
| 3198 | version = "0.9.12" | ||
| 3199 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3200 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | ||
| 3201 | dependencies = [ | ||
| 3202 | "cfg-if", | ||
| 3203 | "libc", | ||
| 3204 | "redox_syscall 0.5.18", | ||
| 3205 | "smallvec", | ||
| 3206 | "windows-link", | ||
| 3207 | ] | ||
| 3208 | |||
| 3209 | [[package]] | ||
| 3210 | name = "paste" | ||
| 3211 | version = "1.0.15" | ||
| 3212 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3213 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | ||
| 3214 | |||
| 3215 | [[package]] | ||
| 3216 | name = "pastey" | ||
| 3217 | version = "0.2.3" | ||
| 3218 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3219 | checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" | ||
| 3220 | |||
| 3221 | [[package]] | ||
| 3222 | name = "patricia_tree" | ||
| 3223 | version = "0.8.0" | ||
| 3224 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3225 | checksum = "31f2f4539bffe53fc4b4da301df49d114b845b077bd5727b7fe2bd9d8df2ae68" | ||
| 3226 | dependencies = [ | ||
| 3227 | "bitflags 2.13.0", | ||
| 3228 | ] | ||
| 3229 | |||
| 3230 | [[package]] | ||
| 3231 | name = "pbkdf2" | ||
| 3232 | version = "0.12.2" | ||
| 3233 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3234 | checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" | ||
| 3235 | dependencies = [ | ||
| 3236 | "digest 0.10.7", | ||
| 3237 | "hmac 0.12.1", | ||
| 3238 | ] | ||
| 3239 | |||
| 3240 | [[package]] | ||
| 3241 | name = "pem-rfc7468" | ||
| 3242 | version = "0.7.0" | ||
| 3243 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3244 | checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" | ||
| 3245 | dependencies = [ | ||
| 3246 | "base64ct", | ||
| 3247 | ] | ||
| 3248 | |||
| 3249 | [[package]] | ||
| 3250 | name = "percent-encoding" | ||
| 3251 | version = "2.3.2" | ||
| 3252 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3253 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | ||
| 3254 | |||
| 3255 | [[package]] | ||
| 3256 | name = "phf" | ||
| 3257 | version = "0.11.3" | ||
| 3258 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3259 | checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" | ||
| 3260 | dependencies = [ | ||
| 3261 | "phf_macros 0.11.3", | ||
| 3262 | "phf_shared 0.11.3", | ||
| 3263 | ] | ||
| 3264 | |||
| 3265 | [[package]] | ||
| 3266 | name = "phf" | ||
| 3267 | version = "0.12.1" | ||
| 3268 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3269 | checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" | ||
| 3270 | dependencies = [ | ||
| 3271 | "phf_shared 0.12.1", | ||
| 3272 | ] | ||
| 3273 | |||
| 3274 | [[package]] | ||
| 3275 | name = "phf" | ||
| 3276 | version = "0.13.1" | ||
| 3277 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3278 | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" | ||
| 3279 | dependencies = [ | ||
| 3280 | "phf_macros 0.13.1", | ||
| 3281 | "phf_shared 0.13.1", | ||
| 3282 | "serde", | ||
| 3283 | ] | ||
| 3284 | |||
| 3285 | [[package]] | ||
| 3286 | name = "phf_codegen" | ||
| 3287 | version = "0.13.1" | ||
| 3288 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3289 | checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" | ||
| 3290 | dependencies = [ | ||
| 3291 | "phf_generator 0.13.1", | ||
| 3292 | "phf_shared 0.13.1", | ||
| 3293 | ] | ||
| 3294 | |||
| 3295 | [[package]] | ||
| 3296 | name = "phf_generator" | ||
| 3297 | version = "0.11.3" | ||
| 3298 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3299 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" | ||
| 3300 | dependencies = [ | ||
| 3301 | "phf_shared 0.11.3", | ||
| 3302 | "rand 0.8.6", | ||
| 3303 | ] | ||
| 3304 | |||
| 3305 | [[package]] | ||
| 3306 | name = "phf_generator" | ||
| 3307 | version = "0.13.1" | ||
| 3308 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3309 | checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" | ||
| 3310 | dependencies = [ | ||
| 3311 | "fastrand", | ||
| 3312 | "phf_shared 0.13.1", | ||
| 3313 | ] | ||
| 3314 | |||
| 3315 | [[package]] | ||
| 3316 | name = "phf_macros" | ||
| 3317 | version = "0.11.3" | ||
| 3318 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3319 | checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" | ||
| 3320 | dependencies = [ | ||
| 3321 | "phf_generator 0.11.3", | ||
| 3322 | "phf_shared 0.11.3", | ||
| 3323 | "proc-macro2", | ||
| 3324 | "quote", | ||
| 3325 | "syn 2.0.118", | ||
| 3326 | ] | ||
| 3327 | |||
| 3328 | [[package]] | ||
| 3329 | name = "phf_macros" | ||
| 3330 | version = "0.13.1" | ||
| 3331 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3332 | checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" | ||
| 3333 | dependencies = [ | ||
| 3334 | "phf_generator 0.13.1", | ||
| 3335 | "phf_shared 0.13.1", | ||
| 3336 | "proc-macro2", | ||
| 3337 | "quote", | ||
| 3338 | "syn 2.0.118", | ||
| 3339 | ] | ||
| 3340 | |||
| 3341 | [[package]] | ||
| 3342 | name = "phf_shared" | ||
| 3343 | version = "0.11.3" | ||
| 3344 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3345 | checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" | ||
| 3346 | dependencies = [ | ||
| 3347 | "siphasher", | ||
| 3348 | ] | ||
| 3349 | |||
| 3350 | [[package]] | ||
| 3351 | name = "phf_shared" | ||
| 3352 | version = "0.12.1" | ||
| 3353 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3354 | checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" | ||
| 3355 | dependencies = [ | ||
| 3356 | "siphasher", | ||
| 3357 | ] | ||
| 3358 | |||
| 3359 | [[package]] | ||
| 3360 | name = "phf_shared" | ||
| 3361 | version = "0.13.1" | ||
| 3362 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3363 | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" | ||
| 3364 | dependencies = [ | ||
| 3365 | "siphasher", | ||
| 3366 | ] | ||
| 3367 | |||
| 3368 | [[package]] | ||
| 3369 | name = "pin-project" | ||
| 3370 | version = "1.1.13" | ||
| 3371 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3372 | checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" | ||
| 3373 | dependencies = [ | ||
| 3374 | "pin-project-internal", | ||
| 3375 | ] | ||
| 3376 | |||
| 3377 | [[package]] | ||
| 3378 | name = "pin-project-internal" | ||
| 3379 | version = "1.1.13" | ||
| 3380 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3381 | checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" | ||
| 3382 | dependencies = [ | ||
| 3383 | "proc-macro2", | ||
| 3384 | "quote", | ||
| 3385 | "syn 2.0.118", | ||
| 3386 | ] | ||
| 3387 | |||
| 3388 | [[package]] | ||
| 3389 | name = "pin-project-lite" | ||
| 3390 | version = "0.2.17" | ||
| 3391 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3392 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | ||
| 3393 | |||
| 3394 | [[package]] | ||
| 3395 | name = "pin-utils" | ||
| 3396 | version = "0.1.0" | ||
| 3397 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3398 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" | ||
| 3399 | |||
| 3400 | [[package]] | ||
| 3401 | name = "pkcs8" | ||
| 3402 | version = "0.10.2" | ||
| 3403 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3404 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | ||
| 3405 | dependencies = [ | ||
| 3406 | "der", | ||
| 3407 | "spki", | ||
| 3408 | ] | ||
| 3409 | |||
| 3410 | [[package]] | ||
| 3411 | name = "pkg-config" | ||
| 3412 | version = "0.3.33" | ||
| 3413 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3414 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | ||
| 3415 | |||
| 3416 | [[package]] | ||
| 3417 | name = "pnet_base" | ||
| 3418 | version = "0.34.0" | ||
| 3419 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3420 | checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c" | ||
| 3421 | dependencies = [ | ||
| 3422 | "no-std-net", | ||
| 3423 | ] | ||
| 3424 | |||
| 3425 | [[package]] | ||
| 3426 | name = "pnet_macros" | ||
| 3427 | version = "0.34.0" | ||
| 3428 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3429 | checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804" | ||
| 3430 | dependencies = [ | ||
| 3431 | "proc-macro2", | ||
| 3432 | "quote", | ||
| 3433 | "regex", | ||
| 3434 | "syn 2.0.118", | ||
| 3435 | ] | ||
| 3436 | |||
| 3437 | [[package]] | ||
| 3438 | name = "pnet_macros_support" | ||
| 3439 | version = "0.34.0" | ||
| 3440 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3441 | checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56" | ||
| 3442 | dependencies = [ | ||
| 3443 | "pnet_base", | ||
| 3444 | ] | ||
| 3445 | |||
| 3446 | [[package]] | ||
| 3447 | name = "png" | ||
| 3448 | version = "0.18.1" | ||
| 3449 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3450 | checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | ||
| 3451 | dependencies = [ | ||
| 3452 | "bitflags 2.13.0", | ||
| 3453 | "crc32fast", | ||
| 3454 | "fdeflate", | ||
| 3455 | "flate2", | ||
| 3456 | "miniz_oxide", | ||
| 3457 | ] | ||
| 3458 | |||
| 3459 | [[package]] | ||
| 3460 | name = "poise" | ||
| 3461 | version = "0.6.2" | ||
| 3462 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3463 | checksum = "711433114bca9ff68582aa58af5cbcf573e83250a8714c2dca1c36f453cad3ae" | ||
| 3464 | dependencies = [ | ||
| 3465 | "async-trait", | ||
| 3466 | "derivative", | ||
| 3467 | "futures-util", | ||
| 3468 | "indexmap", | ||
| 3469 | "parking_lot 0.12.5", | ||
| 3470 | "regex", | ||
| 3471 | "serenity", | ||
| 3472 | "serenity_poise_macros", | ||
| 3473 | "tokio", | ||
| 3474 | "tracing", | ||
| 3475 | "trim-in-place", | ||
| 3476 | ] | ||
| 3477 | |||
| 3478 | [[package]] | ||
| 3479 | name = "poly1305" | ||
| 3480 | version = "0.8.0" | ||
| 3481 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3482 | checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" | ||
| 3483 | dependencies = [ | ||
| 3484 | "cpufeatures 0.2.17", | ||
| 3485 | "opaque-debug", | ||
| 3486 | "universal-hash", | ||
| 3487 | ] | ||
| 3488 | |||
| 3489 | [[package]] | ||
| 3490 | name = "polyval" | ||
| 3491 | version = "0.6.2" | ||
| 3492 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3493 | checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" | ||
| 3494 | dependencies = [ | ||
| 3495 | "cfg-if", | ||
| 3496 | "cpufeatures 0.2.17", | ||
| 3497 | "opaque-debug", | ||
| 3498 | "universal-hash", | ||
| 3499 | ] | ||
| 3500 | |||
| 3501 | [[package]] | ||
| 3502 | name = "portable-atomic" | ||
| 3503 | version = "1.13.1" | ||
| 3504 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3505 | checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" | ||
| 3506 | |||
| 3507 | [[package]] | ||
| 3508 | name = "portable-atomic-util" | ||
| 3509 | version = "0.2.7" | ||
| 3510 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3511 | checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" | ||
| 3512 | dependencies = [ | ||
| 3513 | "portable-atomic", | ||
| 3514 | ] | ||
| 3515 | |||
| 3516 | [[package]] | ||
| 3517 | name = "potential_utf" | ||
| 3518 | version = "0.1.5" | ||
| 3519 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3520 | checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" | ||
| 3521 | dependencies = [ | ||
| 3522 | "zerovec", | ||
| 3523 | ] | ||
| 3524 | |||
| 3525 | [[package]] | ||
| 3526 | name = "powerfmt" | ||
| 3527 | version = "0.2.0" | ||
| 3528 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3529 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | ||
| 3530 | |||
| 3531 | [[package]] | ||
| 3532 | name = "ppv-lite86" | ||
| 3533 | version = "0.2.21" | ||
| 3534 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3535 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | ||
| 3536 | dependencies = [ | ||
| 3537 | "zerocopy", | ||
| 3538 | ] | ||
| 3539 | |||
| 3540 | [[package]] | ||
| 3541 | name = "precomputed-hash" | ||
| 3542 | version = "0.1.1" | ||
| 3543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3544 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" | ||
| 3545 | |||
| 3546 | [[package]] | ||
| 3547 | name = "primal-check" | ||
| 3548 | version = "0.3.4" | ||
| 3549 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3550 | checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" | ||
| 3551 | dependencies = [ | ||
| 3552 | "num-integer", | ||
| 3553 | ] | ||
| 3554 | |||
| 3555 | [[package]] | ||
| 3556 | name = "primeorder" | ||
| 3557 | version = "0.13.6" | ||
| 3558 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3559 | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | ||
| 3560 | dependencies = [ | ||
| 3561 | "elliptic-curve", | ||
| 3562 | ] | ||
| 3563 | |||
| 3564 | [[package]] | ||
| 3565 | name = "proc-macro-error-attr2" | ||
| 3566 | version = "2.0.0" | ||
| 3567 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3568 | checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" | ||
| 3569 | dependencies = [ | ||
| 3570 | "proc-macro2", | ||
| 3571 | "quote", | ||
| 3572 | ] | ||
| 3573 | |||
| 3574 | [[package]] | ||
| 3575 | name = "proc-macro-error2" | ||
| 3576 | version = "2.0.1" | ||
| 3577 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3578 | checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" | ||
| 3579 | dependencies = [ | ||
| 3580 | "proc-macro-error-attr2", | ||
| 3581 | "proc-macro2", | ||
| 3582 | "quote", | ||
| 3583 | "syn 2.0.118", | ||
| 3584 | ] | ||
| 3585 | |||
| 3586 | [[package]] | ||
| 3587 | name = "proc-macro2" | ||
| 3588 | version = "1.0.106" | ||
| 3589 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3590 | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | ||
| 3591 | dependencies = [ | ||
| 3592 | "unicode-ident", | ||
| 3593 | ] | ||
| 3594 | |||
| 3595 | [[package]] | ||
| 3596 | name = "profiling" | ||
| 3597 | version = "1.0.18" | ||
| 3598 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3599 | checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | ||
| 3600 | dependencies = [ | ||
| 3601 | "profiling-procmacros", | ||
| 3602 | ] | ||
| 3603 | |||
| 3604 | [[package]] | ||
| 3605 | name = "profiling-procmacros" | ||
| 3606 | version = "1.0.18" | ||
| 3607 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3608 | checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" | ||
| 3609 | dependencies = [ | ||
| 3610 | "quote", | ||
| 3611 | "syn 2.0.118", | ||
| 3612 | ] | ||
| 3613 | |||
| 3614 | [[package]] | ||
| 3615 | name = "pulldown-cmark" | ||
| 3616 | version = "0.9.6" | ||
| 3617 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3618 | checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" | ||
| 3619 | dependencies = [ | ||
| 3620 | "bitflags 2.13.0", | ||
| 3621 | "memchr", | ||
| 3622 | "unicase", | ||
| 3623 | ] | ||
| 3624 | |||
| 3625 | [[package]] | ||
| 3626 | name = "pxfm" | ||
| 3627 | version = "0.1.29" | ||
| 3628 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3629 | checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" | ||
| 3630 | |||
| 3631 | [[package]] | ||
| 3632 | name = "qoi" | ||
| 3633 | version = "0.4.1" | ||
| 3634 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3635 | checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" | ||
| 3636 | dependencies = [ | ||
| 3637 | "bytemuck", | ||
| 3638 | ] | ||
| 3639 | |||
| 3640 | [[package]] | ||
| 3641 | name = "qrcode" | ||
| 3642 | version = "0.14.1" | ||
| 3643 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3644 | checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" | ||
| 3645 | dependencies = [ | ||
| 3646 | "image", | ||
| 3647 | ] | ||
| 3648 | |||
| 3649 | [[package]] | ||
| 3650 | name = "quick-error" | ||
| 3651 | version = "2.0.1" | ||
| 3652 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3653 | checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" | ||
| 3654 | |||
| 3655 | [[package]] | ||
| 3656 | name = "quinn" | ||
| 3657 | version = "0.11.9" | ||
| 3658 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3659 | checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" | ||
| 3660 | dependencies = [ | ||
| 3661 | "bytes", | ||
| 3662 | "cfg_aliases", | ||
| 3663 | "pin-project-lite", | ||
| 3664 | "quinn-proto", | ||
| 3665 | "quinn-udp", | ||
| 3666 | "rustc-hash", | ||
| 3667 | "rustls 0.23.40", | ||
| 3668 | "socket2 0.6.4", | ||
| 3669 | "thiserror 2.0.18", | ||
| 3670 | "tokio", | ||
| 3671 | "tracing", | ||
| 3672 | "web-time", | ||
| 3673 | ] | ||
| 3674 | |||
| 3675 | [[package]] | ||
| 3676 | name = "quinn-proto" | ||
| 3677 | version = "0.11.14" | ||
| 3678 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3679 | checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" | ||
| 3680 | dependencies = [ | ||
| 3681 | "bytes", | ||
| 3682 | "getrandom 0.3.4", | ||
| 3683 | "lru-slab", | ||
| 3684 | "rand 0.9.4", | ||
| 3685 | "ring", | ||
| 3686 | "rustc-hash", | ||
| 3687 | "rustls 0.23.40", | ||
| 3688 | "rustls-pki-types", | ||
| 3689 | "slab", | ||
| 3690 | "thiserror 2.0.18", | ||
| 3691 | "tinyvec", | ||
| 3692 | "tracing", | ||
| 3693 | "web-time", | ||
| 3694 | ] | ||
| 3695 | |||
| 3696 | [[package]] | ||
| 3697 | name = "quinn-udp" | ||
| 3698 | version = "0.5.14" | ||
| 3699 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3700 | checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" | ||
| 3701 | dependencies = [ | ||
| 3702 | "cfg_aliases", | ||
| 3703 | "libc", | ||
| 3704 | "once_cell", | ||
| 3705 | "socket2 0.6.4", | ||
| 3706 | "tracing", | ||
| 3707 | "windows-sys 0.60.2", | ||
| 3708 | ] | ||
| 3709 | |||
| 3710 | [[package]] | ||
| 3711 | name = "quote" | ||
| 3712 | version = "1.0.45" | ||
| 3713 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3714 | checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" | ||
| 3715 | dependencies = [ | ||
| 3716 | "proc-macro2", | ||
| 3717 | ] | ||
| 3718 | |||
| 3719 | [[package]] | ||
| 3720 | name = "r-efi" | ||
| 3721 | version = "5.3.0" | ||
| 3722 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3723 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | ||
| 3724 | |||
| 3725 | [[package]] | ||
| 3726 | name = "r-efi" | ||
| 3727 | version = "6.0.0" | ||
| 3728 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3729 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | ||
| 3730 | |||
| 3731 | [[package]] | ||
| 3732 | name = "rand" | ||
| 3733 | version = "0.8.6" | ||
| 3734 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3735 | checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" | ||
| 3736 | dependencies = [ | ||
| 3737 | "libc", | ||
| 3738 | "rand_chacha 0.3.1", | ||
| 3739 | "rand_core 0.6.4", | ||
| 3740 | ] | ||
| 3741 | |||
| 3742 | [[package]] | ||
| 3743 | name = "rand" | ||
| 3744 | version = "0.9.4" | ||
| 3745 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3746 | checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" | ||
| 3747 | dependencies = [ | ||
| 3748 | "rand_chacha 0.9.0", | ||
| 3749 | "rand_core 0.9.5", | ||
| 3750 | ] | ||
| 3751 | |||
| 3752 | [[package]] | ||
| 3753 | name = "rand" | ||
| 3754 | version = "0.10.1" | ||
| 3755 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3756 | checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" | ||
| 3757 | dependencies = [ | ||
| 3758 | "chacha20 0.10.0", | ||
| 3759 | "getrandom 0.4.3", | ||
| 3760 | "rand_core 0.10.1", | ||
| 3761 | ] | ||
| 3762 | |||
| 3763 | [[package]] | ||
| 3764 | name = "rand_chacha" | ||
| 3765 | version = "0.3.1" | ||
| 3766 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3767 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | ||
| 3768 | dependencies = [ | ||
| 3769 | "ppv-lite86", | ||
| 3770 | "rand_core 0.6.4", | ||
| 3771 | ] | ||
| 3772 | |||
| 3773 | [[package]] | ||
| 3774 | name = "rand_chacha" | ||
| 3775 | version = "0.9.0" | ||
| 3776 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3777 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | ||
| 3778 | dependencies = [ | ||
| 3779 | "ppv-lite86", | ||
| 3780 | "rand_core 0.9.5", | ||
| 3781 | ] | ||
| 3782 | |||
| 3783 | [[package]] | ||
| 3784 | name = "rand_chacha" | ||
| 3785 | version = "0.10.0" | ||
| 3786 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3787 | checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" | ||
| 3788 | dependencies = [ | ||
| 3789 | "ppv-lite86", | ||
| 3790 | "rand_core 0.10.1", | ||
| 3791 | ] | ||
| 3792 | |||
| 3793 | [[package]] | ||
| 3794 | name = "rand_core" | ||
| 3795 | version = "0.6.4" | ||
| 3796 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3797 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" | ||
| 3798 | dependencies = [ | ||
| 3799 | "getrandom 0.2.17", | ||
| 3800 | ] | ||
| 3801 | |||
| 3802 | [[package]] | ||
| 3803 | name = "rand_core" | ||
| 3804 | version = "0.9.5" | ||
| 3805 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3806 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | ||
| 3807 | dependencies = [ | ||
| 3808 | "getrandom 0.3.4", | ||
| 3809 | ] | ||
| 3810 | |||
| 3811 | [[package]] | ||
| 3812 | name = "rand_core" | ||
| 3813 | version = "0.10.1" | ||
| 3814 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3815 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | ||
| 3816 | |||
| 3817 | [[package]] | ||
| 3818 | name = "rand_distr" | ||
| 3819 | version = "0.4.3" | ||
| 3820 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3821 | checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" | ||
| 3822 | dependencies = [ | ||
| 3823 | "num-traits", | ||
| 3824 | "rand 0.8.6", | ||
| 3825 | ] | ||
| 3826 | |||
| 3827 | [[package]] | ||
| 3828 | name = "rav1e" | ||
| 3829 | version = "0.7.1" | ||
| 3830 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3831 | checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" | ||
| 3832 | dependencies = [ | ||
| 3833 | "arbitrary", | ||
| 3834 | "arg_enum_proc_macro", | ||
| 3835 | "arrayvec", | ||
| 3836 | "av1-grain", | ||
| 3837 | "bitstream-io", | ||
| 3838 | "built", | ||
| 3839 | "cfg-if", | ||
| 3840 | "interpolate_name", | ||
| 3841 | "itertools", | ||
| 3842 | "libc", | ||
| 3843 | "libfuzzer-sys", | ||
| 3844 | "log", | ||
| 3845 | "maybe-rayon", | ||
| 3846 | "new_debug_unreachable", | ||
| 3847 | "noop_proc_macro", | ||
| 3848 | "num-derive", | ||
| 3849 | "num-traits", | ||
| 3850 | "once_cell", | ||
| 3851 | "paste", | ||
| 3852 | "profiling", | ||
| 3853 | "rand 0.8.6", | ||
| 3854 | "rand_chacha 0.3.1", | ||
| 3855 | "simd_helpers", | ||
| 3856 | "system-deps", | ||
| 3857 | "thiserror 1.0.69", | ||
| 3858 | "v_frame", | ||
| 3859 | "wasm-bindgen", | ||
| 3860 | ] | ||
| 3861 | |||
| 3862 | [[package]] | ||
| 3863 | name = "ravif" | ||
| 3864 | version = "0.11.20" | ||
| 3865 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3866 | checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" | ||
| 3867 | dependencies = [ | ||
| 3868 | "avif-serialize", | ||
| 3869 | "imgref", | ||
| 3870 | "loop9", | ||
| 3871 | "quick-error", | ||
| 3872 | "rav1e", | ||
| 3873 | "rayon", | ||
| 3874 | "rgb", | ||
| 3875 | ] | ||
| 3876 | |||
| 3877 | [[package]] | ||
| 3878 | name = "rawpointer" | ||
| 3879 | version = "0.2.1" | ||
| 3880 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3881 | checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" | ||
| 3882 | |||
| 3883 | [[package]] | ||
| 3884 | name = "rayon" | ||
| 3885 | version = "1.12.0" | ||
| 3886 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3887 | checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" | ||
| 3888 | dependencies = [ | ||
| 3889 | "either", | ||
| 3890 | "rayon-core", | ||
| 3891 | ] | ||
| 3892 | |||
| 3893 | [[package]] | ||
| 3894 | name = "rayon-core" | ||
| 3895 | version = "1.13.0" | ||
| 3896 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3897 | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" | ||
| 3898 | dependencies = [ | ||
| 3899 | "crossbeam-deque", | ||
| 3900 | "crossbeam-utils", | ||
| 3901 | ] | ||
| 3902 | |||
| 3903 | [[package]] | ||
| 3904 | name = "realfft" | ||
| 3905 | version = "3.5.0" | ||
| 3906 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3907 | checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" | ||
| 3908 | dependencies = [ | ||
| 3909 | "rustfft", | ||
| 3910 | ] | ||
| 3911 | |||
| 3912 | [[package]] | ||
| 3913 | name = "redox_syscall" | ||
| 3914 | version = "0.2.16" | ||
| 3915 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3916 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" | ||
| 3917 | dependencies = [ | ||
| 3918 | "bitflags 1.3.2", | ||
| 3919 | ] | ||
| 3920 | |||
| 3921 | [[package]] | ||
| 3922 | name = "redox_syscall" | ||
| 3923 | version = "0.5.18" | ||
| 3924 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3925 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | ||
| 3926 | dependencies = [ | ||
| 3927 | "bitflags 2.13.0", | ||
| 3928 | ] | ||
| 3929 | |||
| 3930 | [[package]] | ||
| 3931 | name = "regex" | ||
| 3932 | version = "1.12.4" | ||
| 3933 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3934 | checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" | ||
| 3935 | dependencies = [ | ||
| 3936 | "aho-corasick", | ||
| 3937 | "memchr", | ||
| 3938 | "regex-automata", | ||
| 3939 | "regex-syntax", | ||
| 3940 | ] | ||
| 3941 | |||
| 3942 | [[package]] | ||
| 3943 | name = "regex-automata" | ||
| 3944 | version = "0.4.14" | ||
| 3945 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3946 | checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" | ||
| 3947 | dependencies = [ | ||
| 3948 | "aho-corasick", | ||
| 3949 | "memchr", | ||
| 3950 | "regex-syntax", | ||
| 3951 | ] | ||
| 3952 | |||
| 3953 | [[package]] | ||
| 3954 | name = "regex-lite" | ||
| 3955 | version = "0.1.9" | ||
| 3956 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3957 | checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" | ||
| 3958 | |||
| 3959 | [[package]] | ||
| 3960 | name = "regex-syntax" | ||
| 3961 | version = "0.8.11" | ||
| 3962 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3963 | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | ||
| 3964 | |||
| 3965 | [[package]] | ||
| 3966 | name = "reqwest" | ||
| 3967 | version = "0.12.28" | ||
| 3968 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3969 | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | ||
| 3970 | dependencies = [ | ||
| 3971 | "base64", | ||
| 3972 | "bytes", | ||
| 3973 | "encoding_rs", | ||
| 3974 | "futures-core", | ||
| 3975 | "futures-util", | ||
| 3976 | "h2", | ||
| 3977 | "http", | ||
| 3978 | "http-body", | ||
| 3979 | "http-body-util", | ||
| 3980 | "hyper", | ||
| 3981 | "hyper-rustls", | ||
| 3982 | "hyper-tls", | ||
| 3983 | "hyper-util", | ||
| 3984 | "js-sys", | ||
| 3985 | "log", | ||
| 3986 | "mime", | ||
| 3987 | "mime_guess", | ||
| 3988 | "native-tls", | ||
| 3989 | "percent-encoding", | ||
| 3990 | "pin-project-lite", | ||
| 3991 | "quinn", | ||
| 3992 | "rustls 0.23.40", | ||
| 3993 | "rustls-pki-types", | ||
| 3994 | "serde", | ||
| 3995 | "serde_json", | ||
| 3996 | "serde_urlencoded", | ||
| 3997 | "sync_wrapper", | ||
| 3998 | "tokio", | ||
| 3999 | "tokio-native-tls", | ||
| 4000 | "tokio-rustls 0.26.4", | ||
| 4001 | "tokio-util", | ||
| 4002 | "tower", | ||
| 4003 | "tower-http", | ||
| 4004 | "tower-service", | ||
| 4005 | "url", | ||
| 4006 | "wasm-bindgen", | ||
| 4007 | "wasm-bindgen-futures", | ||
| 4008 | "wasm-streams", | ||
| 4009 | "web-sys", | ||
| 4010 | "webpki-roots 1.0.8", | ||
| 4011 | ] | ||
| 4012 | |||
| 4013 | [[package]] | ||
| 4014 | name = "rfc6979" | ||
| 4015 | version = "0.4.0" | ||
| 4016 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4017 | checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" | ||
| 4018 | dependencies = [ | ||
| 4019 | "hmac 0.12.1", | ||
| 4020 | "subtle", | ||
| 4021 | ] | ||
| 4022 | |||
| 4023 | [[package]] | ||
| 4024 | name = "rgb" | ||
| 4025 | version = "0.8.53" | ||
| 4026 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4027 | checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" | ||
| 4028 | |||
| 4029 | [[package]] | ||
| 4030 | name = "ring" | ||
| 4031 | version = "0.17.14" | ||
| 4032 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4033 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | ||
| 4034 | dependencies = [ | ||
| 4035 | "cc", | ||
| 4036 | "cfg-if", | ||
| 4037 | "getrandom 0.2.17", | ||
| 4038 | "libc", | ||
| 4039 | "untrusted", | ||
| 4040 | "windows-sys 0.52.0", | ||
| 4041 | ] | ||
| 4042 | |||
| 4043 | [[package]] | ||
| 4044 | name = "ringbuf" | ||
| 4045 | version = "0.4.8" | ||
| 4046 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4047 | checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" | ||
| 4048 | dependencies = [ | ||
| 4049 | "crossbeam-utils", | ||
| 4050 | "portable-atomic", | ||
| 4051 | "portable-atomic-util", | ||
| 4052 | ] | ||
| 4053 | |||
| 4054 | [[package]] | ||
| 4055 | name = "rubato" | ||
| 4056 | version = "0.16.2" | ||
| 4057 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4058 | checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" | ||
| 4059 | dependencies = [ | ||
| 4060 | "num-complex", | ||
| 4061 | "num-integer", | ||
| 4062 | "num-traits", | ||
| 4063 | "realfft", | ||
| 4064 | ] | ||
| 4065 | |||
| 4066 | [[package]] | ||
| 4067 | name = "rustc-hash" | ||
| 4068 | version = "2.1.2" | ||
| 4069 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4070 | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" | ||
| 4071 | |||
| 4072 | [[package]] | ||
| 4073 | name = "rustc_version" | ||
| 4074 | version = "0.4.1" | ||
| 4075 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4076 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | ||
| 4077 | dependencies = [ | ||
| 4078 | "semver", | ||
| 4079 | ] | ||
| 4080 | |||
| 4081 | [[package]] | ||
| 4082 | name = "rustfft" | ||
| 4083 | version = "6.4.1" | ||
| 4084 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4085 | checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" | ||
| 4086 | dependencies = [ | ||
| 4087 | "num-complex", | ||
| 4088 | "num-integer", | ||
| 4089 | "num-traits", | ||
| 4090 | "primal-check", | ||
| 4091 | "strength_reduce", | ||
| 4092 | "transpose", | ||
| 4093 | ] | ||
| 4094 | |||
| 4095 | [[package]] | ||
| 4096 | name = "rustix" | ||
| 4097 | version = "1.1.4" | ||
| 4098 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4099 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | ||
| 4100 | dependencies = [ | ||
| 4101 | "bitflags 2.13.0", | ||
| 4102 | "errno", | ||
| 4103 | "libc", | ||
| 4104 | "linux-raw-sys", | ||
| 4105 | "windows-sys 0.61.2", | ||
| 4106 | ] | ||
| 4107 | |||
| 4108 | [[package]] | ||
| 4109 | name = "rustls" | ||
| 4110 | version = "0.22.4" | ||
| 4111 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4112 | checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" | ||
| 4113 | dependencies = [ | ||
| 4114 | "log", | ||
| 4115 | "ring", | ||
| 4116 | "rustls-pki-types", | ||
| 4117 | "rustls-webpki 0.102.8", | ||
| 4118 | "subtle", | ||
| 4119 | "zeroize", | ||
| 4120 | ] | ||
| 4121 | |||
| 4122 | [[package]] | ||
| 4123 | name = "rustls" | ||
| 4124 | version = "0.23.40" | ||
| 4125 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4126 | checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" | ||
| 4127 | dependencies = [ | ||
| 4128 | "aws-lc-rs", | ||
| 4129 | "log", | ||
| 4130 | "once_cell", | ||
| 4131 | "ring", | ||
| 4132 | "rustls-pki-types", | ||
| 4133 | "rustls-webpki 0.103.13", | ||
| 4134 | "subtle", | ||
| 4135 | "zeroize", | ||
| 4136 | ] | ||
| 4137 | |||
| 4138 | [[package]] | ||
| 4139 | name = "rustls-native-certs" | ||
| 4140 | version = "0.8.4" | ||
| 4141 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4142 | checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" | ||
| 4143 | dependencies = [ | ||
| 4144 | "openssl-probe", | ||
| 4145 | "rustls-pki-types", | ||
| 4146 | "schannel", | ||
| 4147 | "security-framework", | ||
| 4148 | ] | ||
| 4149 | |||
| 4150 | [[package]] | ||
| 4151 | name = "rustls-pki-types" | ||
| 4152 | version = "1.14.1" | ||
| 4153 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4154 | checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" | ||
| 4155 | dependencies = [ | ||
| 4156 | "web-time", | ||
| 4157 | "zeroize", | ||
| 4158 | ] | ||
| 4159 | |||
| 4160 | [[package]] | ||
| 4161 | name = "rustls-webpki" | ||
| 4162 | version = "0.102.8" | ||
| 4163 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4164 | checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" | ||
| 4165 | dependencies = [ | ||
| 4166 | "ring", | ||
| 4167 | "rustls-pki-types", | ||
| 4168 | "untrusted", | ||
| 4169 | ] | ||
| 4170 | |||
| 4171 | [[package]] | ||
| 4172 | name = "rustls-webpki" | ||
| 4173 | version = "0.103.13" | ||
| 4174 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4175 | checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" | ||
| 4176 | dependencies = [ | ||
| 4177 | "aws-lc-rs", | ||
| 4178 | "ring", | ||
| 4179 | "rustls-pki-types", | ||
| 4180 | "untrusted", | ||
| 4181 | ] | ||
| 4182 | |||
| 4183 | [[package]] | ||
| 4184 | name = "rustversion" | ||
| 4185 | version = "1.0.22" | ||
| 4186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4187 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | ||
| 4188 | |||
| 4189 | [[package]] | ||
| 4190 | name = "rusty_pool" | ||
| 4191 | version = "0.7.0" | ||
| 4192 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4193 | checksum = "4ed36cdb20de66d89a17ea04b8883fc7a386f2cf877aaedca5005583ce4876ff" | ||
| 4194 | dependencies = [ | ||
| 4195 | "crossbeam-channel", | ||
| 4196 | "futures", | ||
| 4197 | "futures-channel", | ||
| 4198 | "futures-executor", | ||
| 4199 | "num_cpus", | ||
| 4200 | ] | ||
| 4201 | |||
| 4202 | [[package]] | ||
| 4203 | name = "rxing" | ||
| 4204 | version = "0.8.6" | ||
| 4205 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4206 | checksum = "dc56bde1cf7f3c9cc908ef2670efede68bbad283acb6674b709aa3befdab451d" | ||
| 4207 | dependencies = [ | ||
| 4208 | "chrono", | ||
| 4209 | "chrono-tz", | ||
| 4210 | "codepage-437", | ||
| 4211 | "encoding_rs", | ||
| 4212 | "fancy-regex", | ||
| 4213 | "image", | ||
| 4214 | "imageproc", | ||
| 4215 | "multimap", | ||
| 4216 | "num", | ||
| 4217 | "once_cell", | ||
| 4218 | "regex", | ||
| 4219 | "rxing-one-d-proc-derive", | ||
| 4220 | "thiserror 2.0.18", | ||
| 4221 | "unicode-segmentation", | ||
| 4222 | "uriparse", | ||
| 4223 | "urlencoding", | ||
| 4224 | ] | ||
| 4225 | |||
| 4226 | [[package]] | ||
| 4227 | name = "rxing-one-d-proc-derive" | ||
| 4228 | version = "0.8.0" | ||
| 4229 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4230 | checksum = "d7e5f136594d614d0d8e44f63508abd25c8fe0280655bb1d477b177bf718d0a4" | ||
| 4231 | dependencies = [ | ||
| 4232 | "quote", | ||
| 4233 | "syn 2.0.118", | ||
| 4234 | ] | ||
| 4235 | |||
| 4236 | [[package]] | ||
| 4237 | name = "ryu" | ||
| 4238 | version = "1.0.23" | ||
| 4239 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4240 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 4241 | |||
| 4242 | [[package]] | ||
| 4243 | name = "safe_arch" | ||
| 4244 | version = "0.7.4" | ||
| 4245 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4246 | checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" | ||
| 4247 | dependencies = [ | ||
| 4248 | "bytemuck", | ||
| 4249 | ] | ||
| 4250 | |||
| 4251 | [[package]] | ||
| 4252 | name = "salsa20" | ||
| 4253 | version = "0.10.2" | ||
| 4254 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4255 | checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" | ||
| 4256 | dependencies = [ | ||
| 4257 | "cipher", | ||
| 4258 | ] | ||
| 4259 | |||
| 4260 | [[package]] | ||
| 4261 | name = "same-file" | ||
| 4262 | version = "1.0.6" | ||
| 4263 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4264 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | ||
| 4265 | dependencies = [ | ||
| 4266 | "winapi-util", | ||
| 4267 | ] | ||
| 4268 | |||
| 4269 | [[package]] | ||
| 4270 | name = "schannel" | ||
| 4271 | version = "0.1.29" | ||
| 4272 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4273 | checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" | ||
| 4274 | dependencies = [ | ||
| 4275 | "windows-sys 0.61.2", | ||
| 4276 | ] | ||
| 4277 | |||
| 4278 | [[package]] | ||
| 4279 | name = "scoped-tls" | ||
| 4280 | version = "1.0.1" | ||
| 4281 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4282 | checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" | ||
| 4283 | |||
| 4284 | [[package]] | ||
| 4285 | name = "scopeguard" | ||
| 4286 | version = "1.2.0" | ||
| 4287 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4288 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | ||
| 4289 | |||
| 4290 | [[package]] | ||
| 4291 | name = "scraper" | ||
| 4292 | version = "0.27.0" | ||
| 4293 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4294 | checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" | ||
| 4295 | dependencies = [ | ||
| 4296 | "cssparser", | ||
| 4297 | "ego-tree", | ||
| 4298 | "getopts", | ||
| 4299 | "html5ever", | ||
| 4300 | "precomputed-hash", | ||
| 4301 | "selectors", | ||
| 4302 | "tendril", | ||
| 4303 | ] | ||
| 4304 | |||
| 4305 | [[package]] | ||
| 4306 | name = "scrypt" | ||
| 4307 | version = "0.11.0" | ||
| 4308 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4309 | checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" | ||
| 4310 | dependencies = [ | ||
| 4311 | "pbkdf2", | ||
| 4312 | "salsa20", | ||
| 4313 | "sha2 0.10.9", | ||
| 4314 | ] | ||
| 4315 | |||
| 4316 | [[package]] | ||
| 4317 | name = "sec1" | ||
| 4318 | version = "0.7.3" | ||
| 4319 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4320 | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | ||
| 4321 | dependencies = [ | ||
| 4322 | "base16ct", | ||
| 4323 | "der", | ||
| 4324 | "generic-array", | ||
| 4325 | "pkcs8", | ||
| 4326 | "subtle", | ||
| 4327 | "zeroize", | ||
| 4328 | ] | ||
| 4329 | |||
| 4330 | [[package]] | ||
| 4331 | name = "secrecy" | ||
| 4332 | version = "0.8.0" | ||
| 4333 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4334 | checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" | ||
| 4335 | dependencies = [ | ||
| 4336 | "serde", | ||
| 4337 | "zeroize", | ||
| 4338 | ] | ||
| 4339 | |||
| 4340 | [[package]] | ||
| 4341 | name = "security-framework" | ||
| 4342 | version = "3.7.0" | ||
| 4343 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4344 | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" | ||
| 4345 | dependencies = [ | ||
| 4346 | "bitflags 2.13.0", | ||
| 4347 | "core-foundation 0.10.1", | ||
| 4348 | "core-foundation-sys", | ||
| 4349 | "libc", | ||
| 4350 | "security-framework-sys", | ||
| 4351 | ] | ||
| 4352 | |||
| 4353 | [[package]] | ||
| 4354 | name = "security-framework-sys" | ||
| 4355 | version = "2.17.0" | ||
| 4356 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4357 | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" | ||
| 4358 | dependencies = [ | ||
| 4359 | "core-foundation-sys", | ||
| 4360 | "libc", | ||
| 4361 | ] | ||
| 4362 | |||
| 4363 | [[package]] | ||
| 4364 | name = "selectors" | ||
| 4365 | version = "0.38.0" | ||
| 4366 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4367 | checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" | ||
| 4368 | dependencies = [ | ||
| 4369 | "bitflags 2.13.0", | ||
| 4370 | "cssparser", | ||
| 4371 | "derive_more 2.1.1", | ||
| 4372 | "log", | ||
| 4373 | "new_debug_unreachable", | ||
| 4374 | "phf 0.13.1", | ||
| 4375 | "phf_codegen", | ||
| 4376 | "precomputed-hash", | ||
| 4377 | "rustc-hash", | ||
| 4378 | "servo_arc", | ||
| 4379 | "smallvec", | ||
| 4380 | ] | ||
| 4381 | |||
| 4382 | [[package]] | ||
| 4383 | name = "semver" | ||
| 4384 | version = "1.0.28" | ||
| 4385 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4386 | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" | ||
| 4387 | dependencies = [ | ||
| 4388 | "serde", | ||
| 4389 | "serde_core", | ||
| 4390 | ] | ||
| 4391 | |||
| 4392 | [[package]] | ||
| 4393 | name = "senator-sperma" | ||
| 4394 | version = "0.2.1" | ||
| 4395 | dependencies = [ | ||
| 4396 | "chrono", | ||
| 4397 | "cron", | ||
| 4398 | "dotenvy", | ||
| 4399 | "futures-util", | ||
| 4400 | "glob", | ||
| 4401 | "image", | ||
| 4402 | "poise", | ||
| 4403 | "qrcode", | ||
| 4404 | "rand 0.10.1", | ||
| 4405 | "reqwest", | ||
| 4406 | "rustls 0.23.40", | ||
| 4407 | "rxing", | ||
| 4408 | "scraper", | ||
| 4409 | "serde", | ||
| 4410 | "serde_json", | ||
| 4411 | "serenity", | ||
| 4412 | "songbird", | ||
| 4413 | "sqlx", | ||
| 4414 | "symphonia 0.5.5", | ||
| 4415 | "symphonia 0.6.0", | ||
| 4416 | "tempfile", | ||
| 4417 | "tokio", | ||
| 4418 | "tracing", | ||
| 4419 | "tracing-subscriber", | ||
| 4420 | "uuid", | ||
| 4421 | ] | ||
| 4422 | |||
| 4423 | [[package]] | ||
| 4424 | name = "serde" | ||
| 4425 | version = "1.0.228" | ||
| 4426 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4427 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | ||
| 4428 | dependencies = [ | ||
| 4429 | "serde_core", | ||
| 4430 | "serde_derive", | ||
| 4431 | ] | ||
| 4432 | |||
| 4433 | [[package]] | ||
| 4434 | name = "serde-aux" | ||
| 4435 | version = "4.7.0" | ||
| 4436 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4437 | checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" | ||
| 4438 | dependencies = [ | ||
| 4439 | "chrono", | ||
| 4440 | "serde", | ||
| 4441 | "serde-value", | ||
| 4442 | "serde_json", | ||
| 4443 | ] | ||
| 4444 | |||
| 4445 | [[package]] | ||
| 4446 | name = "serde-value" | ||
| 4447 | version = "0.7.0" | ||
| 4448 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4449 | checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" | ||
| 4450 | dependencies = [ | ||
| 4451 | "ordered-float", | ||
| 4452 | "serde", | ||
| 4453 | ] | ||
| 4454 | |||
| 4455 | [[package]] | ||
| 4456 | name = "serde_bytes" | ||
| 4457 | version = "0.11.19" | ||
| 4458 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4459 | checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" | ||
| 4460 | dependencies = [ | ||
| 4461 | "serde", | ||
| 4462 | "serde_core", | ||
| 4463 | ] | ||
| 4464 | |||
| 4465 | [[package]] | ||
| 4466 | name = "serde_core" | ||
| 4467 | version = "1.0.228" | ||
| 4468 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4469 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | ||
| 4470 | dependencies = [ | ||
| 4471 | "serde_derive", | ||
| 4472 | ] | ||
| 4473 | |||
| 4474 | [[package]] | ||
| 4475 | name = "serde_cow" | ||
| 4476 | version = "0.1.2" | ||
| 4477 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4478 | checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" | ||
| 4479 | dependencies = [ | ||
| 4480 | "serde", | ||
| 4481 | ] | ||
| 4482 | |||
| 4483 | [[package]] | ||
| 4484 | name = "serde_derive" | ||
| 4485 | version = "1.0.228" | ||
| 4486 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4487 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | ||
| 4488 | dependencies = [ | ||
| 4489 | "proc-macro2", | ||
| 4490 | "quote", | ||
| 4491 | "syn 2.0.118", | ||
| 4492 | ] | ||
| 4493 | |||
| 4494 | [[package]] | ||
| 4495 | name = "serde_json" | ||
| 4496 | version = "1.0.150" | ||
| 4497 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4498 | checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | ||
| 4499 | dependencies = [ | ||
| 4500 | "itoa", | ||
| 4501 | "memchr", | ||
| 4502 | "serde", | ||
| 4503 | "serde_core", | ||
| 4504 | "zmij", | ||
| 4505 | ] | ||
| 4506 | |||
| 4507 | [[package]] | ||
| 4508 | name = "serde_repr" | ||
| 4509 | version = "0.1.20" | ||
| 4510 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4511 | checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" | ||
| 4512 | dependencies = [ | ||
| 4513 | "proc-macro2", | ||
| 4514 | "quote", | ||
| 4515 | "syn 2.0.118", | ||
| 4516 | ] | ||
| 4517 | |||
| 4518 | [[package]] | ||
| 4519 | name = "serde_spanned" | ||
| 4520 | version = "0.6.9" | ||
| 4521 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4522 | checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" | ||
| 4523 | dependencies = [ | ||
| 4524 | "serde", | ||
| 4525 | ] | ||
| 4526 | |||
| 4527 | [[package]] | ||
| 4528 | name = "serde_urlencoded" | ||
| 4529 | version = "0.7.1" | ||
| 4530 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4531 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | ||
| 4532 | dependencies = [ | ||
| 4533 | "form_urlencoded", | ||
| 4534 | "itoa", | ||
| 4535 | "ryu", | ||
| 4536 | "serde", | ||
| 4537 | ] | ||
| 4538 | |||
| 4539 | [[package]] | ||
| 4540 | name = "serenity" | ||
| 4541 | version = "0.12.5" | ||
| 4542 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4543 | checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" | ||
| 4544 | dependencies = [ | ||
| 4545 | "arrayvec", | ||
| 4546 | "async-trait", | ||
| 4547 | "base64", | ||
| 4548 | "bitflags 2.13.0", | ||
| 4549 | "bytes", | ||
| 4550 | "chrono", | ||
| 4551 | "command_attr", | ||
| 4552 | "dashmap 5.5.3", | ||
| 4553 | "flate2", | ||
| 4554 | "futures", | ||
| 4555 | "levenshtein", | ||
| 4556 | "mime_guess", | ||
| 4557 | "parking_lot 0.12.5", | ||
| 4558 | "percent-encoding", | ||
| 4559 | "reqwest", | ||
| 4560 | "rustc-hash", | ||
| 4561 | "secrecy", | ||
| 4562 | "serde", | ||
| 4563 | "serde_cow", | ||
| 4564 | "serde_json", | ||
| 4565 | "static_assertions", | ||
| 4566 | "time", | ||
| 4567 | "tokio", | ||
| 4568 | "tokio-tungstenite 0.21.0", | ||
| 4569 | "tracing", | ||
| 4570 | "typemap_rev", | ||
| 4571 | "typesize", | ||
| 4572 | "url", | ||
| 4573 | "uwl", | ||
| 4574 | ] | ||
| 4575 | |||
| 4576 | [[package]] | ||
| 4577 | name = "serenity-voice-model" | ||
| 4578 | version = "0.3.0" | ||
| 4579 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4580 | checksum = "910c6511e59df6fa362921e4900cdbd3192210231a2ba135594c480871422af6" | ||
| 4581 | dependencies = [ | ||
| 4582 | "bitflags 2.13.0", | ||
| 4583 | "num-traits", | ||
| 4584 | "serde", | ||
| 4585 | "serde_json", | ||
| 4586 | "serde_repr", | ||
| 4587 | ] | ||
| 4588 | |||
| 4589 | [[package]] | ||
| 4590 | name = "serenity_poise_macros" | ||
| 4591 | version = "0.6.2" | ||
| 4592 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4593 | checksum = "9bc8407893be0d973a7bb48325e10e0160a0db44f4099467c83179688220e5f5" | ||
| 4594 | dependencies = [ | ||
| 4595 | "darling 0.20.11", | ||
| 4596 | "proc-macro2", | ||
| 4597 | "quote", | ||
| 4598 | "syn 2.0.118", | ||
| 4599 | ] | ||
| 4600 | |||
| 4601 | [[package]] | ||
| 4602 | name = "servo_arc" | ||
| 4603 | version = "0.4.3" | ||
| 4604 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4605 | checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" | ||
| 4606 | dependencies = [ | ||
| 4607 | "stable_deref_trait", | ||
| 4608 | ] | ||
| 4609 | |||
| 4610 | [[package]] | ||
| 4611 | name = "sha1" | ||
| 4612 | version = "0.10.6" | ||
| 4613 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4614 | checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" | ||
| 4615 | dependencies = [ | ||
| 4616 | "cfg-if", | ||
| 4617 | "cpufeatures 0.2.17", | ||
| 4618 | "digest 0.10.7", | ||
| 4619 | ] | ||
| 4620 | |||
| 4621 | [[package]] | ||
| 4622 | name = "sha1" | ||
| 4623 | version = "0.11.0" | ||
| 4624 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4625 | checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | ||
| 4626 | dependencies = [ | ||
| 4627 | "cfg-if", | ||
| 4628 | "cpufeatures 0.3.0", | ||
| 4629 | "digest 0.11.3", | ||
| 4630 | ] | ||
| 4631 | |||
| 4632 | [[package]] | ||
| 4633 | name = "sha1_smol" | ||
| 4634 | version = "1.0.1" | ||
| 4635 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4636 | checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" | ||
| 4637 | |||
| 4638 | [[package]] | ||
| 4639 | name = "sha2" | ||
| 4640 | version = "0.10.9" | ||
| 4641 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4642 | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | ||
| 4643 | dependencies = [ | ||
| 4644 | "cfg-if", | ||
| 4645 | "cpufeatures 0.2.17", | ||
| 4646 | "digest 0.10.7", | ||
| 4647 | ] | ||
| 4648 | |||
| 4649 | [[package]] | ||
| 4650 | name = "sha2" | ||
| 4651 | version = "0.11.0" | ||
| 4652 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4653 | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" | ||
| 4654 | dependencies = [ | ||
| 4655 | "cfg-if", | ||
| 4656 | "cpufeatures 0.3.0", | ||
| 4657 | "digest 0.11.3", | ||
| 4658 | ] | ||
| 4659 | |||
| 4660 | [[package]] | ||
| 4661 | name = "sharded-slab" | ||
| 4662 | version = "0.1.7" | ||
| 4663 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4664 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" | ||
| 4665 | dependencies = [ | ||
| 4666 | "lazy_static", | ||
| 4667 | ] | ||
| 4668 | |||
| 4669 | [[package]] | ||
| 4670 | name = "shlex" | ||
| 4671 | version = "2.0.1" | ||
| 4672 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4673 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | ||
| 4674 | |||
| 4675 | [[package]] | ||
| 4676 | name = "shorthand" | ||
| 4677 | version = "0.1.1" | ||
| 4678 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4679 | checksum = "474f77f985d8212610f170332eaf173e768404c0c1d4deb041f32c297cf18931" | ||
| 4680 | dependencies = [ | ||
| 4681 | "from_map", | ||
| 4682 | "proc-macro2", | ||
| 4683 | "quote", | ||
| 4684 | "syn 1.0.109", | ||
| 4685 | ] | ||
| 4686 | |||
| 4687 | [[package]] | ||
| 4688 | name = "signal-hook-registry" | ||
| 4689 | version = "1.4.8" | ||
| 4690 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4691 | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" | ||
| 4692 | dependencies = [ | ||
| 4693 | "errno", | ||
| 4694 | "libc", | ||
| 4695 | ] | ||
| 4696 | |||
| 4697 | [[package]] | ||
| 4698 | name = "signature" | ||
| 4699 | version = "2.2.0" | ||
| 4700 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4701 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | ||
| 4702 | dependencies = [ | ||
| 4703 | "digest 0.10.7", | ||
| 4704 | "rand_core 0.6.4", | ||
| 4705 | ] | ||
| 4706 | |||
| 4707 | [[package]] | ||
| 4708 | name = "simba" | ||
| 4709 | version = "0.8.1" | ||
| 4710 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4711 | checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" | ||
| 4712 | dependencies = [ | ||
| 4713 | "approx", | ||
| 4714 | "num-complex", | ||
| 4715 | "num-traits", | ||
| 4716 | "paste", | ||
| 4717 | "wide", | ||
| 4718 | ] | ||
| 4719 | |||
| 4720 | [[package]] | ||
| 4721 | name = "simd-adler32" | ||
| 4722 | version = "0.3.9" | ||
| 4723 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4724 | checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" | ||
| 4725 | |||
| 4726 | [[package]] | ||
| 4727 | name = "simd_helpers" | ||
| 4728 | version = "0.1.0" | ||
| 4729 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4730 | checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" | ||
| 4731 | dependencies = [ | ||
| 4732 | "quote", | ||
| 4733 | ] | ||
| 4734 | |||
| 4735 | [[package]] | ||
| 4736 | name = "simdutf8" | ||
| 4737 | version = "0.1.5" | ||
| 4738 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4739 | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | ||
| 4740 | |||
| 4741 | [[package]] | ||
| 4742 | name = "siphasher" | ||
| 4743 | version = "1.0.3" | ||
| 4744 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4745 | checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" | ||
| 4746 | |||
| 4747 | [[package]] | ||
| 4748 | name = "skeptic" | ||
| 4749 | version = "0.13.7" | ||
| 4750 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4751 | checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" | ||
| 4752 | dependencies = [ | ||
| 4753 | "bytecount", | ||
| 4754 | "cargo_metadata", | ||
| 4755 | "error-chain", | ||
| 4756 | "glob", | ||
| 4757 | "pulldown-cmark", | ||
| 4758 | "tempfile", | ||
| 4759 | "walkdir", | ||
| 4760 | ] | ||
| 4761 | |||
| 4762 | [[package]] | ||
| 4763 | name = "slab" | ||
| 4764 | version = "0.4.12" | ||
| 4765 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4766 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | ||
| 4767 | |||
| 4768 | [[package]] | ||
| 4769 | name = "smallvec" | ||
| 4770 | version = "1.15.2" | ||
| 4771 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4772 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | ||
| 4773 | dependencies = [ | ||
| 4774 | "serde", | ||
| 4775 | ] | ||
| 4776 | |||
| 4777 | [[package]] | ||
| 4778 | name = "socket2" | ||
| 4779 | version = "0.5.10" | ||
| 4780 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4781 | checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" | ||
| 4782 | dependencies = [ | ||
| 4783 | "libc", | ||
| 4784 | "windows-sys 0.52.0", | ||
| 4785 | ] | ||
| 4786 | |||
| 4787 | [[package]] | ||
| 4788 | name = "socket2" | ||
| 4789 | version = "0.6.4" | ||
| 4790 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4791 | checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" | ||
| 4792 | dependencies = [ | ||
| 4793 | "libc", | ||
| 4794 | "windows-sys 0.61.2", | ||
| 4795 | ] | ||
| 4796 | |||
| 4797 | [[package]] | ||
| 4798 | name = "songbird" | ||
| 4799 | version = "0.6.0" | ||
| 4800 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4801 | checksum = "5b9bf4ecb5083685b79a8fc60222f12e4f036e2817c5c341cd2b9ff2b429d237" | ||
| 4802 | dependencies = [ | ||
| 4803 | "aead", | ||
| 4804 | "aes-gcm", | ||
| 4805 | "async-trait", | ||
| 4806 | "byteorder", | ||
| 4807 | "bytes", | ||
| 4808 | "chacha20poly1305", | ||
| 4809 | "crypto-common 0.1.7", | ||
| 4810 | "dashmap 6.2.1", | ||
| 4811 | "davey", | ||
| 4812 | "derivative", | ||
| 4813 | "discortp", | ||
| 4814 | "either", | ||
| 4815 | "enum-map", | ||
| 4816 | "flume 0.11.1", | ||
| 4817 | "futures", | ||
| 4818 | "nohash-hasher", | ||
| 4819 | "nonmax", | ||
| 4820 | "opus2", | ||
| 4821 | "parking_lot 0.12.5", | ||
| 4822 | "pin-project", | ||
| 4823 | "rand 0.9.4", | ||
| 4824 | "reqwest", | ||
| 4825 | "ringbuf", | ||
| 4826 | "rubato", | ||
| 4827 | "rusty_pool", | ||
| 4828 | "serde", | ||
| 4829 | "serde-aux", | ||
| 4830 | "serde_json", | ||
| 4831 | "serenity", | ||
| 4832 | "serenity-voice-model", | ||
| 4833 | "socket2 0.5.10", | ||
| 4834 | "stream_lib", | ||
| 4835 | "streamcatcher", | ||
| 4836 | "symphonia 0.5.5", | ||
| 4837 | "symphonia-core 0.5.5", | ||
| 4838 | "tokio", | ||
| 4839 | "tokio-tungstenite 0.26.2", | ||
| 4840 | "tokio-util", | ||
| 4841 | "tokio-websockets", | ||
| 4842 | "tracing", | ||
| 4843 | "tracing-futures", | ||
| 4844 | "twilight-gateway", | ||
| 4845 | "typenum", | ||
| 4846 | "url", | ||
| 4847 | "uuid", | ||
| 4848 | ] | ||
| 4849 | |||
| 4850 | [[package]] | ||
| 4851 | name = "spin" | ||
| 4852 | version = "0.9.8" | ||
| 4853 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4854 | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" | ||
| 4855 | dependencies = [ | ||
| 4856 | "lock_api", | ||
| 4857 | ] | ||
| 4858 | |||
| 4859 | [[package]] | ||
| 4860 | name = "spki" | ||
| 4861 | version = "0.7.3" | ||
| 4862 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4863 | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" | ||
| 4864 | dependencies = [ | ||
| 4865 | "base64ct", | ||
| 4866 | "der", | ||
| 4867 | ] | ||
| 4868 | |||
| 4869 | [[package]] | ||
| 4870 | name = "sqlx" | ||
| 4871 | version = "0.9.0" | ||
| 4872 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4873 | checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" | ||
| 4874 | dependencies = [ | ||
| 4875 | "sqlx-core", | ||
| 4876 | "sqlx-macros", | ||
| 4877 | "sqlx-mysql", | ||
| 4878 | "sqlx-postgres", | ||
| 4879 | "sqlx-sqlite", | ||
| 4880 | ] | ||
| 4881 | |||
| 4882 | [[package]] | ||
| 4883 | name = "sqlx-core" | ||
| 4884 | version = "0.9.0" | ||
| 4885 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4886 | checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" | ||
| 4887 | dependencies = [ | ||
| 4888 | "base64", | ||
| 4889 | "bytes", | ||
| 4890 | "cfg-if", | ||
| 4891 | "chrono", | ||
| 4892 | "crc", | ||
| 4893 | "crossbeam-queue", | ||
| 4894 | "either", | ||
| 4895 | "event-listener", | ||
| 4896 | "futures-core", | ||
| 4897 | "futures-intrusive", | ||
| 4898 | "futures-io", | ||
| 4899 | "futures-util", | ||
| 4900 | "hashbrown 0.16.1", | ||
| 4901 | "hashlink", | ||
| 4902 | "indexmap", | ||
| 4903 | "log", | ||
| 4904 | "memchr", | ||
| 4905 | "percent-encoding", | ||
| 4906 | "serde", | ||
| 4907 | "serde_json", | ||
| 4908 | "sha2 0.10.9", | ||
| 4909 | "smallvec", | ||
| 4910 | "thiserror 2.0.18", | ||
| 4911 | "tokio", | ||
| 4912 | "tokio-stream", | ||
| 4913 | "tracing", | ||
| 4914 | "url", | ||
| 4915 | ] | ||
| 4916 | |||
| 4917 | [[package]] | ||
| 4918 | name = "sqlx-macros" | ||
| 4919 | version = "0.9.0" | ||
| 4920 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4921 | checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" | ||
| 4922 | dependencies = [ | ||
| 4923 | "proc-macro2", | ||
| 4924 | "quote", | ||
| 4925 | "sqlx-core", | ||
| 4926 | "sqlx-macros-core", | ||
| 4927 | "syn 2.0.118", | ||
| 4928 | ] | ||
| 4929 | |||
| 4930 | [[package]] | ||
| 4931 | name = "sqlx-macros-core" | ||
| 4932 | version = "0.9.0" | ||
| 4933 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4934 | checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" | ||
| 4935 | dependencies = [ | ||
| 4936 | "cfg-if", | ||
| 4937 | "dotenvy", | ||
| 4938 | "either", | ||
| 4939 | "heck 0.5.0", | ||
| 4940 | "hex", | ||
| 4941 | "proc-macro2", | ||
| 4942 | "quote", | ||
| 4943 | "serde", | ||
| 4944 | "serde_json", | ||
| 4945 | "sha2 0.10.9", | ||
| 4946 | "sqlx-core", | ||
| 4947 | "sqlx-mysql", | ||
| 4948 | "sqlx-postgres", | ||
| 4949 | "sqlx-sqlite", | ||
| 4950 | "syn 2.0.118", | ||
| 4951 | "thiserror 2.0.18", | ||
| 4952 | "tokio", | ||
| 4953 | "url", | ||
| 4954 | ] | ||
| 4955 | |||
| 4956 | [[package]] | ||
| 4957 | name = "sqlx-mysql" | ||
| 4958 | version = "0.9.0" | ||
| 4959 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4960 | checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" | ||
| 4961 | dependencies = [ | ||
| 4962 | "bitflags 2.13.0", | ||
| 4963 | "byteorder", | ||
| 4964 | "bytes", | ||
| 4965 | "chrono", | ||
| 4966 | "crc", | ||
| 4967 | "digest 0.11.3", | ||
| 4968 | "dotenvy", | ||
| 4969 | "either", | ||
| 4970 | "futures-core", | ||
| 4971 | "futures-util", | ||
| 4972 | "generic-array", | ||
| 4973 | "log", | ||
| 4974 | "percent-encoding", | ||
| 4975 | "serde", | ||
| 4976 | "sha1 0.11.0", | ||
| 4977 | "sha2 0.11.0", | ||
| 4978 | "sqlx-core", | ||
| 4979 | "thiserror 2.0.18", | ||
| 4980 | "tracing", | ||
| 4981 | ] | ||
| 4982 | |||
| 4983 | [[package]] | ||
| 4984 | name = "sqlx-postgres" | ||
| 4985 | version = "0.9.0" | ||
| 4986 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4987 | checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" | ||
| 4988 | dependencies = [ | ||
| 4989 | "atoi", | ||
| 4990 | "base64", | ||
| 4991 | "bitflags 2.13.0", | ||
| 4992 | "byteorder", | ||
| 4993 | "chrono", | ||
| 4994 | "crc", | ||
| 4995 | "dotenvy", | ||
| 4996 | "etcetera", | ||
| 4997 | "futures-channel", | ||
| 4998 | "futures-core", | ||
| 4999 | "futures-util", | ||
| 5000 | "hex", | ||
| 5001 | "hkdf 0.13.0", | ||
| 5002 | "hmac 0.13.0", | ||
| 5003 | "itoa", | ||
| 5004 | "log", | ||
| 5005 | "md-5", | ||
| 5006 | "memchr", | ||
| 5007 | "rand 0.10.1", | ||
| 5008 | "serde", | ||
| 5009 | "serde_json", | ||
| 5010 | "sha2 0.11.0", | ||
| 5011 | "smallvec", | ||
| 5012 | "sqlx-core", | ||
| 5013 | "stringprep", | ||
| 5014 | "thiserror 2.0.18", | ||
| 5015 | "tracing", | ||
| 5016 | "whoami", | ||
| 5017 | ] | ||
| 5018 | |||
| 5019 | [[package]] | ||
| 5020 | name = "sqlx-sqlite" | ||
| 5021 | version = "0.9.0" | ||
| 5022 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5023 | checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" | ||
| 5024 | dependencies = [ | ||
| 5025 | "atoi", | ||
| 5026 | "chrono", | ||
| 5027 | "flume 0.12.0", | ||
| 5028 | "form_urlencoded", | ||
| 5029 | "futures-channel", | ||
| 5030 | "futures-core", | ||
| 5031 | "futures-executor", | ||
| 5032 | "futures-intrusive", | ||
| 5033 | "futures-util", | ||
| 5034 | "libsqlite3-sys", | ||
| 5035 | "log", | ||
| 5036 | "percent-encoding", | ||
| 5037 | "serde", | ||
| 5038 | "sqlx-core", | ||
| 5039 | "thiserror 2.0.18", | ||
| 5040 | "tracing", | ||
| 5041 | "url", | ||
| 5042 | ] | ||
| 5043 | |||
| 5044 | [[package]] | ||
| 5045 | name = "stable-vec" | ||
| 5046 | version = "0.4.2" | ||
| 5047 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5048 | checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" | ||
| 5049 | |||
| 5050 | [[package]] | ||
| 5051 | name = "stable_deref_trait" | ||
| 5052 | version = "1.2.1" | ||
| 5053 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5054 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | ||
| 5055 | |||
| 5056 | [[package]] | ||
| 5057 | name = "static_assertions" | ||
| 5058 | version = "1.1.0" | ||
| 5059 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5060 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | ||
| 5061 | |||
| 5062 | [[package]] | ||
| 5063 | name = "stream_lib" | ||
| 5064 | version = "0.5.2" | ||
| 5065 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5066 | checksum = "e106dd009a0dfd2cf57777c39cad08f852debd366df6e841b250d956cec3277e" | ||
| 5067 | dependencies = [ | ||
| 5068 | "bytes", | ||
| 5069 | "futures-core", | ||
| 5070 | "futures-util", | ||
| 5071 | "hls_m3u8", | ||
| 5072 | "patricia_tree", | ||
| 5073 | "reqwest", | ||
| 5074 | "tokio", | ||
| 5075 | "tracing", | ||
| 5076 | "url", | ||
| 5077 | ] | ||
| 5078 | |||
| 5079 | [[package]] | ||
| 5080 | name = "streamcatcher" | ||
| 5081 | version = "1.0.1" | ||
| 5082 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5083 | checksum = "71664755c349abb0758fda6218fb2d2391ca2a73f9302c03b145491db4fcea29" | ||
| 5084 | dependencies = [ | ||
| 5085 | "crossbeam-utils", | ||
| 5086 | "futures-util", | ||
| 5087 | "loom", | ||
| 5088 | ] | ||
| 5089 | |||
| 5090 | [[package]] | ||
| 5091 | name = "strength_reduce" | ||
| 5092 | version = "0.2.4" | ||
| 5093 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5094 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" | ||
| 5095 | |||
| 5096 | [[package]] | ||
| 5097 | name = "string_cache" | ||
| 5098 | version = "0.9.0" | ||
| 5099 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5100 | checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" | ||
| 5101 | dependencies = [ | ||
| 5102 | "new_debug_unreachable", | ||
| 5103 | "parking_lot 0.12.5", | ||
| 5104 | "phf_shared 0.13.1", | ||
| 5105 | "precomputed-hash", | ||
| 5106 | ] | ||
| 5107 | |||
| 5108 | [[package]] | ||
| 5109 | name = "string_cache_codegen" | ||
| 5110 | version = "0.6.1" | ||
| 5111 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5112 | checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" | ||
| 5113 | dependencies = [ | ||
| 5114 | "phf_generator 0.13.1", | ||
| 5115 | "phf_shared 0.13.1", | ||
| 5116 | "proc-macro2", | ||
| 5117 | "quote", | ||
| 5118 | ] | ||
| 5119 | |||
| 5120 | [[package]] | ||
| 5121 | name = "stringprep" | ||
| 5122 | version = "0.1.5" | ||
| 5123 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5124 | checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" | ||
| 5125 | dependencies = [ | ||
| 5126 | "unicode-bidi", | ||
| 5127 | "unicode-normalization", | ||
| 5128 | "unicode-properties", | ||
| 5129 | ] | ||
| 5130 | |||
| 5131 | [[package]] | ||
| 5132 | name = "strsim" | ||
| 5133 | version = "0.9.3" | ||
| 5134 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5135 | checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" | ||
| 5136 | |||
| 5137 | [[package]] | ||
| 5138 | name = "strsim" | ||
| 5139 | version = "0.11.1" | ||
| 5140 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5141 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | ||
| 5142 | |||
| 5143 | [[package]] | ||
| 5144 | name = "strum" | ||
| 5145 | version = "0.17.1" | ||
| 5146 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5147 | checksum = "530efb820d53b712f4e347916c5e7ed20deb76a4f0457943b3182fb889b06d2c" | ||
| 5148 | dependencies = [ | ||
| 5149 | "strum_macros", | ||
| 5150 | ] | ||
| 5151 | |||
| 5152 | [[package]] | ||
| 5153 | name = "strum_macros" | ||
| 5154 | version = "0.17.1" | ||
| 5155 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5156 | checksum = "5e6e163a520367c465f59e0a61a23cfae3b10b6546d78b6f672a382be79f7110" | ||
| 5157 | dependencies = [ | ||
| 5158 | "heck 0.3.3", | ||
| 5159 | "proc-macro2", | ||
| 5160 | "quote", | ||
| 5161 | "syn 1.0.109", | ||
| 5162 | ] | ||
| 5163 | |||
| 5164 | [[package]] | ||
| 5165 | name = "subtle" | ||
| 5166 | version = "2.6.1" | ||
| 5167 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5168 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | ||
| 5169 | |||
| 5170 | [[package]] | ||
| 5171 | name = "symphonia" | ||
| 5172 | version = "0.5.5" | ||
| 5173 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5174 | checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" | ||
| 5175 | dependencies = [ | ||
| 5176 | "lazy_static", | ||
| 5177 | "symphonia-bundle-flac 0.5.5", | ||
| 5178 | "symphonia-codec-adpcm", | ||
| 5179 | "symphonia-codec-pcm", | ||
| 5180 | "symphonia-codec-vorbis", | ||
| 5181 | "symphonia-core 0.5.5", | ||
| 5182 | "symphonia-format-mkv", | ||
| 5183 | "symphonia-format-ogg 0.5.5", | ||
| 5184 | "symphonia-format-riff 0.5.5", | ||
| 5185 | "symphonia-metadata 0.5.5", | ||
| 5186 | ] | ||
| 5187 | |||
| 5188 | [[package]] | ||
| 5189 | name = "symphonia" | ||
| 5190 | version = "0.6.0" | ||
| 5191 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5192 | checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" | ||
| 5193 | dependencies = [ | ||
| 5194 | "lazy_static", | ||
| 5195 | "symphonia-bundle-flac 0.6.0", | ||
| 5196 | "symphonia-bundle-mp3", | ||
| 5197 | "symphonia-codec-aac", | ||
| 5198 | "symphonia-codec-alac", | ||
| 5199 | "symphonia-core 0.6.0", | ||
| 5200 | "symphonia-format-isomp4", | ||
| 5201 | "symphonia-format-ogg 0.6.0", | ||
| 5202 | "symphonia-format-riff 0.6.0", | ||
| 5203 | "symphonia-metadata 0.6.0", | ||
| 5204 | ] | ||
| 5205 | |||
| 5206 | [[package]] | ||
| 5207 | name = "symphonia-bundle-flac" | ||
| 5208 | version = "0.5.5" | ||
| 5209 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5210 | checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" | ||
| 5211 | dependencies = [ | ||
| 5212 | "log", | ||
| 5213 | "symphonia-core 0.5.5", | ||
| 5214 | "symphonia-metadata 0.5.5", | ||
| 5215 | "symphonia-utils-xiph", | ||
| 5216 | ] | ||
| 5217 | |||
| 5218 | [[package]] | ||
| 5219 | name = "symphonia-bundle-flac" | ||
| 5220 | version = "0.6.0" | ||
| 5221 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5222 | checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" | ||
| 5223 | dependencies = [ | ||
| 5224 | "log", | ||
| 5225 | "symphonia-common", | ||
| 5226 | "symphonia-core 0.6.0", | ||
| 5227 | "symphonia-metadata 0.6.0", | ||
| 5228 | ] | ||
| 5229 | |||
| 5230 | [[package]] | ||
| 5231 | name = "symphonia-bundle-mp3" | ||
| 5232 | version = "0.6.0" | ||
| 5233 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5234 | checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" | ||
| 5235 | dependencies = [ | ||
| 5236 | "lazy_static", | ||
| 5237 | "log", | ||
| 5238 | "symphonia-core 0.6.0", | ||
| 5239 | ] | ||
| 5240 | |||
| 5241 | [[package]] | ||
| 5242 | name = "symphonia-codec-aac" | ||
| 5243 | version = "0.6.0" | ||
| 5244 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5245 | checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" | ||
| 5246 | dependencies = [ | ||
| 5247 | "lazy_static", | ||
| 5248 | "log", | ||
| 5249 | "symphonia-common", | ||
| 5250 | "symphonia-core 0.6.0", | ||
| 5251 | ] | ||
| 5252 | |||
| 5253 | [[package]] | ||
| 5254 | name = "symphonia-codec-adpcm" | ||
| 5255 | version = "0.5.5" | ||
| 5256 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5257 | checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" | ||
| 5258 | dependencies = [ | ||
| 5259 | "log", | ||
| 5260 | "symphonia-core 0.5.5", | ||
| 5261 | ] | ||
| 5262 | |||
| 5263 | [[package]] | ||
| 5264 | name = "symphonia-codec-alac" | ||
| 5265 | version = "0.6.0" | ||
| 5266 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5267 | checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8" | ||
| 5268 | dependencies = [ | ||
| 5269 | "log", | ||
| 5270 | "symphonia-common", | ||
| 5271 | "symphonia-core 0.6.0", | ||
| 5272 | ] | ||
| 5273 | |||
| 5274 | [[package]] | ||
| 5275 | name = "symphonia-codec-pcm" | ||
| 5276 | version = "0.5.5" | ||
| 5277 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5278 | checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" | ||
| 5279 | dependencies = [ | ||
| 5280 | "log", | ||
| 5281 | "symphonia-core 0.5.5", | ||
| 5282 | ] | ||
| 5283 | |||
| 5284 | [[package]] | ||
| 5285 | name = "symphonia-codec-vorbis" | ||
| 5286 | version = "0.5.5" | ||
| 5287 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5288 | checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" | ||
| 5289 | dependencies = [ | ||
| 5290 | "log", | ||
| 5291 | "symphonia-core 0.5.5", | ||
| 5292 | "symphonia-utils-xiph", | ||
| 5293 | ] | ||
| 5294 | |||
| 5295 | [[package]] | ||
| 5296 | name = "symphonia-common" | ||
| 5297 | version = "0.6.0" | ||
| 5298 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5299 | checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" | ||
| 5300 | dependencies = [ | ||
| 5301 | "log", | ||
| 5302 | "symphonia-core 0.6.0", | ||
| 5303 | "symphonia-metadata 0.6.0", | ||
| 5304 | ] | ||
| 5305 | |||
| 5306 | [[package]] | ||
| 5307 | name = "symphonia-core" | ||
| 5308 | version = "0.5.5" | ||
| 5309 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5310 | checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" | ||
| 5311 | dependencies = [ | ||
| 5312 | "arrayvec", | ||
| 5313 | "bitflags 1.3.2", | ||
| 5314 | "bytemuck", | ||
| 5315 | "lazy_static", | ||
| 5316 | "log", | ||
| 5317 | ] | ||
| 5318 | |||
| 5319 | [[package]] | ||
| 5320 | name = "symphonia-core" | ||
| 5321 | version = "0.6.0" | ||
| 5322 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5323 | checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" | ||
| 5324 | dependencies = [ | ||
| 5325 | "bitflags 2.13.0", | ||
| 5326 | "bytemuck", | ||
| 5327 | "lazy_static", | ||
| 5328 | "log", | ||
| 5329 | "num-complex", | ||
| 5330 | "smallvec", | ||
| 5331 | ] | ||
| 5332 | |||
| 5333 | [[package]] | ||
| 5334 | name = "symphonia-format-isomp4" | ||
| 5335 | version = "0.6.0" | ||
| 5336 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5337 | checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" | ||
| 5338 | dependencies = [ | ||
| 5339 | "log", | ||
| 5340 | "symphonia-common", | ||
| 5341 | "symphonia-core 0.6.0", | ||
| 5342 | "symphonia-metadata 0.6.0", | ||
| 5343 | ] | ||
| 5344 | |||
| 5345 | [[package]] | ||
| 5346 | name = "symphonia-format-mkv" | ||
| 5347 | version = "0.5.5" | ||
| 5348 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5349 | checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" | ||
| 5350 | dependencies = [ | ||
| 5351 | "lazy_static", | ||
| 5352 | "log", | ||
| 5353 | "symphonia-core 0.5.5", | ||
| 5354 | "symphonia-metadata 0.5.5", | ||
| 5355 | "symphonia-utils-xiph", | ||
| 5356 | ] | ||
| 5357 | |||
| 5358 | [[package]] | ||
| 5359 | name = "symphonia-format-ogg" | ||
| 5360 | version = "0.5.5" | ||
| 5361 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5362 | checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" | ||
| 5363 | dependencies = [ | ||
| 5364 | "log", | ||
| 5365 | "symphonia-core 0.5.5", | ||
| 5366 | "symphonia-metadata 0.5.5", | ||
| 5367 | "symphonia-utils-xiph", | ||
| 5368 | ] | ||
| 5369 | |||
| 5370 | [[package]] | ||
| 5371 | name = "symphonia-format-ogg" | ||
| 5372 | version = "0.6.0" | ||
| 5373 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5374 | checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" | ||
| 5375 | dependencies = [ | ||
| 5376 | "log", | ||
| 5377 | "symphonia-common", | ||
| 5378 | "symphonia-core 0.6.0", | ||
| 5379 | "symphonia-metadata 0.6.0", | ||
| 5380 | ] | ||
| 5381 | |||
| 5382 | [[package]] | ||
| 5383 | name = "symphonia-format-riff" | ||
| 5384 | version = "0.5.5" | ||
| 5385 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5386 | checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" | ||
| 5387 | dependencies = [ | ||
| 5388 | "extended", | ||
| 5389 | "log", | ||
| 5390 | "symphonia-core 0.5.5", | ||
| 5391 | "symphonia-metadata 0.5.5", | ||
| 5392 | ] | ||
| 5393 | |||
| 5394 | [[package]] | ||
| 5395 | name = "symphonia-format-riff" | ||
| 5396 | version = "0.6.0" | ||
| 5397 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5398 | checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" | ||
| 5399 | dependencies = [ | ||
| 5400 | "extended", | ||
| 5401 | "log", | ||
| 5402 | "symphonia-core 0.6.0", | ||
| 5403 | "symphonia-metadata 0.6.0", | ||
| 5404 | ] | ||
| 5405 | |||
| 5406 | [[package]] | ||
| 5407 | name = "symphonia-metadata" | ||
| 5408 | version = "0.5.5" | ||
| 5409 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5410 | checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" | ||
| 5411 | dependencies = [ | ||
| 5412 | "encoding_rs", | ||
| 5413 | "lazy_static", | ||
| 5414 | "log", | ||
| 5415 | "symphonia-core 0.5.5", | ||
| 5416 | ] | ||
| 5417 | |||
| 5418 | [[package]] | ||
| 5419 | name = "symphonia-metadata" | ||
| 5420 | version = "0.6.0" | ||
| 5421 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5422 | checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" | ||
| 5423 | dependencies = [ | ||
| 5424 | "lazy_static", | ||
| 5425 | "log", | ||
| 5426 | "regex-lite", | ||
| 5427 | "smallvec", | ||
| 5428 | "symphonia-core 0.6.0", | ||
| 5429 | ] | ||
| 5430 | |||
| 5431 | [[package]] | ||
| 5432 | name = "symphonia-utils-xiph" | ||
| 5433 | version = "0.5.5" | ||
| 5434 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5435 | checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" | ||
| 5436 | dependencies = [ | ||
| 5437 | "symphonia-core 0.5.5", | ||
| 5438 | "symphonia-metadata 0.5.5", | ||
| 5439 | ] | ||
| 5440 | |||
| 5441 | [[package]] | ||
| 5442 | name = "syn" | ||
| 5443 | version = "1.0.109" | ||
| 5444 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5445 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" | ||
| 5446 | dependencies = [ | ||
| 5447 | "proc-macro2", | ||
| 5448 | "quote", | ||
| 5449 | "unicode-ident", | ||
| 5450 | ] | ||
| 5451 | |||
| 5452 | [[package]] | ||
| 5453 | name = "syn" | ||
| 5454 | version = "2.0.118" | ||
| 5455 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5456 | checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" | ||
| 5457 | dependencies = [ | ||
| 5458 | "proc-macro2", | ||
| 5459 | "quote", | ||
| 5460 | "unicode-ident", | ||
| 5461 | ] | ||
| 5462 | |||
| 5463 | [[package]] | ||
| 5464 | name = "sync_wrapper" | ||
| 5465 | version = "1.0.2" | ||
| 5466 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5467 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | ||
| 5468 | dependencies = [ | ||
| 5469 | "futures-core", | ||
| 5470 | ] | ||
| 5471 | |||
| 5472 | [[package]] | ||
| 5473 | name = "synstructure" | ||
| 5474 | version = "0.13.2" | ||
| 5475 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5476 | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" | ||
| 5477 | dependencies = [ | ||
| 5478 | "proc-macro2", | ||
| 5479 | "quote", | ||
| 5480 | "syn 2.0.118", | ||
| 5481 | ] | ||
| 5482 | |||
| 5483 | [[package]] | ||
| 5484 | name = "system-configuration" | ||
| 5485 | version = "0.7.0" | ||
| 5486 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5487 | checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" | ||
| 5488 | dependencies = [ | ||
| 5489 | "bitflags 2.13.0", | ||
| 5490 | "core-foundation 0.9.4", | ||
| 5491 | "system-configuration-sys", | ||
| 5492 | ] | ||
| 5493 | |||
| 5494 | [[package]] | ||
| 5495 | name = "system-configuration-sys" | ||
| 5496 | version = "0.6.0" | ||
| 5497 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5498 | checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" | ||
| 5499 | dependencies = [ | ||
| 5500 | "core-foundation-sys", | ||
| 5501 | "libc", | ||
| 5502 | ] | ||
| 5503 | |||
| 5504 | [[package]] | ||
| 5505 | name = "system-deps" | ||
| 5506 | version = "6.2.2" | ||
| 5507 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5508 | checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" | ||
| 5509 | dependencies = [ | ||
| 5510 | "cfg-expr", | ||
| 5511 | "heck 0.5.0", | ||
| 5512 | "pkg-config", | ||
| 5513 | "toml", | ||
| 5514 | "version-compare", | ||
| 5515 | ] | ||
| 5516 | |||
| 5517 | [[package]] | ||
| 5518 | name = "tagptr" | ||
| 5519 | version = "0.2.0" | ||
| 5520 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5521 | checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" | ||
| 5522 | |||
| 5523 | [[package]] | ||
| 5524 | name = "target-lexicon" | ||
| 5525 | version = "0.12.16" | ||
| 5526 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5527 | checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" | ||
| 5528 | |||
| 5529 | [[package]] | ||
| 5530 | name = "tempfile" | ||
| 5531 | version = "3.27.0" | ||
| 5532 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5533 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | ||
| 5534 | dependencies = [ | ||
| 5535 | "fastrand", | ||
| 5536 | "getrandom 0.4.3", | ||
| 5537 | "once_cell", | ||
| 5538 | "rustix", | ||
| 5539 | "windows-sys 0.61.2", | ||
| 5540 | ] | ||
| 5541 | |||
| 5542 | [[package]] | ||
| 5543 | name = "tendril" | ||
| 5544 | version = "0.5.0" | ||
| 5545 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5546 | checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" | ||
| 5547 | dependencies = [ | ||
| 5548 | "new_debug_unreachable", | ||
| 5549 | "utf-8", | ||
| 5550 | ] | ||
| 5551 | |||
| 5552 | [[package]] | ||
| 5553 | name = "thiserror" | ||
| 5554 | version = "1.0.69" | ||
| 5555 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5556 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" | ||
| 5557 | dependencies = [ | ||
| 5558 | "thiserror-impl 1.0.69", | ||
| 5559 | ] | ||
| 5560 | |||
| 5561 | [[package]] | ||
| 5562 | name = "thiserror" | ||
| 5563 | version = "2.0.18" | ||
| 5564 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5565 | checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" | ||
| 5566 | dependencies = [ | ||
| 5567 | "thiserror-impl 2.0.18", | ||
| 5568 | ] | ||
| 5569 | |||
| 5570 | [[package]] | ||
| 5571 | name = "thiserror-impl" | ||
| 5572 | version = "1.0.69" | ||
| 5573 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5574 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" | ||
| 5575 | dependencies = [ | ||
| 5576 | "proc-macro2", | ||
| 5577 | "quote", | ||
| 5578 | "syn 2.0.118", | ||
| 5579 | ] | ||
| 5580 | |||
| 5581 | [[package]] | ||
| 5582 | name = "thiserror-impl" | ||
| 5583 | version = "2.0.18" | ||
| 5584 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5585 | checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" | ||
| 5586 | dependencies = [ | ||
| 5587 | "proc-macro2", | ||
| 5588 | "quote", | ||
| 5589 | "syn 2.0.118", | ||
| 5590 | ] | ||
| 5591 | |||
| 5592 | [[package]] | ||
| 5593 | name = "thread_local" | ||
| 5594 | version = "1.1.9" | ||
| 5595 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5596 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" | ||
| 5597 | dependencies = [ | ||
| 5598 | "cfg-if", | ||
| 5599 | ] | ||
| 5600 | |||
| 5601 | [[package]] | ||
| 5602 | name = "tiff" | ||
| 5603 | version = "0.10.3" | ||
| 5604 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5605 | checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" | ||
| 5606 | dependencies = [ | ||
| 5607 | "fax", | ||
| 5608 | "flate2", | ||
| 5609 | "half", | ||
| 5610 | "quick-error", | ||
| 5611 | "weezl", | ||
| 5612 | "zune-jpeg", | ||
| 5613 | ] | ||
| 5614 | |||
| 5615 | [[package]] | ||
| 5616 | name = "time" | ||
| 5617 | version = "0.3.49" | ||
| 5618 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5619 | checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" | ||
| 5620 | dependencies = [ | ||
| 5621 | "deranged", | ||
| 5622 | "num-conv", | ||
| 5623 | "powerfmt", | ||
| 5624 | "serde_core", | ||
| 5625 | "time-core", | ||
| 5626 | "time-macros", | ||
| 5627 | ] | ||
| 5628 | |||
| 5629 | [[package]] | ||
| 5630 | name = "time-core" | ||
| 5631 | version = "0.1.9" | ||
| 5632 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5633 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | ||
| 5634 | |||
| 5635 | [[package]] | ||
| 5636 | name = "time-macros" | ||
| 5637 | version = "0.2.29" | ||
| 5638 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5639 | checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" | ||
| 5640 | dependencies = [ | ||
| 5641 | "num-conv", | ||
| 5642 | "time-core", | ||
| 5643 | ] | ||
| 5644 | |||
| 5645 | [[package]] | ||
| 5646 | name = "tinystr" | ||
| 5647 | version = "0.8.3" | ||
| 5648 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5649 | checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" | ||
| 5650 | dependencies = [ | ||
| 5651 | "displaydoc", | ||
| 5652 | "zerovec", | ||
| 5653 | ] | ||
| 5654 | |||
| 5655 | [[package]] | ||
| 5656 | name = "tinyvec" | ||
| 5657 | version = "1.11.0" | ||
| 5658 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5659 | checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" | ||
| 5660 | dependencies = [ | ||
| 5661 | "tinyvec_macros", | ||
| 5662 | ] | ||
| 5663 | |||
| 5664 | [[package]] | ||
| 5665 | name = "tinyvec_macros" | ||
| 5666 | version = "0.1.1" | ||
| 5667 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5668 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | ||
| 5669 | |||
| 5670 | [[package]] | ||
| 5671 | name = "tls_codec" | ||
| 5672 | version = "0.4.2" | ||
| 5673 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5674 | checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" | ||
| 5675 | dependencies = [ | ||
| 5676 | "serde", | ||
| 5677 | "tls_codec_derive", | ||
| 5678 | "zeroize", | ||
| 5679 | ] | ||
| 5680 | |||
| 5681 | [[package]] | ||
| 5682 | name = "tls_codec_derive" | ||
| 5683 | version = "0.4.2" | ||
| 5684 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5685 | checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" | ||
| 5686 | dependencies = [ | ||
| 5687 | "proc-macro2", | ||
| 5688 | "quote", | ||
| 5689 | "syn 2.0.118", | ||
| 5690 | ] | ||
| 5691 | |||
| 5692 | [[package]] | ||
| 5693 | name = "tokio" | ||
| 5694 | version = "1.52.3" | ||
| 5695 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5696 | checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" | ||
| 5697 | dependencies = [ | ||
| 5698 | "bytes", | ||
| 5699 | "libc", | ||
| 5700 | "mio", | ||
| 5701 | "pin-project-lite", | ||
| 5702 | "signal-hook-registry", | ||
| 5703 | "socket2 0.6.4", | ||
| 5704 | "tokio-macros", | ||
| 5705 | "windows-sys 0.61.2", | ||
| 5706 | ] | ||
| 5707 | |||
| 5708 | [[package]] | ||
| 5709 | name = "tokio-macros" | ||
| 5710 | version = "2.7.0" | ||
| 5711 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5712 | checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" | ||
| 5713 | dependencies = [ | ||
| 5714 | "proc-macro2", | ||
| 5715 | "quote", | ||
| 5716 | "syn 2.0.118", | ||
| 5717 | ] | ||
| 5718 | |||
| 5719 | [[package]] | ||
| 5720 | name = "tokio-native-tls" | ||
| 5721 | version = "0.3.1" | ||
| 5722 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5723 | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" | ||
| 5724 | dependencies = [ | ||
| 5725 | "native-tls", | ||
| 5726 | "tokio", | ||
| 5727 | ] | ||
| 5728 | |||
| 5729 | [[package]] | ||
| 5730 | name = "tokio-rustls" | ||
| 5731 | version = "0.25.0" | ||
| 5732 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5733 | checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" | ||
| 5734 | dependencies = [ | ||
| 5735 | "rustls 0.22.4", | ||
| 5736 | "rustls-pki-types", | ||
| 5737 | "tokio", | ||
| 5738 | ] | ||
| 5739 | |||
| 5740 | [[package]] | ||
| 5741 | name = "tokio-rustls" | ||
| 5742 | version = "0.26.4" | ||
| 5743 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5744 | checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" | ||
| 5745 | dependencies = [ | ||
| 5746 | "rustls 0.23.40", | ||
| 5747 | "tokio", | ||
| 5748 | ] | ||
| 5749 | |||
| 5750 | [[package]] | ||
| 5751 | name = "tokio-stream" | ||
| 5752 | version = "0.1.18" | ||
| 5753 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5754 | checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" | ||
| 5755 | dependencies = [ | ||
| 5756 | "futures-core", | ||
| 5757 | "pin-project-lite", | ||
| 5758 | "tokio", | ||
| 5759 | ] | ||
| 5760 | |||
| 5761 | [[package]] | ||
| 5762 | name = "tokio-tungstenite" | ||
| 5763 | version = "0.21.0" | ||
| 5764 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5765 | checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" | ||
| 5766 | dependencies = [ | ||
| 5767 | "futures-util", | ||
| 5768 | "log", | ||
| 5769 | "rustls 0.22.4", | ||
| 5770 | "rustls-pki-types", | ||
| 5771 | "tokio", | ||
| 5772 | "tokio-rustls 0.25.0", | ||
| 5773 | "tungstenite 0.21.0", | ||
| 5774 | "webpki-roots 0.26.11", | ||
| 5775 | ] | ||
| 5776 | |||
| 5777 | [[package]] | ||
| 5778 | name = "tokio-tungstenite" | ||
| 5779 | version = "0.26.2" | ||
| 5780 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5781 | checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" | ||
| 5782 | dependencies = [ | ||
| 5783 | "futures-util", | ||
| 5784 | "log", | ||
| 5785 | "rustls 0.23.40", | ||
| 5786 | "rustls-pki-types", | ||
| 5787 | "tokio", | ||
| 5788 | "tokio-rustls 0.26.4", | ||
| 5789 | "tungstenite 0.26.2", | ||
| 5790 | "webpki-roots 0.26.11", | ||
| 5791 | ] | ||
| 5792 | |||
| 5793 | [[package]] | ||
| 5794 | name = "tokio-util" | ||
| 5795 | version = "0.7.18" | ||
| 5796 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5797 | checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" | ||
| 5798 | dependencies = [ | ||
| 5799 | "bytes", | ||
| 5800 | "futures-core", | ||
| 5801 | "futures-sink", | ||
| 5802 | "pin-project-lite", | ||
| 5803 | "tokio", | ||
| 5804 | ] | ||
| 5805 | |||
| 5806 | [[package]] | ||
| 5807 | name = "tokio-websockets" | ||
| 5808 | version = "0.11.4" | ||
| 5809 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5810 | checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" | ||
| 5811 | dependencies = [ | ||
| 5812 | "base64", | ||
| 5813 | "bytes", | ||
| 5814 | "fastrand", | ||
| 5815 | "futures-core", | ||
| 5816 | "futures-sink", | ||
| 5817 | "http", | ||
| 5818 | "httparse", | ||
| 5819 | "ring", | ||
| 5820 | "rustls-native-certs", | ||
| 5821 | "rustls-pki-types", | ||
| 5822 | "sha1_smol", | ||
| 5823 | "simdutf8", | ||
| 5824 | "tokio", | ||
| 5825 | "tokio-rustls 0.26.4", | ||
| 5826 | "tokio-util", | ||
| 5827 | ] | ||
| 5828 | |||
| 5829 | [[package]] | ||
| 5830 | name = "toml" | ||
| 5831 | version = "0.8.23" | ||
| 5832 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5833 | checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" | ||
| 5834 | dependencies = [ | ||
| 5835 | "serde", | ||
| 5836 | "serde_spanned", | ||
| 5837 | "toml_datetime", | ||
| 5838 | "toml_edit", | ||
| 5839 | ] | ||
| 5840 | |||
| 5841 | [[package]] | ||
| 5842 | name = "toml_datetime" | ||
| 5843 | version = "0.6.11" | ||
| 5844 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5845 | checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" | ||
| 5846 | dependencies = [ | ||
| 5847 | "serde", | ||
| 5848 | ] | ||
| 5849 | |||
| 5850 | [[package]] | ||
| 5851 | name = "toml_edit" | ||
| 5852 | version = "0.22.27" | ||
| 5853 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5854 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" | ||
| 5855 | dependencies = [ | ||
| 5856 | "indexmap", | ||
| 5857 | "serde", | ||
| 5858 | "serde_spanned", | ||
| 5859 | "toml_datetime", | ||
| 5860 | "winnow", | ||
| 5861 | ] | ||
| 5862 | |||
| 5863 | [[package]] | ||
| 5864 | name = "tower" | ||
| 5865 | version = "0.5.3" | ||
| 5866 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5867 | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | ||
| 5868 | dependencies = [ | ||
| 5869 | "futures-core", | ||
| 5870 | "futures-util", | ||
| 5871 | "pin-project-lite", | ||
| 5872 | "sync_wrapper", | ||
| 5873 | "tokio", | ||
| 5874 | "tower-layer", | ||
| 5875 | "tower-service", | ||
| 5876 | ] | ||
| 5877 | |||
| 5878 | [[package]] | ||
| 5879 | name = "tower-http" | ||
| 5880 | version = "0.6.11" | ||
| 5881 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5882 | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" | ||
| 5883 | dependencies = [ | ||
| 5884 | "bitflags 2.13.0", | ||
| 5885 | "bytes", | ||
| 5886 | "futures-util", | ||
| 5887 | "http", | ||
| 5888 | "http-body", | ||
| 5889 | "pin-project-lite", | ||
| 5890 | "tower", | ||
| 5891 | "tower-layer", | ||
| 5892 | "tower-service", | ||
| 5893 | "url", | ||
| 5894 | ] | ||
| 5895 | |||
| 5896 | [[package]] | ||
| 5897 | name = "tower-layer" | ||
| 5898 | version = "0.3.3" | ||
| 5899 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5900 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | ||
| 5901 | |||
| 5902 | [[package]] | ||
| 5903 | name = "tower-service" | ||
| 5904 | version = "0.3.3" | ||
| 5905 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5906 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | ||
| 5907 | |||
| 5908 | [[package]] | ||
| 5909 | name = "tracing" | ||
| 5910 | version = "0.1.44" | ||
| 5911 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5912 | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | ||
| 5913 | dependencies = [ | ||
| 5914 | "log", | ||
| 5915 | "pin-project-lite", | ||
| 5916 | "tracing-attributes", | ||
| 5917 | "tracing-core", | ||
| 5918 | ] | ||
| 5919 | |||
| 5920 | [[package]] | ||
| 5921 | name = "tracing-attributes" | ||
| 5922 | version = "0.1.31" | ||
| 5923 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5924 | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | ||
| 5925 | dependencies = [ | ||
| 5926 | "proc-macro2", | ||
| 5927 | "quote", | ||
| 5928 | "syn 2.0.118", | ||
| 5929 | ] | ||
| 5930 | |||
| 5931 | [[package]] | ||
| 5932 | name = "tracing-core" | ||
| 5933 | version = "0.1.36" | ||
| 5934 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5935 | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | ||
| 5936 | dependencies = [ | ||
| 5937 | "once_cell", | ||
| 5938 | "valuable", | ||
| 5939 | ] | ||
| 5940 | |||
| 5941 | [[package]] | ||
| 5942 | name = "tracing-futures" | ||
| 5943 | version = "0.2.5" | ||
| 5944 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5945 | checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" | ||
| 5946 | dependencies = [ | ||
| 5947 | "pin-project", | ||
| 5948 | "tracing", | ||
| 5949 | ] | ||
| 5950 | |||
| 5951 | [[package]] | ||
| 5952 | name = "tracing-log" | ||
| 5953 | version = "0.2.0" | ||
| 5954 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5955 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" | ||
| 5956 | dependencies = [ | ||
| 5957 | "log", | ||
| 5958 | "once_cell", | ||
| 5959 | "tracing-core", | ||
| 5960 | ] | ||
| 5961 | |||
| 5962 | [[package]] | ||
| 5963 | name = "tracing-subscriber" | ||
| 5964 | version = "0.3.23" | ||
| 5965 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5966 | checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" | ||
| 5967 | dependencies = [ | ||
| 5968 | "matchers", | ||
| 5969 | "nu-ansi-term", | ||
| 5970 | "once_cell", | ||
| 5971 | "regex-automata", | ||
| 5972 | "sharded-slab", | ||
| 5973 | "smallvec", | ||
| 5974 | "thread_local", | ||
| 5975 | "tracing", | ||
| 5976 | "tracing-core", | ||
| 5977 | "tracing-log", | ||
| 5978 | ] | ||
| 5979 | |||
| 5980 | [[package]] | ||
| 5981 | name = "transpose" | ||
| 5982 | version = "0.2.3" | ||
| 5983 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5984 | checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" | ||
| 5985 | dependencies = [ | ||
| 5986 | "num-integer", | ||
| 5987 | "strength_reduce", | ||
| 5988 | ] | ||
| 5989 | |||
| 5990 | [[package]] | ||
| 5991 | name = "trim-in-place" | ||
| 5992 | version = "0.1.7" | ||
| 5993 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5994 | checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" | ||
| 5995 | |||
| 5996 | [[package]] | ||
| 5997 | name = "triomphe" | ||
| 5998 | version = "0.1.15" | ||
| 5999 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6000 | checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" | ||
| 6001 | |||
| 6002 | [[package]] | ||
| 6003 | name = "try-lock" | ||
| 6004 | version = "0.2.5" | ||
| 6005 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6006 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | ||
| 6007 | |||
| 6008 | [[package]] | ||
| 6009 | name = "ttf-parser" | ||
| 6010 | version = "0.25.1" | ||
| 6011 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6012 | checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" | ||
| 6013 | |||
| 6014 | [[package]] | ||
| 6015 | name = "tungstenite" | ||
| 6016 | version = "0.21.0" | ||
| 6017 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6018 | checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" | ||
| 6019 | dependencies = [ | ||
| 6020 | "byteorder", | ||
| 6021 | "bytes", | ||
| 6022 | "data-encoding", | ||
| 6023 | "http", | ||
| 6024 | "httparse", | ||
| 6025 | "log", | ||
| 6026 | "rand 0.8.6", | ||
| 6027 | "rustls 0.22.4", | ||
| 6028 | "rustls-pki-types", | ||
| 6029 | "sha1 0.10.6", | ||
| 6030 | "thiserror 1.0.69", | ||
| 6031 | "url", | ||
| 6032 | "utf-8", | ||
| 6033 | ] | ||
| 6034 | |||
| 6035 | [[package]] | ||
| 6036 | name = "tungstenite" | ||
| 6037 | version = "0.26.2" | ||
| 6038 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6039 | checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" | ||
| 6040 | dependencies = [ | ||
| 6041 | "bytes", | ||
| 6042 | "data-encoding", | ||
| 6043 | "http", | ||
| 6044 | "httparse", | ||
| 6045 | "log", | ||
| 6046 | "rand 0.9.4", | ||
| 6047 | "rustls 0.23.40", | ||
| 6048 | "rustls-pki-types", | ||
| 6049 | "sha1 0.10.6", | ||
| 6050 | "thiserror 2.0.18", | ||
| 6051 | "url", | ||
| 6052 | "utf-8", | ||
| 6053 | ] | ||
| 6054 | |||
| 6055 | [[package]] | ||
| 6056 | name = "twilight-gateway" | ||
| 6057 | version = "0.16.0" | ||
| 6058 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6059 | checksum = "863ef55467bcf6a2958162766fd0b72f33112e36971b37f9ec09b86d4fd0f7d1" | ||
| 6060 | dependencies = [ | ||
| 6061 | "bitflags 2.13.0", | ||
| 6062 | "fastrand", | ||
| 6063 | "futures-core", | ||
| 6064 | "futures-sink", | ||
| 6065 | "serde", | ||
| 6066 | "serde_json", | ||
| 6067 | "tokio", | ||
| 6068 | "tokio-websockets", | ||
| 6069 | "tracing", | ||
| 6070 | "twilight-gateway-queue", | ||
| 6071 | "twilight-model", | ||
| 6072 | ] | ||
| 6073 | |||
| 6074 | [[package]] | ||
| 6075 | name = "twilight-gateway-queue" | ||
| 6076 | version = "0.16.0" | ||
| 6077 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6078 | checksum = "27c962bd4693da0a215abe6b431fd73eb87111f49c431b87951780f9f7985002" | ||
| 6079 | dependencies = [ | ||
| 6080 | "tokio", | ||
| 6081 | "tracing", | ||
| 6082 | ] | ||
| 6083 | |||
| 6084 | [[package]] | ||
| 6085 | name = "twilight-model" | ||
| 6086 | version = "0.16.0" | ||
| 6087 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6088 | checksum = "191e2efa051dfbd9bed4c9f6bd3f5e007bda909c687a1db2760371a3d566617d" | ||
| 6089 | dependencies = [ | ||
| 6090 | "bitflags 2.13.0", | ||
| 6091 | "serde", | ||
| 6092 | "serde-value", | ||
| 6093 | "serde_repr", | ||
| 6094 | "time", | ||
| 6095 | ] | ||
| 6096 | |||
| 6097 | [[package]] | ||
| 6098 | name = "typemap_rev" | ||
| 6099 | version = "0.3.0" | ||
| 6100 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6101 | checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998" | ||
| 6102 | |||
| 6103 | [[package]] | ||
| 6104 | name = "typenum" | ||
| 6105 | version = "1.20.1" | ||
| 6106 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6107 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | ||
| 6108 | |||
| 6109 | [[package]] | ||
| 6110 | name = "typesize" | ||
| 6111 | version = "0.1.15" | ||
| 6112 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6113 | checksum = "a313188364f7e10138257cb288c64e9b0e282510abefc91c796799559fa14762" | ||
| 6114 | dependencies = [ | ||
| 6115 | "chrono", | ||
| 6116 | "dashmap 5.5.3", | ||
| 6117 | "hashbrown 0.14.5", | ||
| 6118 | "mini-moka", | ||
| 6119 | "parking_lot 0.12.5", | ||
| 6120 | "secrecy", | ||
| 6121 | "serde_json", | ||
| 6122 | "time", | ||
| 6123 | "typesize-derive", | ||
| 6124 | "url", | ||
| 6125 | ] | ||
| 6126 | |||
| 6127 | [[package]] | ||
| 6128 | name = "typesize-derive" | ||
| 6129 | version = "0.1.11" | ||
| 6130 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6131 | checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" | ||
| 6132 | dependencies = [ | ||
| 6133 | "proc-macro2", | ||
| 6134 | "quote", | ||
| 6135 | "syn 2.0.118", | ||
| 6136 | ] | ||
| 6137 | |||
| 6138 | [[package]] | ||
| 6139 | name = "unicase" | ||
| 6140 | version = "2.9.0" | ||
| 6141 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6142 | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | ||
| 6143 | |||
| 6144 | [[package]] | ||
| 6145 | name = "unicode-bidi" | ||
| 6146 | version = "0.3.18" | ||
| 6147 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6148 | checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" | ||
| 6149 | |||
| 6150 | [[package]] | ||
| 6151 | name = "unicode-ident" | ||
| 6152 | version = "1.0.24" | ||
| 6153 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6154 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
| 6155 | |||
| 6156 | [[package]] | ||
| 6157 | name = "unicode-normalization" | ||
| 6158 | version = "0.1.25" | ||
| 6159 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6160 | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | ||
| 6161 | dependencies = [ | ||
| 6162 | "tinyvec", | ||
| 6163 | ] | ||
| 6164 | |||
| 6165 | [[package]] | ||
| 6166 | name = "unicode-properties" | ||
| 6167 | version = "0.1.4" | ||
| 6168 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6169 | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" | ||
| 6170 | |||
| 6171 | [[package]] | ||
| 6172 | name = "unicode-segmentation" | ||
| 6173 | version = "1.13.3" | ||
| 6174 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6175 | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | ||
| 6176 | |||
| 6177 | [[package]] | ||
| 6178 | name = "unicode-width" | ||
| 6179 | version = "0.2.2" | ||
| 6180 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6181 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | ||
| 6182 | |||
| 6183 | [[package]] | ||
| 6184 | name = "universal-hash" | ||
| 6185 | version = "0.5.1" | ||
| 6186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6187 | checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" | ||
| 6188 | dependencies = [ | ||
| 6189 | "crypto-common 0.1.7", | ||
| 6190 | "subtle", | ||
| 6191 | ] | ||
| 6192 | |||
| 6193 | [[package]] | ||
| 6194 | name = "untrusted" | ||
| 6195 | version = "0.9.0" | ||
| 6196 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6197 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | ||
| 6198 | |||
| 6199 | [[package]] | ||
| 6200 | name = "uriparse" | ||
| 6201 | version = "0.6.4" | ||
| 6202 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6203 | checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" | ||
| 6204 | dependencies = [ | ||
| 6205 | "fnv", | ||
| 6206 | "lazy_static", | ||
| 6207 | ] | ||
| 6208 | |||
| 6209 | [[package]] | ||
| 6210 | name = "url" | ||
| 6211 | version = "2.5.8" | ||
| 6212 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6213 | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" | ||
| 6214 | dependencies = [ | ||
| 6215 | "form_urlencoded", | ||
| 6216 | "idna", | ||
| 6217 | "percent-encoding", | ||
| 6218 | "serde", | ||
| 6219 | "serde_derive", | ||
| 6220 | ] | ||
| 6221 | |||
| 6222 | [[package]] | ||
| 6223 | name = "urlencoding" | ||
| 6224 | version = "2.1.3" | ||
| 6225 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6226 | checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" | ||
| 6227 | |||
| 6228 | [[package]] | ||
| 6229 | name = "utf-8" | ||
| 6230 | version = "0.7.6" | ||
| 6231 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6232 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" | ||
| 6233 | |||
| 6234 | [[package]] | ||
| 6235 | name = "utf8_iter" | ||
| 6236 | version = "1.0.4" | ||
| 6237 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6238 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | ||
| 6239 | |||
| 6240 | [[package]] | ||
| 6241 | name = "uuid" | ||
| 6242 | version = "1.23.3" | ||
| 6243 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6244 | checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" | ||
| 6245 | dependencies = [ | ||
| 6246 | "getrandom 0.4.3", | ||
| 6247 | "js-sys", | ||
| 6248 | "wasm-bindgen", | ||
| 6249 | ] | ||
| 6250 | |||
| 6251 | [[package]] | ||
| 6252 | name = "uwl" | ||
| 6253 | version = "0.6.0" | ||
| 6254 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6255 | checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" | ||
| 6256 | |||
| 6257 | [[package]] | ||
| 6258 | name = "v_frame" | ||
| 6259 | version = "0.3.9" | ||
| 6260 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6261 | checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" | ||
| 6262 | dependencies = [ | ||
| 6263 | "aligned-vec", | ||
| 6264 | "num-traits", | ||
| 6265 | "wasm-bindgen", | ||
| 6266 | ] | ||
| 6267 | |||
| 6268 | [[package]] | ||
| 6269 | name = "valuable" | ||
| 6270 | version = "0.1.1" | ||
| 6271 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6272 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" | ||
| 6273 | |||
| 6274 | [[package]] | ||
| 6275 | name = "vcpkg" | ||
| 6276 | version = "0.2.15" | ||
| 6277 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6278 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | ||
| 6279 | |||
| 6280 | [[package]] | ||
| 6281 | name = "version-compare" | ||
| 6282 | version = "0.2.1" | ||
| 6283 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6284 | checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" | ||
| 6285 | |||
| 6286 | [[package]] | ||
| 6287 | name = "version_check" | ||
| 6288 | version = "0.9.5" | ||
| 6289 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6290 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | ||
| 6291 | |||
| 6292 | [[package]] | ||
| 6293 | name = "walkdir" | ||
| 6294 | version = "2.5.0" | ||
| 6295 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6296 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | ||
| 6297 | dependencies = [ | ||
| 6298 | "same-file", | ||
| 6299 | "winapi-util", | ||
| 6300 | ] | ||
| 6301 | |||
| 6302 | [[package]] | ||
| 6303 | name = "want" | ||
| 6304 | version = "0.3.1" | ||
| 6305 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6306 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | ||
| 6307 | dependencies = [ | ||
| 6308 | "try-lock", | ||
| 6309 | ] | ||
| 6310 | |||
| 6311 | [[package]] | ||
| 6312 | name = "wasi" | ||
| 6313 | version = "0.11.1+wasi-snapshot-preview1" | ||
| 6314 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6315 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | ||
| 6316 | |||
| 6317 | [[package]] | ||
| 6318 | name = "wasip2" | ||
| 6319 | version = "1.0.4+wasi-0.2.12" | ||
| 6320 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6321 | checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" | ||
| 6322 | dependencies = [ | ||
| 6323 | "wit-bindgen", | ||
| 6324 | ] | ||
| 6325 | |||
| 6326 | [[package]] | ||
| 6327 | name = "wasm-bindgen" | ||
| 6328 | version = "0.2.125" | ||
| 6329 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6330 | checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" | ||
| 6331 | dependencies = [ | ||
| 6332 | "cfg-if", | ||
| 6333 | "once_cell", | ||
| 6334 | "rustversion", | ||
| 6335 | "wasm-bindgen-macro", | ||
| 6336 | "wasm-bindgen-shared", | ||
| 6337 | ] | ||
| 6338 | |||
| 6339 | [[package]] | ||
| 6340 | name = "wasm-bindgen-futures" | ||
| 6341 | version = "0.4.75" | ||
| 6342 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6343 | checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" | ||
| 6344 | dependencies = [ | ||
| 6345 | "js-sys", | ||
| 6346 | "wasm-bindgen", | ||
| 6347 | ] | ||
| 6348 | |||
| 6349 | [[package]] | ||
| 6350 | name = "wasm-bindgen-macro" | ||
| 6351 | version = "0.2.125" | ||
| 6352 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6353 | checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" | ||
| 6354 | dependencies = [ | ||
| 6355 | "quote", | ||
| 6356 | "wasm-bindgen-macro-support", | ||
| 6357 | ] | ||
| 6358 | |||
| 6359 | [[package]] | ||
| 6360 | name = "wasm-bindgen-macro-support" | ||
| 6361 | version = "0.2.125" | ||
| 6362 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6363 | checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" | ||
| 6364 | dependencies = [ | ||
| 6365 | "bumpalo", | ||
| 6366 | "proc-macro2", | ||
| 6367 | "quote", | ||
| 6368 | "syn 2.0.118", | ||
| 6369 | "wasm-bindgen-shared", | ||
| 6370 | ] | ||
| 6371 | |||
| 6372 | [[package]] | ||
| 6373 | name = "wasm-bindgen-shared" | ||
| 6374 | version = "0.2.125" | ||
| 6375 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6376 | checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" | ||
| 6377 | dependencies = [ | ||
| 6378 | "unicode-ident", | ||
| 6379 | ] | ||
| 6380 | |||
| 6381 | [[package]] | ||
| 6382 | name = "wasm-streams" | ||
| 6383 | version = "0.4.2" | ||
| 6384 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6385 | checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" | ||
| 6386 | dependencies = [ | ||
| 6387 | "futures-util", | ||
| 6388 | "js-sys", | ||
| 6389 | "wasm-bindgen", | ||
| 6390 | "wasm-bindgen-futures", | ||
| 6391 | "web-sys", | ||
| 6392 | ] | ||
| 6393 | |||
| 6394 | [[package]] | ||
| 6395 | name = "web-sys" | ||
| 6396 | version = "0.3.102" | ||
| 6397 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6398 | checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" | ||
| 6399 | dependencies = [ | ||
| 6400 | "js-sys", | ||
| 6401 | "wasm-bindgen", | ||
| 6402 | ] | ||
| 6403 | |||
| 6404 | [[package]] | ||
| 6405 | name = "web-time" | ||
| 6406 | version = "1.1.0" | ||
| 6407 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6408 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | ||
| 6409 | dependencies = [ | ||
| 6410 | "js-sys", | ||
| 6411 | "wasm-bindgen", | ||
| 6412 | ] | ||
| 6413 | |||
| 6414 | [[package]] | ||
| 6415 | name = "web_atoms" | ||
| 6416 | version = "0.2.5" | ||
| 6417 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6418 | checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" | ||
| 6419 | dependencies = [ | ||
| 6420 | "phf 0.13.1", | ||
| 6421 | "phf_codegen", | ||
| 6422 | "string_cache", | ||
| 6423 | "string_cache_codegen", | ||
| 6424 | ] | ||
| 6425 | |||
| 6426 | [[package]] | ||
| 6427 | name = "webpki-roots" | ||
| 6428 | version = "0.26.11" | ||
| 6429 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6430 | checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" | ||
| 6431 | dependencies = [ | ||
| 6432 | "webpki-roots 1.0.8", | ||
| 6433 | ] | ||
| 6434 | |||
| 6435 | [[package]] | ||
| 6436 | name = "webpki-roots" | ||
| 6437 | version = "1.0.8" | ||
| 6438 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6439 | checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" | ||
| 6440 | dependencies = [ | ||
| 6441 | "rustls-pki-types", | ||
| 6442 | ] | ||
| 6443 | |||
| 6444 | [[package]] | ||
| 6445 | name = "weezl" | ||
| 6446 | version = "0.1.12" | ||
| 6447 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6448 | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | ||
| 6449 | |||
| 6450 | [[package]] | ||
| 6451 | name = "whoami" | ||
| 6452 | version = "2.1.2" | ||
| 6453 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6454 | checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" | ||
| 6455 | |||
| 6456 | [[package]] | ||
| 6457 | name = "wide" | ||
| 6458 | version = "0.7.33" | ||
| 6459 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6460 | checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" | ||
| 6461 | dependencies = [ | ||
| 6462 | "bytemuck", | ||
| 6463 | "safe_arch", | ||
| 6464 | ] | ||
| 6465 | |||
| 6466 | [[package]] | ||
| 6467 | name = "winapi" | ||
| 6468 | version = "0.3.9" | ||
| 6469 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6470 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | ||
| 6471 | dependencies = [ | ||
| 6472 | "winapi-i686-pc-windows-gnu", | ||
| 6473 | "winapi-x86_64-pc-windows-gnu", | ||
| 6474 | ] | ||
| 6475 | |||
| 6476 | [[package]] | ||
| 6477 | name = "winapi-i686-pc-windows-gnu" | ||
| 6478 | version = "0.4.0" | ||
| 6479 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6480 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | ||
| 6481 | |||
| 6482 | [[package]] | ||
| 6483 | name = "winapi-util" | ||
| 6484 | version = "0.1.11" | ||
| 6485 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6486 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | ||
| 6487 | dependencies = [ | ||
| 6488 | "windows-sys 0.61.2", | ||
| 6489 | ] | ||
| 6490 | |||
| 6491 | [[package]] | ||
| 6492 | name = "winapi-x86_64-pc-windows-gnu" | ||
| 6493 | version = "0.4.0" | ||
| 6494 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6495 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | ||
| 6496 | |||
| 6497 | [[package]] | ||
| 6498 | name = "windows" | ||
| 6499 | version = "0.48.0" | ||
| 6500 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6501 | checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" | ||
| 6502 | dependencies = [ | ||
| 6503 | "windows-targets 0.48.5", | ||
| 6504 | ] | ||
| 6505 | |||
| 6506 | [[package]] | ||
| 6507 | name = "windows-core" | ||
| 6508 | version = "0.62.2" | ||
| 6509 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6510 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | ||
| 6511 | dependencies = [ | ||
| 6512 | "windows-implement", | ||
| 6513 | "windows-interface", | ||
| 6514 | "windows-link", | ||
| 6515 | "windows-result", | ||
| 6516 | "windows-strings", | ||
| 6517 | ] | ||
| 6518 | |||
| 6519 | [[package]] | ||
| 6520 | name = "windows-implement" | ||
| 6521 | version = "0.60.2" | ||
| 6522 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6523 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | ||
| 6524 | dependencies = [ | ||
| 6525 | "proc-macro2", | ||
| 6526 | "quote", | ||
| 6527 | "syn 2.0.118", | ||
| 6528 | ] | ||
| 6529 | |||
| 6530 | [[package]] | ||
| 6531 | name = "windows-interface" | ||
| 6532 | version = "0.59.3" | ||
| 6533 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6534 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | ||
| 6535 | dependencies = [ | ||
| 6536 | "proc-macro2", | ||
| 6537 | "quote", | ||
| 6538 | "syn 2.0.118", | ||
| 6539 | ] | ||
| 6540 | |||
| 6541 | [[package]] | ||
| 6542 | name = "windows-link" | ||
| 6543 | version = "0.2.1" | ||
| 6544 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6545 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 6546 | |||
| 6547 | [[package]] | ||
| 6548 | name = "windows-registry" | ||
| 6549 | version = "0.6.1" | ||
| 6550 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6551 | checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" | ||
| 6552 | dependencies = [ | ||
| 6553 | "windows-link", | ||
| 6554 | "windows-result", | ||
| 6555 | "windows-strings", | ||
| 6556 | ] | ||
| 6557 | |||
| 6558 | [[package]] | ||
| 6559 | name = "windows-result" | ||
| 6560 | version = "0.4.1" | ||
| 6561 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6562 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | ||
| 6563 | dependencies = [ | ||
| 6564 | "windows-link", | ||
| 6565 | ] | ||
| 6566 | |||
| 6567 | [[package]] | ||
| 6568 | name = "windows-strings" | ||
| 6569 | version = "0.5.1" | ||
| 6570 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6571 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | ||
| 6572 | dependencies = [ | ||
| 6573 | "windows-link", | ||
| 6574 | ] | ||
| 6575 | |||
| 6576 | [[package]] | ||
| 6577 | name = "windows-sys" | ||
| 6578 | version = "0.52.0" | ||
| 6579 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6580 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | ||
| 6581 | dependencies = [ | ||
| 6582 | "windows-targets 0.52.6", | ||
| 6583 | ] | ||
| 6584 | |||
| 6585 | [[package]] | ||
| 6586 | name = "windows-sys" | ||
| 6587 | version = "0.60.2" | ||
| 6588 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6589 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" | ||
| 6590 | dependencies = [ | ||
| 6591 | "windows-targets 0.53.5", | ||
| 6592 | ] | ||
| 6593 | |||
| 6594 | [[package]] | ||
| 6595 | name = "windows-sys" | ||
| 6596 | version = "0.61.2" | ||
| 6597 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6598 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 6599 | dependencies = [ | ||
| 6600 | "windows-link", | ||
| 6601 | ] | ||
| 6602 | |||
| 6603 | [[package]] | ||
| 6604 | name = "windows-targets" | ||
| 6605 | version = "0.48.5" | ||
| 6606 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6607 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" | ||
| 6608 | dependencies = [ | ||
| 6609 | "windows_aarch64_gnullvm 0.48.5", | ||
| 6610 | "windows_aarch64_msvc 0.48.5", | ||
| 6611 | "windows_i686_gnu 0.48.5", | ||
| 6612 | "windows_i686_msvc 0.48.5", | ||
| 6613 | "windows_x86_64_gnu 0.48.5", | ||
| 6614 | "windows_x86_64_gnullvm 0.48.5", | ||
| 6615 | "windows_x86_64_msvc 0.48.5", | ||
| 6616 | ] | ||
| 6617 | |||
| 6618 | [[package]] | ||
| 6619 | name = "windows-targets" | ||
| 6620 | version = "0.52.6" | ||
| 6621 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6622 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | ||
| 6623 | dependencies = [ | ||
| 6624 | "windows_aarch64_gnullvm 0.52.6", | ||
| 6625 | "windows_aarch64_msvc 0.52.6", | ||
| 6626 | "windows_i686_gnu 0.52.6", | ||
| 6627 | "windows_i686_gnullvm 0.52.6", | ||
| 6628 | "windows_i686_msvc 0.52.6", | ||
| 6629 | "windows_x86_64_gnu 0.52.6", | ||
| 6630 | "windows_x86_64_gnullvm 0.52.6", | ||
| 6631 | "windows_x86_64_msvc 0.52.6", | ||
| 6632 | ] | ||
| 6633 | |||
| 6634 | [[package]] | ||
| 6635 | name = "windows-targets" | ||
| 6636 | version = "0.53.5" | ||
| 6637 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6638 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" | ||
| 6639 | dependencies = [ | ||
| 6640 | "windows-link", | ||
| 6641 | "windows_aarch64_gnullvm 0.53.1", | ||
| 6642 | "windows_aarch64_msvc 0.53.1", | ||
| 6643 | "windows_i686_gnu 0.53.1", | ||
| 6644 | "windows_i686_gnullvm 0.53.1", | ||
| 6645 | "windows_i686_msvc 0.53.1", | ||
| 6646 | "windows_x86_64_gnu 0.53.1", | ||
| 6647 | "windows_x86_64_gnullvm 0.53.1", | ||
| 6648 | "windows_x86_64_msvc 0.53.1", | ||
| 6649 | ] | ||
| 6650 | |||
| 6651 | [[package]] | ||
| 6652 | name = "windows_aarch64_gnullvm" | ||
| 6653 | version = "0.48.5" | ||
| 6654 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6655 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" | ||
| 6656 | |||
| 6657 | [[package]] | ||
| 6658 | name = "windows_aarch64_gnullvm" | ||
| 6659 | version = "0.52.6" | ||
| 6660 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6661 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | ||
| 6662 | |||
| 6663 | [[package]] | ||
| 6664 | name = "windows_aarch64_gnullvm" | ||
| 6665 | version = "0.53.1" | ||
| 6666 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6667 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" | ||
| 6668 | |||
| 6669 | [[package]] | ||
| 6670 | name = "windows_aarch64_msvc" | ||
| 6671 | version = "0.48.5" | ||
| 6672 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6673 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" | ||
| 6674 | |||
| 6675 | [[package]] | ||
| 6676 | name = "windows_aarch64_msvc" | ||
| 6677 | version = "0.52.6" | ||
| 6678 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6679 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | ||
| 6680 | |||
| 6681 | [[package]] | ||
| 6682 | name = "windows_aarch64_msvc" | ||
| 6683 | version = "0.53.1" | ||
| 6684 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6685 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" | ||
| 6686 | |||
| 6687 | [[package]] | ||
| 6688 | name = "windows_i686_gnu" | ||
| 6689 | version = "0.48.5" | ||
| 6690 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6691 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" | ||
| 6692 | |||
| 6693 | [[package]] | ||
| 6694 | name = "windows_i686_gnu" | ||
| 6695 | version = "0.52.6" | ||
| 6696 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6697 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | ||
| 6698 | |||
| 6699 | [[package]] | ||
| 6700 | name = "windows_i686_gnu" | ||
| 6701 | version = "0.53.1" | ||
| 6702 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6703 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" | ||
| 6704 | |||
| 6705 | [[package]] | ||
| 6706 | name = "windows_i686_gnullvm" | ||
| 6707 | version = "0.52.6" | ||
| 6708 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6709 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | ||
| 6710 | |||
| 6711 | [[package]] | ||
| 6712 | name = "windows_i686_gnullvm" | ||
| 6713 | version = "0.53.1" | ||
| 6714 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6715 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" | ||
| 6716 | |||
| 6717 | [[package]] | ||
| 6718 | name = "windows_i686_msvc" | ||
| 6719 | version = "0.48.5" | ||
| 6720 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6721 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" | ||
| 6722 | |||
| 6723 | [[package]] | ||
| 6724 | name = "windows_i686_msvc" | ||
| 6725 | version = "0.52.6" | ||
| 6726 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6727 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | ||
| 6728 | |||
| 6729 | [[package]] | ||
| 6730 | name = "windows_i686_msvc" | ||
| 6731 | version = "0.53.1" | ||
| 6732 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6733 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" | ||
| 6734 | |||
| 6735 | [[package]] | ||
| 6736 | name = "windows_x86_64_gnu" | ||
| 6737 | version = "0.48.5" | ||
| 6738 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6739 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" | ||
| 6740 | |||
| 6741 | [[package]] | ||
| 6742 | name = "windows_x86_64_gnu" | ||
| 6743 | version = "0.52.6" | ||
| 6744 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6745 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | ||
| 6746 | |||
| 6747 | [[package]] | ||
| 6748 | name = "windows_x86_64_gnu" | ||
| 6749 | version = "0.53.1" | ||
| 6750 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6751 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" | ||
| 6752 | |||
| 6753 | [[package]] | ||
| 6754 | name = "windows_x86_64_gnullvm" | ||
| 6755 | version = "0.48.5" | ||
| 6756 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6757 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" | ||
| 6758 | |||
| 6759 | [[package]] | ||
| 6760 | name = "windows_x86_64_gnullvm" | ||
| 6761 | version = "0.52.6" | ||
| 6762 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6763 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | ||
| 6764 | |||
| 6765 | [[package]] | ||
| 6766 | name = "windows_x86_64_gnullvm" | ||
| 6767 | version = "0.53.1" | ||
| 6768 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6769 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" | ||
| 6770 | |||
| 6771 | [[package]] | ||
| 6772 | name = "windows_x86_64_msvc" | ||
| 6773 | version = "0.48.5" | ||
| 6774 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6775 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" | ||
| 6776 | |||
| 6777 | [[package]] | ||
| 6778 | name = "windows_x86_64_msvc" | ||
| 6779 | version = "0.52.6" | ||
| 6780 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6781 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | ||
| 6782 | |||
| 6783 | [[package]] | ||
| 6784 | name = "windows_x86_64_msvc" | ||
| 6785 | version = "0.53.1" | ||
| 6786 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6787 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" | ||
| 6788 | |||
| 6789 | [[package]] | ||
| 6790 | name = "winnow" | ||
| 6791 | version = "0.7.15" | ||
| 6792 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6793 | checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" | ||
| 6794 | dependencies = [ | ||
| 6795 | "memchr", | ||
| 6796 | ] | ||
| 6797 | |||
| 6798 | [[package]] | ||
| 6799 | name = "wit-bindgen" | ||
| 6800 | version = "0.57.1" | ||
| 6801 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6802 | checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" | ||
| 6803 | |||
| 6804 | [[package]] | ||
| 6805 | name = "writeable" | ||
| 6806 | version = "0.6.3" | ||
| 6807 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6808 | checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" | ||
| 6809 | |||
| 6810 | [[package]] | ||
| 6811 | name = "x25519-dalek" | ||
| 6812 | version = "2.0.1" | ||
| 6813 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6814 | checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" | ||
| 6815 | dependencies = [ | ||
| 6816 | "curve25519-dalek", | ||
| 6817 | "rand_core 0.6.4", | ||
| 6818 | "serde", | ||
| 6819 | "zeroize", | ||
| 6820 | ] | ||
| 6821 | |||
| 6822 | [[package]] | ||
| 6823 | name = "yoke" | ||
| 6824 | version = "0.8.3" | ||
| 6825 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6826 | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | ||
| 6827 | dependencies = [ | ||
| 6828 | "stable_deref_trait", | ||
| 6829 | "yoke-derive", | ||
| 6830 | "zerofrom", | ||
| 6831 | ] | ||
| 6832 | |||
| 6833 | [[package]] | ||
| 6834 | name = "yoke-derive" | ||
| 6835 | version = "0.8.2" | ||
| 6836 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6837 | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" | ||
| 6838 | dependencies = [ | ||
| 6839 | "proc-macro2", | ||
| 6840 | "quote", | ||
| 6841 | "syn 2.0.118", | ||
| 6842 | "synstructure", | ||
| 6843 | ] | ||
| 6844 | |||
| 6845 | [[package]] | ||
| 6846 | name = "zerocopy" | ||
| 6847 | version = "0.8.52" | ||
| 6848 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6849 | checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" | ||
| 6850 | dependencies = [ | ||
| 6851 | "zerocopy-derive", | ||
| 6852 | ] | ||
| 6853 | |||
| 6854 | [[package]] | ||
| 6855 | name = "zerocopy-derive" | ||
| 6856 | version = "0.8.52" | ||
| 6857 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6858 | checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" | ||
| 6859 | dependencies = [ | ||
| 6860 | "proc-macro2", | ||
| 6861 | "quote", | ||
| 6862 | "syn 2.0.118", | ||
| 6863 | ] | ||
| 6864 | |||
| 6865 | [[package]] | ||
| 6866 | name = "zerofrom" | ||
| 6867 | version = "0.1.8" | ||
| 6868 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6869 | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | ||
| 6870 | dependencies = [ | ||
| 6871 | "zerofrom-derive", | ||
| 6872 | ] | ||
| 6873 | |||
| 6874 | [[package]] | ||
| 6875 | name = "zerofrom-derive" | ||
| 6876 | version = "0.1.7" | ||
| 6877 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6878 | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" | ||
| 6879 | dependencies = [ | ||
| 6880 | "proc-macro2", | ||
| 6881 | "quote", | ||
| 6882 | "syn 2.0.118", | ||
| 6883 | "synstructure", | ||
| 6884 | ] | ||
| 6885 | |||
| 6886 | [[package]] | ||
| 6887 | name = "zeroize" | ||
| 6888 | version = "1.9.0" | ||
| 6889 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6890 | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" | ||
| 6891 | dependencies = [ | ||
| 6892 | "zeroize_derive", | ||
| 6893 | ] | ||
| 6894 | |||
| 6895 | [[package]] | ||
| 6896 | name = "zeroize_derive" | ||
| 6897 | version = "1.5.0" | ||
| 6898 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6899 | checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" | ||
| 6900 | dependencies = [ | ||
| 6901 | "proc-macro2", | ||
| 6902 | "quote", | ||
| 6903 | "syn 2.0.118", | ||
| 6904 | ] | ||
| 6905 | |||
| 6906 | [[package]] | ||
| 6907 | name = "zerotrie" | ||
| 6908 | version = "0.2.4" | ||
| 6909 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6910 | checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" | ||
| 6911 | dependencies = [ | ||
| 6912 | "displaydoc", | ||
| 6913 | "yoke", | ||
| 6914 | "zerofrom", | ||
| 6915 | ] | ||
| 6916 | |||
| 6917 | [[package]] | ||
| 6918 | name = "zerovec" | ||
| 6919 | version = "0.11.6" | ||
| 6920 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6921 | checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" | ||
| 6922 | dependencies = [ | ||
| 6923 | "yoke", | ||
| 6924 | "zerofrom", | ||
| 6925 | "zerovec-derive", | ||
| 6926 | ] | ||
| 6927 | |||
| 6928 | [[package]] | ||
| 6929 | name = "zerovec-derive" | ||
| 6930 | version = "0.11.3" | ||
| 6931 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6932 | checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" | ||
| 6933 | dependencies = [ | ||
| 6934 | "proc-macro2", | ||
| 6935 | "quote", | ||
| 6936 | "syn 2.0.118", | ||
| 6937 | ] | ||
| 6938 | |||
| 6939 | [[package]] | ||
| 6940 | name = "zmij" | ||
| 6941 | version = "1.0.21" | ||
| 6942 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6943 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | ||
| 6944 | |||
| 6945 | [[package]] | ||
| 6946 | name = "zune-core" | ||
| 6947 | version = "0.4.12" | ||
| 6948 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6949 | checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" | ||
| 6950 | |||
| 6951 | [[package]] | ||
| 6952 | name = "zune-inflate" | ||
| 6953 | version = "0.2.54" | ||
| 6954 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6955 | checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" | ||
| 6956 | dependencies = [ | ||
| 6957 | "simd-adler32", | ||
| 6958 | ] | ||
| 6959 | |||
| 6960 | [[package]] | ||
| 6961 | name = "zune-jpeg" | ||
| 6962 | version = "0.4.21" | ||
| 6963 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6964 | checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" | ||
| 6965 | dependencies = [ | ||
| 6966 | "zune-core", | ||
| 6967 | ] | ||
diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a56fe2 --- /dev/null +++ b/Cargo.toml | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | [package] | ||
| 2 | name = "senator-sperma" | ||
| 3 | version = "0.2.1" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [profile.dev] | ||
| 7 | debug = 1 | ||
| 8 | |||
| 9 | [dependencies] | ||
| 10 | # Discord | ||
| 11 | poise = "0.6.2" | ||
| 12 | serenity = "0.12.5" | ||
| 13 | songbird = "0.6" | ||
| 14 | |||
| 15 | # Async runtime | ||
| 16 | tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "process", "time", "io-util"] } | ||
| 17 | |||
| 18 | # Database | ||
| 19 | sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "chrono"] } | ||
| 20 | |||
| 21 | # HTTP | ||
| 22 | reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } | ||
| 23 | rustls = { version = "0.23", features = ["ring"] } | ||
| 24 | |||
| 25 | # Serialization | ||
| 26 | serde = { version = "1.0.228", features = ["derive"] } | ||
| 27 | serde_json = "1.0.150" | ||
| 28 | |||
| 29 | # Config | ||
| 30 | dotenvy = "0.15.7" | ||
| 31 | |||
| 32 | # Logging | ||
| 33 | tracing = "0.1.44" | ||
| 34 | tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } | ||
| 35 | |||
| 36 | # Time & Cron | ||
| 37 | chrono = { version = "0.4.45", features = ["serde"] } | ||
| 38 | cron = "0.17" | ||
| 39 | |||
| 40 | # QR | ||
| 41 | qrcode = "0.14.1" | ||
| 42 | image = "=0.25.8" | ||
| 43 | rxing = { version = "0.8", default-features = false, features = ["image", "encoding_rs"] } | ||
| 44 | |||
| 45 | # HTML parsing | ||
| 46 | scraper = "0.27" | ||
| 47 | |||
| 48 | # Audio metadata | ||
| 49 | symphonia = { version = "0.6", default-features = false, features = ["mp3", "aac", "alac", "flac", "ogg", "wav", "isomp4"] } | ||
| 50 | # Enable pcm codec on songbird's internal symphonia 0.5 for RawReader support | ||
| 51 | songbird-codec-pcm = { package = "symphonia", version = "0.5", features = ["pcm"] } | ||
| 52 | |||
| 53 | # Utilities | ||
| 54 | rand = "0.10.1" | ||
| 55 | uuid = { version = "1.23.3", features = ["v4"] } | ||
| 56 | glob = "0.3.3" | ||
| 57 | tempfile = "3.27" | ||
| 58 | futures-util = "0.3" | ||
| @@ -1,92 +1,123 @@ | |||
| 1 | # Senator Sperma | 1 | # Senator Sperma (Rust) |
| 2 | 2 | ||
| 3 | A Discord bot with music playback, TTS, web search, and utility commands. | 3 | A Discord bot rebuilt in Rust from the original Python implementation. Music |
| 4 | playback via songbird, AI chat via OpenRouter or anything OpenAI compatible (ollama.cpp and such), TTS, web/file search | ||
| 5 | scheduled jobs, per-server triggers, QR codes, media conversion, and misc utilities. | ||
| 4 | 6 | ||
| 5 | Requires Python 3.14. | 7 | Built on poise 0.6 + serenity 0.12 + songbird 0.6, with async SQLite (sqlx 0.9) |
| 8 | for persistence. | ||
| 6 | 9 | ||
| 7 | ## Setup | 10 | ## Setup |
| 8 | 11 | ||
| 9 | ### 1. Clone & Create Virtual Environment | 12 | ### 1. Clone |
| 10 | 13 | ||
| 11 | ```bash | 14 | ```bash |
| 12 | git clone https://codeberg.org/salmonik/senator-sperma | 15 | git clone https://codeberg.org/salmonik/senator-sperma |
| 13 | cd senator-sperma | 16 | cd senator-sperma |
| 14 | python -m venv .venv | ||
| 15 | source .venv/bin/activate # Linux/macOS | ||
| 16 | # or .venv\Scripts\activate # Windows | ||
| 17 | ``` | 17 | ``` |
| 18 | 18 | ||
| 19 | ### 2. Install Dependencies | 19 | ### 2. System Dependencies |
| 20 | 20 | ||
| 21 | ```bash | 21 | The following binaries must be available on `PATH`: |
| 22 | pip install -r requirements.txt | 22 | |
| 23 | ``` | 23 | - `ffmpeg` — audio decoding/encoding for playback, conversion, and compression |
| 24 | - `qalc` (Qalculate) — used by `/math` to evaluate expressions | ||
| 25 | - `yt-dlp` (optional) — used by `/download` | ||
| 24 | 26 | ||
| 25 | You also need `ffmpeg` and `zbar` library and installed system-wide | 27 | Unlike the Python version, QR encode/decode is handled by the pure-Rust |
| 28 | `qrcode`/`rxing` crates — no `zbar` system library required. | ||
| 26 | 29 | ||
| 27 | ### 3. Create .env File | 30 | ### 3. Build |
| 28 | 31 | ||
| 29 | Create a `.env` file in the project root: | 32 | Requires a recent Rust toolchain (edition 2024). |
| 30 | 33 | ||
| 31 | ```bash | 34 | ```bash |
| 32 | cp .env.example .env | 35 | cargo build --release |
| 33 | ``` | 36 | ``` |
| 34 | 37 | ||
| 35 | Edit `.env` with your credentials: | 38 | ### 4. Configure Environment |
| 36 | 39 | ||
| 37 | ```env | 40 | Create a `.env` in the project root |
| 38 | # Required | 41 | look at `.env.example` on what to put there |
| 39 | TOKEN=your_discord_bot_token | ||
| 40 | 42 | ||
| 41 | # Optional - Bot Configuration | 43 | On launch, missing variables are reported via tracing (`error!` for required, |
| 42 | STATUS_CHANNEL_ID=123456789012345678 # Channel for startup message | 44 | `warn!` for optional with defaults). The database and schema migrations are |
| 43 | ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant | 45 | created automatically on first run. |
| 44 | 46 | ||
| 45 | # Optional - File Server (for music /search commands) | 47 | ### 5. Run |
| 46 | COPYPARTY_URL=http://localhost:8080 | ||
| 47 | COPYPARTY_PASSWORD=your_password | ||
| 48 | |||
| 49 | # Optional - AI commands | ||
| 50 | OPENROUTER_API_KEY= | ||
| 51 | ``` | ||
| 52 | |||
| 53 | ### 6. Run the Bot | ||
| 54 | 48 | ||
| 55 | ```bash | 49 | ```bash |
| 56 | python main.py | 50 | cargo run --release |
| 57 | ``` | 51 | ``` |
| 58 | 52 | ||
| 59 | ## Configuration | ||
| 60 | |||
| 61 | All settings are optional - defaults are provided if not set. | ||
| 62 | |||
| 63 | | Variable | Description | Default | | ||
| 64 | |---------|-------------|---------| | ||
| 65 | | `TOKEN` | Discord bot token | (required) | | ||
| 66 | | `STATUS_CHANNEL_ID` | Channel for startup message | none | | ||
| 67 | | `ASSISTANT_KEYWORD` | Keyword to trigger AI assistant | `jarvis` | | ||
| 68 | | `COPYPARTY_URL` | File server base URL | `http://localhost:8080` | | ||
| 69 | | `COPYPARTY_PASSWORD` | File server password | none | | ||
| 70 | | `OPENROUTER_API_KEY` | AI API key (for /senator command) | none | | ||
| 71 | |||
| 72 | ## Commands | 53 | ## Commands |
| 73 | 54 | ||
| 74 | ### Music | 55 | Slash commands are autoregistered globally on startup. Some are registered |
| 75 | - `/play` - Play music from file server or an internet audio stream | 56 | under their original Polish verb names to match the UI expected by existing |
| 76 | - `/stop` - Stop playback | 57 | servers. |
| 77 | - `/join` - Join voice channel | 58 | |
| 78 | - `/loop` - Loop current track | 59 | ### AI |
| 79 | 60 | - `/senator` — chat with the OpenRouter model | |
| 80 | ### Utility | 61 | |
| 81 | - `/search` - Search files on server | 62 | ### Music (songbird) |
| 82 | - `/web` - DuckDuckGo search | 63 | - `/grajcuj` — play from Copyparty search, a direct URL, or an uploaded file |
| 83 | - `/download` - Download video/audio | 64 | - `/stop` — stop playback and leave voice |
| 84 | - `/tts` - Text to speech | 65 | - `/join` — join the caller's voice channel |
| 85 | - `/qr_koduj` / `qr_dekoduj` - QR encode/decode | 66 | |
| 86 | - `/kompresja` - Compress images | 67 | ### TTS |
| 87 | - `/konwersja` - Convert audio/video | 68 | - `/tts` — Google Translate TTS (Polish), sent as a file or played in voice |
| 88 | 69 | ||
| 89 | ## Dependencies | 70 | ### Search & Web |
| 90 | 71 | - `/web` — DuckDuckGo HTML scrape (soon to use 4get) | |
| 91 | - `ffmpeg` - System-wide audio processing | 72 | - `/search` — search Copyparty and return a direct download URL |
| 92 | - `py-cord` - Discord library (due to instable nature of discord's voice features, py-cord has to be installed directly from git) | 73 | - `/download` — fetch media from any yt-dlp-supported URL |
| 74 | |||
| 75 | ### Media Tools | ||
| 76 | - `/compress` (alias `kompresja`) — compress a media attachment to low quality | ||
| 77 | - `/convert` (alias `konwersja`) — convert media between formats | ||
| 78 | |||
| 79 | ### Utilities | ||
| 80 | - `/upload` — push Discord attachments into Copyparty | ||
| 81 | - `/say` — repeat the given text as the bot | ||
| 82 | - `/math` — evaluate an ASCII math expression through `qalc` | ||
| 83 | - `/qr generowanie` / `/qr dekodowanie` — QR encode / decode | ||
| 84 | |||
| 85 | ### Triggers | ||
| 86 | - `/trigger create` — new trigger group | ||
| 87 | - `/trigger add` — add keywords/responses to a group | ||
| 88 | - `/trigger remove` — remove a keyword/response | ||
| 89 | - `/trigger delete` — delete a group | ||
| 90 | - `/trigger list` — list a group's keywords and responses | ||
| 91 | - `/trigger help` — usage info | ||
| 92 | |||
| 93 | ### Cron | ||
| 94 | - `/cron dodaj_audio` — schedule audio playback | ||
| 95 | - `/cron dodaj_wiadomość` — schedule a periodic message | ||
| 96 | - `/cron pokaż` — list all cron jobs | ||
| 97 | - `/cron usuń` — remove a cron job | ||
| 98 | - `/cron pauza` — pause a job | ||
| 99 | - `/cron wznów` — resume a job | ||
| 100 | |||
| 101 | ### Message Context Menu | ||
| 102 | - "Wyślij załączniki" (right-click message) — upload attachments to Copyparty | ||
| 103 | - "kompresja" (right-click message) — compress all attachments | ||
| 104 | |||
| 105 | ## Architecture | ||
| 106 | |||
| 107 | The runtime is organised around a shared `Data` struct (poise `user_data`) | ||
| 108 | that holds the DB pool, HTTP client, Copyparty client, voice manager, the | ||
| 109 | in-memory trigger cache, autocomplete cache, and per-guild music track handles. | ||
| 110 | |||
| 111 | - `src/main.rs` — boot: tracing, dotenvy, Config, DB pool, framework, cron task | ||
| 112 | - `src/config.rs` — env-driven `Config` + variable validation | ||
| 113 | - `src/commands/` — one module per command group | ||
| 114 | - `src/copyparty/` — Copyparty search/upload client | ||
| 115 | - `src/openrouter/` — OpenRouter chat client | ||
| 116 | - `src/database/` — sqlx pool, migrations, query helpers (6 tables) | ||
| 117 | - `src/voice/` — `VoiceManager`, mp3 → songbird input | ||
| 118 | - `src/events/` — `handle_ready`, voice-state, message (trigger matching) | ||
| 119 | - `src/tasks/` — 30s cron scheduler poll loop | ||
| 120 | - `src/lang.rs` — user-facing Polish strings | ||
| 121 | |||
| 122 | All temporary files go through `crate::config::TEMP_PATH` | ||
| 123 | (`/tmp/senator_sperma`). | ||
diff --git a/cogs/ai.py b/cogs/ai.py deleted file mode 100644 index dbdb02e..0000000 --- a/cogs/ai.py +++ /dev/null | |||
| @@ -1,73 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import discord | ||
| 8 | from discord.ext import commands | ||
| 9 | import logging | ||
| 10 | from gtts import gTTS | ||
| 11 | import io | ||
| 12 | from cogs.utils.llm import llm_completion | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | _DEFAULT_SENATOR_MODEL = "cognitivecomputations/dolphin-mistral-24b-venice-edition:free" | ||
| 17 | |||
| 18 | class AI(commands.Cog): | ||
| 19 | def __init__(self, bot): | ||
| 20 | self.bot = bot | ||
| 21 | |||
| 22 | @commands.slash_command( | ||
| 23 | name="senator", | ||
| 24 | description="SEENATOR", | ||
| 25 | ) | ||
| 26 | async def senator( | ||
| 27 | self, | ||
| 28 | ctx: discord.ApplicationContext, | ||
| 29 | text: str = discord.Option(str, "message", required=True), | ||
| 30 | tts: str = discord.Option(bool, "face?", default=False) | ||
| 31 | ): | ||
| 32 | await ctx.defer() | ||
| 33 | |||
| 34 | model = ( | ||
| 35 | os.getenv("LLM_MODEL") | ||
| 36 | or os.getenv("OPENROUTER_MODEL") | ||
| 37 | or _DEFAULT_SENATOR_MODEL | ||
| 38 | ) | ||
| 39 | messages = [ | ||
| 40 | { | ||
| 41 | "role": "system", | ||
| 42 | "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \ | ||
| 43 | You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \ | ||
| 44 | If the message is in Polish, respond in Polish. Respond in 100 words." | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | "role": "user", | ||
| 48 | "content": text | ||
| 49 | } | ||
| 50 | ] | ||
| 51 | |||
| 52 | response_text = await llm_completion(messages, model=model) | ||
| 53 | |||
| 54 | if response_text is None: | ||
| 55 | await ctx.send_followup("bot siem zesroł") | ||
| 56 | return | ||
| 57 | |||
| 58 | if tts: | ||
| 59 | tts_obj = gTTS(text=response_text, lang='pl') | ||
| 60 | tts_buffer = io.BytesIO() | ||
| 61 | tts_obj.write_to_fp(tts_buffer) | ||
| 62 | tts_buffer.seek(0) | ||
| 63 | from cogs.voice import get_voice_manager | ||
| 64 | vm = get_voice_manager(self.bot) | ||
| 65 | channel = ctx.author.voice.channel | ||
| 66 | await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False) | ||
| 67 | else: | ||
| 68 | await ctx.send_followup(response_text) | ||
| 69 | |||
| 70 | |||
| 71 | |||
| 72 | def setup(bot): | ||
| 73 | bot.add_cog(AI(bot)) | ||
diff --git a/cogs/assistant.py b/cogs/assistant.py deleted file mode 100644 index ffb664e..0000000 --- a/cogs/assistant.py +++ /dev/null | |||
| @@ -1,95 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | from discord.ext import commands | ||
| 8 | import logging | ||
| 9 | import os | ||
| 10 | from cogs.utils.llm import llm_completion | ||
| 11 | |||
| 12 | logger = logging.getLogger(__name__) | ||
| 13 | |||
| 14 | class Jarvis(commands.Cog): | ||
| 15 | def __init__(self, bot): | ||
| 16 | self.bot = bot | ||
| 17 | self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower() | ||
| 18 | |||
| 19 | @commands.Cog.listener() | ||
| 20 | async def on_message(self, message): | ||
| 21 | if message.author == self.bot.user: | ||
| 22 | return | ||
| 23 | |||
| 24 | logger.info(message.content) | ||
| 25 | |||
| 26 | if message.mentions: | ||
| 27 | if message.mentions[0].id == self.bot.user.id: | ||
| 28 | await self.assistant(message) | ||
| 29 | |||
| 30 | elif self.keyword in message.content.lower(): | ||
| 31 | await self.assistant(message) | ||
| 32 | |||
| 33 | @staticmethod | ||
| 34 | async def assistant(message): | ||
| 35 | chain = await Jarvis._build_message_chain(message) | ||
| 36 | messages = Jarvis._build_messages(message, chain) | ||
| 37 | |||
| 38 | extra = None | ||
| 39 | api_url = os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions") | ||
| 40 | if "openrouter" in api_url.lower(): | ||
| 41 | extra = {"plugins": [{"id": "response-healing"}]} | ||
| 42 | |||
| 43 | response_text = await llm_completion(messages, extra=extra) | ||
| 44 | if response_text is None: | ||
| 45 | await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") | ||
| 46 | return | ||
| 47 | |||
| 48 | await message.reply(response_text) | ||
| 49 | |||
| 50 | @staticmethod | ||
| 51 | async def _build_message_chain(message): | ||
| 52 | chain = [] | ||
| 53 | current = message | ||
| 54 | max_depth = 5 | ||
| 55 | |||
| 56 | while current and len(chain) < max_depth: | ||
| 57 | chain.append(current) | ||
| 58 | if not current.reference: | ||
| 59 | break | ||
| 60 | try: | ||
| 61 | if current.reference.resolved: | ||
| 62 | current = current.reference.resolved | ||
| 63 | else: | ||
| 64 | current = await current.channel.fetch_message(current.reference.message_id) | ||
| 65 | except discord.NotFound: | ||
| 66 | break | ||
| 67 | |||
| 68 | return list(reversed(chain)) | ||
| 69 | |||
| 70 | @staticmethod | ||
| 71 | def _build_messages(message, chain=None): | ||
| 72 | messages = [{ | ||
| 73 | "role": "system", | ||
| 74 | "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." | ||
| 75 | "Twoim zadaniem jest asystowanie użytkownika" | ||
| 76 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" | ||
| 77 | "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" | ||
| 78 | "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia" | ||
| 79 | }] | ||
| 80 | |||
| 81 | if chain is None: | ||
| 82 | chain = [message] | ||
| 83 | |||
| 84 | for msg in chain: | ||
| 85 | messages.append({ | ||
| 86 | "role": "user", | ||
| 87 | "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}" | ||
| 88 | }) | ||
| 89 | |||
| 90 | return messages | ||
| 91 | |||
| 92 | |||
| 93 | |||
| 94 | def setup(bot): | ||
| 95 | bot.add_cog(Jarvis(bot)) | ||
diff --git a/cogs/compression.py b/cogs/compression.py deleted file mode 100644 index 71348dc..0000000 --- a/cogs/compression.py +++ /dev/null | |||
| @@ -1,306 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import mimetypes | ||
| 7 | import os | ||
| 8 | import asyncio | ||
| 9 | import aiofiles | ||
| 10 | import requests | ||
| 11 | import urllib.parse | ||
| 12 | from typing import List, Optional | ||
| 13 | from os import PathLike | ||
| 14 | import discord | ||
| 15 | from discord.ext import commands | ||
| 16 | from discord import AutocompleteContext | ||
| 17 | from cogs.utils import file_search as search_utils | ||
| 18 | import shlex | ||
| 19 | import logging | ||
| 20 | |||
| 21 | logger = logging.getLogger(__name__) | ||
| 22 | |||
| 23 | def filetype(filename): | ||
| 24 | mime_type, _ = mimetypes.guess_type(filename) | ||
| 25 | if mime_type: | ||
| 26 | if mime_type.startswith('video/'): | ||
| 27 | return "Video" | ||
| 28 | elif mime_type.startswith('audio/'): | ||
| 29 | return "Audio" | ||
| 30 | elif mime_type.startswith('image/'): | ||
| 31 | return "Image" | ||
| 32 | return "Unknown" | ||
| 33 | |||
| 34 | class Compression(commands.Cog): | ||
| 35 | def __init__(self, bot): | ||
| 36 | from cogs.utils.temp import get_temp_dir | ||
| 37 | from cogs.utils.settings import get_baseurl | ||
| 38 | self.bot = bot | ||
| 39 | self.temp_dir = get_temp_dir() | ||
| 40 | self._cached_urls = {} | ||
| 41 | self._baseurl = get_baseurl() | ||
| 42 | |||
| 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 44 | query = ctx.value.lower() if ctx.value else "" | ||
| 45 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 46 | |||
| 47 | user_id = ctx.interaction.user.id | ||
| 48 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 49 | |||
| 50 | choices = [] | ||
| 51 | for url in results[:25]: | ||
| 52 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 53 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 54 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 55 | return choices | ||
| 56 | |||
| 57 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 58 | """Download file from copyparty URL and return local path""" | ||
| 59 | try: | ||
| 60 | response = requests.get(url) | ||
| 61 | if response.status_code != 200: | ||
| 62 | return None | ||
| 63 | |||
| 64 | filename = url.split('/')[-1].split('?')[0] | ||
| 65 | filename = urllib.parse.unquote(filename) | ||
| 66 | file_path = os.path.join(self.temp_dir, filename) | ||
| 67 | |||
| 68 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 69 | await f.write(response.content) | ||
| 70 | return file_path | ||
| 71 | except Exception as e: | ||
| 72 | logger.error(f"Error downloading from {url}: {e}") | ||
| 73 | return None | ||
| 74 | |||
| 75 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 76 | """Upload file to copyparty and return download URL""" | ||
| 77 | try: | ||
| 78 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 79 | file_content = await f.read() | ||
| 80 | |||
| 81 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 82 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 83 | params = {'j': '1', 'ck': 'b2'} | ||
| 84 | |||
| 85 | response = requests.post(self._baseurl, files=files, headers=headers, params=params) | ||
| 86 | |||
| 87 | if response.status_code in [200, 201]: | ||
| 88 | result = response.json() | ||
| 89 | return result.get('fileurl') | ||
| 90 | except Exception as e: | ||
| 91 | logger.error(f"Upload error: {e}") | ||
| 92 | return None | ||
| 93 | |||
| 94 | def _process_video(self, file_path: str, output_path: str) -> str: | ||
| 95 | """Process video compression""" | ||
| 96 | command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:v libx264 -crf 51 -b:v 100 -vf 'fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2' -preset ultrafast -c:a libopus -b:a 500 {shlex.quote(output_path)}" | ||
| 97 | return command | ||
| 98 | |||
| 99 | def _process_audio(self, file_path: str, output_path: str) -> str: | ||
| 100 | """Process audio compression""" | ||
| 101 | if output_path.lower().endswith('.mp3'): | ||
| 102 | output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') | ||
| 103 | if not output_path.lower().endswith('.ogg'): | ||
| 104 | output_path = os.path.splitext(output_path)[0] + '.ogg' | ||
| 105 | command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}" | ||
| 106 | return command | ||
| 107 | |||
| 108 | def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: | ||
| 109 | """Process image compression""" | ||
| 110 | ext = os.path.splitext(file_path)[1].lower() | ||
| 111 | if ext in [".jpg", ".jpeg"]: | ||
| 112 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" | ||
| 113 | elif ext == ".gif": | ||
| 114 | command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}" | ||
| 115 | else: | ||
| 116 | output_path = os.path.splitext(output_path)[0] + ".jpg" | ||
| 117 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" | ||
| 118 | return command, output_path | ||
| 119 | |||
| 120 | async def _execute_compression(self, command: str) -> bool: | ||
| 121 | """Execute compression command""" | ||
| 122 | proc = await asyncio.create_subprocess_exec( | ||
| 123 | *shlex.split(command), | ||
| 124 | stdout=asyncio.subprocess.PIPE, | ||
| 125 | stderr=asyncio.subprocess.PIPE | ||
| 126 | ) | ||
| 127 | await proc.wait() | ||
| 128 | return proc.returncode == 0 | ||
| 129 | |||
| 130 | async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: | ||
| 131 | """Compress a single attachment and return the compressed file""" | ||
| 132 | try: | ||
| 133 | file_path: str = os.path.join(self.temp_dir, attachment.filename) | ||
| 134 | output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") | ||
| 135 | |||
| 136 | # Save the original file | ||
| 137 | await attachment.save(file_path) | ||
| 138 | |||
| 139 | # Process the file | ||
| 140 | file_type = filetype(file_path) | ||
| 141 | logger.info(f"Compression started for: {file_type}") | ||
| 142 | |||
| 143 | command = self._get_compression_command(file_type, file_path, output_path) | ||
| 144 | if command is None: | ||
| 145 | logger.info(f"Unsupported file type: {file_type}") | ||
| 146 | os.remove(file_path) | ||
| 147 | return None | ||
| 148 | |||
| 149 | # Update output_path if it was modified | ||
| 150 | if file_type == "Image": | ||
| 151 | command, output_path = command | ||
| 152 | elif file_type == "Audio": | ||
| 153 | # output_path is already modified in _process_audio | ||
| 154 | pass | ||
| 155 | |||
| 156 | # Run the compression | ||
| 157 | success = await self._execute_compression(command) | ||
| 158 | |||
| 159 | if success and os.path.exists(output_path): | ||
| 160 | logger.info(f"Successfully compressed: {attachment.filename}") | ||
| 161 | return discord.File(output_path, filename=f"compressed_{attachment.filename}") | ||
| 162 | return None | ||
| 163 | |||
| 164 | except Exception as e: | ||
| 165 | logger.error(f"Error processing {attachment.filename}: {str(e)}") | ||
| 166 | return None | ||
| 167 | |||
| 168 | finally: | ||
| 169 | # Clean up temporary files | ||
| 170 | if 'file_path' in locals() and os.path.exists(file_path): | ||
| 171 | os.remove(file_path) | ||
| 172 | |||
| 173 | def _get_compression_command(self, file_type: str, file_path: str, output_path: str): | ||
| 174 | """Get the appropriate compression command based on file type""" | ||
| 175 | if file_type == "Video": | ||
| 176 | return self._process_video(file_path, output_path) | ||
| 177 | elif file_type == "Audio": | ||
| 178 | return self._process_audio(file_path, output_path) | ||
| 179 | elif file_type == "Image": | ||
| 180 | return self._process_image(file_path, output_path) | ||
| 181 | else: | ||
| 182 | return None | ||
| 183 | |||
| 184 | async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]: | ||
| 185 | """Compress multiple attachments and return list of compressed files""" | ||
| 186 | compressed_files = [] | ||
| 187 | for attachment in attachments: | ||
| 188 | compressed_file = await self._compress_attachment(attachment) | ||
| 189 | if compressed_file: | ||
| 190 | compressed_files.append(compressed_file) | ||
| 191 | return compressed_files | ||
| 192 | |||
| 193 | @commands.message_command( | ||
| 194 | name="kompresja", | ||
| 195 | description="skompresuj wideło albo dźwienk :)", | ||
| 196 | ) | ||
| 197 | async def message_compression(self, ctx, message: discord.Message): | ||
| 198 | if not message.attachments: | ||
| 199 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 200 | return | ||
| 201 | |||
| 202 | await ctx.defer() | ||
| 203 | compressed_files = await self._compress_attachments(message.attachments) | ||
| 204 | |||
| 205 | # Send all compressed files in one message | ||
| 206 | if compressed_files: | ||
| 207 | await ctx.respond( | ||
| 208 | f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", | ||
| 209 | files=compressed_files | ||
| 210 | ) | ||
| 211 | |||
| 212 | # Clean up compressed files | ||
| 213 | for file in compressed_files: | ||
| 214 | if os.path.exists(file.fp.name): # type: ignore[attr-defined] | ||
| 215 | os.remove(file.fp.name) # type: ignore[attr-defined] | ||
| 216 | else: | ||
| 217 | await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) | ||
| 218 | |||
| 219 | @commands.slash_command( | ||
| 220 | name="kompresja", | ||
| 221 | description="skompresuj wideło albo dźwienk :)", | ||
| 222 | ) | ||
| 223 | @discord.option( | ||
| 224 | name="plik", | ||
| 225 | description="plik z copyparty", | ||
| 226 | required=False, | ||
| 227 | autocomplete=_file_autocomplete | ||
| 228 | ) | ||
| 229 | @discord.option( | ||
| 230 | name="zalacznik", | ||
| 231 | description="plik z discorda", | ||
| 232 | required=False, | ||
| 233 | type=discord.Attachment | ||
| 234 | ) | ||
| 235 | @discord.option( | ||
| 236 | name="serwer?", | ||
| 237 | description="wysłać plik na serwer?", | ||
| 238 | required=False, | ||
| 239 | default=False, | ||
| 240 | type=bool | ||
| 241 | ) | ||
| 242 | async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False): | ||
| 243 | await ctx.defer() | ||
| 244 | |||
| 245 | file_path = None | ||
| 246 | original_filename = None | ||
| 247 | |||
| 248 | if plik: | ||
| 249 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) | ||
| 250 | if not url: | ||
| 251 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 252 | return | ||
| 253 | file_path = await self._download_from_url(url) | ||
| 254 | original_filename = plik | ||
| 255 | elif zalacznik: | ||
| 256 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 257 | original_filename = zalacznik.filename | ||
| 258 | await zalacznik.save(file_path) | ||
| 259 | else: | ||
| 260 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) | ||
| 261 | return | ||
| 262 | |||
| 263 | if not file_path or not os.path.exists(file_path): | ||
| 264 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 265 | return | ||
| 266 | |||
| 267 | try: | ||
| 268 | file_type = filetype(file_path) | ||
| 269 | output_path = os.path.join(self.temp_dir, f"better_{original_filename}") | ||
| 270 | |||
| 271 | command = self._get_compression_command(file_type, file_path, output_path) | ||
| 272 | if command is None: | ||
| 273 | await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True) | ||
| 274 | return | ||
| 275 | |||
| 276 | if file_type == "Image": | ||
| 277 | command, output_path = command | ||
| 278 | elif file_type == "Audio": | ||
| 279 | # Audio output path remains unchanged - already processed in _process_audio | ||
| 280 | pass | ||
| 281 | |||
| 282 | success = await self._execute_compression(command) | ||
| 283 | |||
| 284 | if not success or not os.path.exists(output_path): | ||
| 285 | await ctx.respond("Kompresja nie powiodła się", ephemeral=True) | ||
| 286 | return | ||
| 287 | |||
| 288 | file_size = os.path.getsize(output_path) | ||
| 289 | if file_size > 1024 * 1024 * 10 or server: | ||
| 290 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 291 | if dl_url: | ||
| 292 | await ctx.respond(f"Skompresowano\n{dl_url}") | ||
| 293 | else: | ||
| 294 | await ctx.respond("Nie udało wysłać na serwer", ephemeral=True) | ||
| 295 | else: | ||
| 296 | await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}")) | ||
| 297 | |||
| 298 | finally: | ||
| 299 | if file_path and os.path.exists(file_path): | ||
| 300 | os.remove(file_path) | ||
| 301 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 302 | os.remove(output_path) | ||
| 303 | |||
| 304 | |||
| 305 | def setup(bot): | ||
| 306 | bot.add_cog(Compression(bot)) | ||
diff --git a/cogs/conversion.py b/cogs/conversion.py deleted file mode 100644 index f9e4c84..0000000 --- a/cogs/conversion.py +++ /dev/null | |||
| @@ -1,245 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | import requests | ||
| 8 | from discord.ext import commands | ||
| 9 | import logging | ||
| 10 | import os | ||
| 11 | import asyncio | ||
| 12 | import aiofiles | ||
| 13 | import mimetypes | ||
| 14 | import subprocess | ||
| 15 | import urllib.parse | ||
| 16 | import shlex | ||
| 17 | from typing import Optional | ||
| 18 | from discord import AutocompleteContext | ||
| 19 | from cogs.utils import file_search as search_utils | ||
| 20 | |||
| 21 | logger = logging.getLogger(__name__) | ||
| 22 | |||
| 23 | def filetype(filename): | ||
| 24 | mime_type, _ = mimetypes.guess_type(filename) | ||
| 25 | if mime_type: | ||
| 26 | if mime_type.startswith('video/'): | ||
| 27 | return "Video" | ||
| 28 | elif mime_type.startswith('audio/'): | ||
| 29 | return "Audio" | ||
| 30 | elif mime_type.startswith('image/'): | ||
| 31 | return "Image" | ||
| 32 | return "Unknown" | ||
| 33 | |||
| 34 | class Conversion(commands.Cog): | ||
| 35 | def __init__(self, bot): | ||
| 36 | self.bot = bot | ||
| 37 | from cogs.utils.temp import get_temp_dir | ||
| 38 | from cogs.utils.settings import get_baseurl, get_uploadurl | ||
| 39 | self.temp_dir = get_temp_dir() | ||
| 40 | self._cached_urls = {} | ||
| 41 | self._baseurl = get_baseurl() | ||
| 42 | self._uploadurl = get_uploadurl() | ||
| 43 | |||
| 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 45 | query = ctx.value.lower() if ctx.value else "" | ||
| 46 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 47 | |||
| 48 | user_id = ctx.interaction.user.id | ||
| 49 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 50 | |||
| 51 | choices = [] | ||
| 52 | for url in results[:25]: | ||
| 53 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 54 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 55 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 56 | return choices | ||
| 57 | |||
| 58 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 59 | """Download file from copyparty URL and return local path""" | ||
| 60 | try: | ||
| 61 | response = requests.get(url) | ||
| 62 | if response.status_code != 200: | ||
| 63 | return None | ||
| 64 | |||
| 65 | filename = url.split('/')[-1].split('?')[0] | ||
| 66 | filename = urllib.parse.unquote(filename) | ||
| 67 | file_path = os.path.join(self.temp_dir, filename) | ||
| 68 | |||
| 69 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 70 | await f.write(response.content) | ||
| 71 | return file_path | ||
| 72 | except Exception as e: | ||
| 73 | logger.error(f"Error downloading from {url}: {e}") | ||
| 74 | return None | ||
| 75 | |||
| 76 | async def _setup_file_paths(self, plik, output_format): | ||
| 77 | """Setup input and output file paths""" | ||
| 78 | from cogs.utils.temp import get_temp_dir | ||
| 79 | |||
| 80 | temp_dir = get_temp_dir() | ||
| 81 | file_path = os.path.join(temp_dir, plik.filename) | ||
| 82 | filename_noext = os.path.splitext(plik.filename)[0] | ||
| 83 | await plik.save(file_path) | ||
| 84 | |||
| 85 | input_path = file_path | ||
| 86 | output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}") | ||
| 87 | return input_path, output_path | ||
| 88 | |||
| 89 | def _build_conversion_command(self, file_type, input_path, output_path, args): | ||
| 90 | """Build the appropriate conversion command based on file type""" | ||
| 91 | if file_type in ["Video", "Audio"]: | ||
| 92 | return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" | ||
| 93 | elif file_type == "Image": | ||
| 94 | return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" | ||
| 95 | else: | ||
| 96 | return None | ||
| 97 | |||
| 98 | async def _upload_to_copyparty(self, output_path): | ||
| 99 | """Upload file to copyparty and return download URL""" | ||
| 100 | try: | ||
| 101 | async with aiofiles.open(output_path, 'rb') as f: | ||
| 102 | file_content = await f.read() | ||
| 103 | |||
| 104 | files = { | ||
| 105 | 'f': (os.path.basename(output_path), file_content, 'application/octet-stream') | ||
| 106 | } | ||
| 107 | headers = { | ||
| 108 | 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' | ||
| 109 | } | ||
| 110 | params = { | ||
| 111 | 'j': '1', | ||
| 112 | 'ck': 'b2' | ||
| 113 | } | ||
| 114 | |||
| 115 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 116 | |||
| 117 | if response.status_code in [200, 201]: | ||
| 118 | result = response.json() | ||
| 119 | if 'fileurl' in result: | ||
| 120 | return result['fileurl'] | ||
| 121 | else: | ||
| 122 | raise ValueError(f"Server response missing fileurl: {result}") | ||
| 123 | else: | ||
| 124 | error_msg = response.text if response.text else 'Unknown error' | ||
| 125 | raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") | ||
| 126 | |||
| 127 | except Exception as e: | ||
| 128 | logger.error(f"copyparty upload error: {e}", exc_info=True) | ||
| 129 | raise | ||
| 130 | |||
| 131 | async def _handle_file_output(self, ctx, output_path): | ||
| 132 | """Handle file output based on size""" | ||
| 133 | if os.path.getsize(output_path) > 1024*1024*10: | ||
| 134 | try: | ||
| 135 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 136 | await ctx.followup.send(f"wysyłon\n{dl_url}\n") | ||
| 137 | except Exception as upload_error: | ||
| 138 | await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}") | ||
| 139 | else: | ||
| 140 | await ctx.respond(file=discord.File(output_path)) | ||
| 141 | |||
| 142 | def _cleanup_files(self, *file_paths): | ||
| 143 | """Clean up temporary files""" | ||
| 144 | for path in file_paths: | ||
| 145 | if os.path.exists(path): | ||
| 146 | os.remove(path) | ||
| 147 | |||
| 148 | @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") | ||
| 149 | @discord.option( | ||
| 150 | name="plik", | ||
| 151 | description="plik z copyparty", | ||
| 152 | required=False, | ||
| 153 | autocomplete=_file_autocomplete | ||
| 154 | ) | ||
| 155 | @discord.option( | ||
| 156 | name="zalacznik", | ||
| 157 | description="plik z discorda", | ||
| 158 | required=False, | ||
| 159 | type=discord.Attachment | ||
| 160 | ) | ||
| 161 | @discord.option( | ||
| 162 | name="serwer?", | ||
| 163 | description="wysłać plik na serwer?", | ||
| 164 | required=False, | ||
| 165 | type=bool, | ||
| 166 | default=False | ||
| 167 | ) | ||
| 168 | @discord.option( | ||
| 169 | name="format", | ||
| 170 | description="format konwersji", | ||
| 171 | required=True, | ||
| 172 | type=str | ||
| 173 | ) | ||
| 174 | async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None): | ||
| 175 | await ctx.defer() | ||
| 176 | |||
| 177 | input_path = None | ||
| 178 | original_filename = None | ||
| 179 | |||
| 180 | try: | ||
| 181 | if plik: | ||
| 182 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) | ||
| 183 | if not url: | ||
| 184 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 185 | return | ||
| 186 | input_path = await self._download_from_url(url) | ||
| 187 | original_filename = plik | ||
| 188 | elif zalacznik: | ||
| 189 | input_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 190 | original_filename = zalacznik.filename | ||
| 191 | await zalacznik.save(input_path) | ||
| 192 | else: | ||
| 193 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) | ||
| 194 | return | ||
| 195 | |||
| 196 | if not input_path or not os.path.exists(input_path): | ||
| 197 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 198 | return | ||
| 199 | |||
| 200 | output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}") | ||
| 201 | |||
| 202 | file_type = filetype(input_path) | ||
| 203 | logger.info(f"Conversion started for: {file_type}") | ||
| 204 | |||
| 205 | command = self._build_conversion_command(file_type, input_path, output_path, "") | ||
| 206 | logger.info(f"Running: {command}") | ||
| 207 | if not command: | ||
| 208 | await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) | ||
| 209 | return | ||
| 210 | |||
| 211 | proc = await asyncio.create_subprocess_exec( | ||
| 212 | *shlex.split(command), | ||
| 213 | stdout=asyncio.subprocess.PIPE, | ||
| 214 | stderr=asyncio.subprocess.PIPE | ||
| 215 | ) | ||
| 216 | stdout, stderr = await proc.communicate() | ||
| 217 | |||
| 218 | if proc.returncode != 0: | ||
| 219 | logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}") | ||
| 220 | await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True) | ||
| 221 | return | ||
| 222 | |||
| 223 | if os.path.getsize(output_path) > 1024 * 1024 * 10 or server: | ||
| 224 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 225 | if dl_url: | ||
| 226 | await ctx.respond(f"Skonwertowano\n{dl_url}") | ||
| 227 | else: | ||
| 228 | await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True) | ||
| 229 | else: | ||
| 230 | await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}")) | ||
| 231 | |||
| 232 | logger.info(f"Conversion completed for: {file_type}") | ||
| 233 | |||
| 234 | except subprocess.CalledProcessError as e: | ||
| 235 | await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) | ||
| 236 | except Exception as e: | ||
| 237 | await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) | ||
| 238 | finally: | ||
| 239 | if input_path and os.path.exists(input_path): | ||
| 240 | os.remove(input_path) | ||
| 241 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 242 | os.remove(output_path) | ||
| 243 | |||
| 244 | def setup(bot): | ||
| 245 | bot.add_cog(Conversion(bot)) | ||
diff --git a/cogs/cron.py b/cogs/cron.py deleted file mode 100644 index ee88085..0000000 --- a/cogs/cron.py +++ /dev/null | |||
| @@ -1,403 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | from datetime import datetime | ||
| 9 | from venv import logger | ||
| 10 | |||
| 11 | import discord | ||
| 12 | from croniter import croniter | ||
| 13 | from discord.ext import commands | ||
| 14 | |||
| 15 | from cogs.utils import file_search as search_utils | ||
| 16 | from cogs.voice import get_voice_manager | ||
| 17 | from cogs.utils.database import get_connection, init_database | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 20 | |||
| 21 | init_database() | ||
| 22 | |||
| 23 | |||
| 24 | class Cron(commands.Cog): | ||
| 25 | def __init__(self, bot): | ||
| 26 | self.bot = bot | ||
| 27 | self.scheduled_tasks = [] | ||
| 28 | self.load_tasks() | ||
| 29 | self.task = bot.loop.create_task(self.schedule_checker()) | ||
| 30 | |||
| 31 | def load_tasks(self): | ||
| 32 | try: | ||
| 33 | with get_connection() as conn: | ||
| 34 | c = conn.cursor() | ||
| 35 | c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") | ||
| 36 | rows = c.fetchall() | ||
| 37 | |||
| 38 | self.scheduled_tasks = [ | ||
| 39 | { | ||
| 40 | "id": row[0], | ||
| 41 | "cron_expression": row[1], | ||
| 42 | "type": row[2], | ||
| 43 | "channel_id": row[3], | ||
| 44 | "active": bool(row[4]), | ||
| 45 | "audio_filename": row[5], | ||
| 46 | "message": row[6], | ||
| 47 | } | ||
| 48 | for row in rows | ||
| 49 | ] | ||
| 50 | logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") | ||
| 51 | except Exception as e: | ||
| 52 | logger.error(f"Error loading cron jobs: {e}") | ||
| 53 | self.scheduled_tasks = [] | ||
| 54 | |||
| 55 | def save_tasks(self): | ||
| 56 | try: | ||
| 57 | with get_connection() as conn: | ||
| 58 | c = conn.cursor() | ||
| 59 | c.execute("PRAGMA foreign_keys = ON") | ||
| 60 | |||
| 61 | c.execute("DELETE FROM cron_jobs") | ||
| 62 | |||
| 63 | for task in self.scheduled_tasks: | ||
| 64 | c.execute( | ||
| 65 | """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) | ||
| 66 | VALUES (?, ?, ?, ?, ?, ?, ?)""", | ||
| 67 | ( | ||
| 68 | task.get("id"), | ||
| 69 | task["cron_expression"], | ||
| 70 | task["type"], | ||
| 71 | task["channel_id"], | ||
| 72 | int(task.get("active", True)), | ||
| 73 | task.get("audio_filename"), | ||
| 74 | task.get("message"), | ||
| 75 | ), | ||
| 76 | ) | ||
| 77 | conn.commit() | ||
| 78 | except Exception as e: | ||
| 79 | logger.error(f"Error saving cron jobs: {e}") | ||
| 80 | |||
| 81 | async def schedule_checker(self): | ||
| 82 | """Background task to check and execute scheduled jobs""" | ||
| 83 | await self.bot.wait_until_ready() | ||
| 84 | while not self.bot.is_closed(): | ||
| 85 | try: | ||
| 86 | now = datetime.now() | ||
| 87 | for task in self.scheduled_tasks: | ||
| 88 | if not task.get("active", True): | ||
| 89 | continue | ||
| 90 | if self.should_run_task(task, now): | ||
| 91 | await self.execute_task(task) | ||
| 92 | await asyncio.sleep(30) | ||
| 93 | except Exception as e: | ||
| 94 | logger.error(f"Error in schedule checker: {e}") | ||
| 95 | await asyncio.sleep(30) | ||
| 96 | |||
| 97 | def should_run_task(self, task, now): | ||
| 98 | """Check if a task should run now based on its cron expression""" | ||
| 99 | try: | ||
| 100 | cron_expr = task["cron_expression"] | ||
| 101 | cron = croniter(cron_expr, now) | ||
| 102 | prev_time = cron.get_prev(datetime) | ||
| 103 | time_diff = abs((now - prev_time).total_seconds()) | ||
| 104 | result = time_diff <= 30 | ||
| 105 | logger.debug( | ||
| 106 | f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" | ||
| 107 | ) | ||
| 108 | return result | ||
| 109 | except Exception as e: | ||
| 110 | logger.error( | ||
| 111 | f"Error checking cron expression {task.get('cron_expression')}: {e}" | ||
| 112 | ) | ||
| 113 | return False | ||
| 114 | |||
| 115 | async def execute_task(self, task): | ||
| 116 | """Execute a scheduled task""" | ||
| 117 | try: | ||
| 118 | task_type = task["type"] | ||
| 119 | channel_id = task["channel_id"] | ||
| 120 | |||
| 121 | channel = self.bot.get_channel(channel_id) | ||
| 122 | if not channel: | ||
| 123 | logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") | ||
| 124 | return | ||
| 125 | |||
| 126 | logger.info(f"Found channel {channel} (type: {type(channel).__name__})") | ||
| 127 | |||
| 128 | perms = channel.permissions_for(channel.guild.me) | ||
| 129 | logger.debug( | ||
| 130 | f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" | ||
| 131 | ) | ||
| 132 | |||
| 133 | if task_type in ("zagraj_audio", "audio"): | ||
| 134 | await self.play_audio_task(task, channel) | ||
| 135 | elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): | ||
| 136 | await self.send_message_task(task, channel) | ||
| 137 | |||
| 138 | logger.info(f"Executed task {task['id']} of type {task_type}") | ||
| 139 | except Exception as e: | ||
| 140 | logger.error(f"Error executing task {task.get('id')}: {e}") | ||
| 141 | |||
| 142 | async def play_audio_task(self, task, channel): | ||
| 143 | """Play audio task implementation using voice manager""" | ||
| 144 | audio_url = task["audio_filename"] | ||
| 145 | |||
| 146 | if isinstance(channel, discord.VoiceChannel): | ||
| 147 | voice_mgr = get_voice_manager(self.bot) | ||
| 148 | |||
| 149 | try: | ||
| 150 | audio_source = discord.FFmpegPCMAudio(audio_url) | ||
| 151 | success = await voice_mgr.play_source(channel, audio_source) | ||
| 152 | |||
| 153 | if success: | ||
| 154 | logger.info(f"Finished playing audio: {audio_url}") | ||
| 155 | else: | ||
| 156 | raise Exception("Playback failed") | ||
| 157 | |||
| 158 | await voice_mgr.disconnect(channel.guild) | ||
| 159 | except Exception as e: | ||
| 160 | logger.error(f"Error playing audio: {e}") | ||
| 161 | try: | ||
| 162 | await voice_mgr.disconnect(channel.guild) | ||
| 163 | except Exception: | ||
| 164 | pass | ||
| 165 | if channel.guild.system_channel: | ||
| 166 | await channel.guild.system_channel.send( | ||
| 167 | f"Błąd podczas odgrywania audio: {audio_url}" | ||
| 168 | ) | ||
| 169 | else: | ||
| 170 | if hasattr(channel, "send"): | ||
| 171 | await channel.send("Odtwarzanie audio wymaga kanału głosowego") | ||
| 172 | |||
| 173 | async def send_message_task(self, task, channel): | ||
| 174 | """Send message task implementation""" | ||
| 175 | message = task["message"] | ||
| 176 | logger.info(f"Message to send: '{message}' | Channel: {channel.id}") | ||
| 177 | await channel.send(message) | ||
| 178 | logger.debug("Message sent successfully") | ||
| 179 | |||
| 180 | async def audio_autocomplete(self, ctx: discord.AutocompleteContext): | ||
| 181 | """Autocomplete for audio files from file_search""" | ||
| 182 | query = ctx.value.lower() if ctx.value else "" | ||
| 183 | if not query: | ||
| 184 | return "" | ||
| 185 | |||
| 186 | audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"] | ||
| 187 | results = await search_utils.UniversalSearcher.search_copyparty( | ||
| 188 | query, max_results=25, file_extensions=audio_extensions | ||
| 189 | ) | ||
| 190 | |||
| 191 | choices = [] | ||
| 192 | for url in results[:25]: | ||
| 193 | filename = url.split("/")[-1] | ||
| 194 | display_name = filename[:98] + "…" if len(filename) > 100 else filename | ||
| 195 | choices.append(discord.OptionChoice(name=display_name, value=url)) | ||
| 196 | |||
| 197 | return ( | ||
| 198 | choices | ||
| 199 | if choices | ||
| 200 | else [discord.OptionChoice(name="Brak wyników", value="")] | ||
| 201 | ) | ||
| 202 | |||
| 203 | def _get_baseurl(self): | ||
| 204 | """Get base URL from settings""" | ||
| 205 | try: | ||
| 206 | from cogs.utils.settings import get_baseurl | ||
| 207 | |||
| 208 | return get_baseurl() | ||
| 209 | except Exception: | ||
| 210 | return "" | ||
| 211 | |||
| 212 | trigger_group = discord.SlashCommandGroup("cron", "komendy cron") | ||
| 213 | |||
| 214 | @trigger_group.command( | ||
| 215 | name="dodaj_audio", description="Dodaj nowe zadanie cron audio" | ||
| 216 | ) | ||
| 217 | async def cron_add_audio( | ||
| 218 | self, | ||
| 219 | ctx, | ||
| 220 | cron_expression: str = discord.Option( | ||
| 221 | str, | ||
| 222 | "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", | ||
| 223 | required=True, | ||
| 224 | ), | ||
| 225 | channel: discord.VoiceChannel = discord.Option( | ||
| 226 | discord.VoiceChannel, | ||
| 227 | "Kanał głosowy na którym będzie wykonywana akcja", | ||
| 228 | required=True, | ||
| 229 | ), | ||
| 230 | audio_filename: str = discord.Option( | ||
| 231 | str, | ||
| 232 | "Plik audio z serwera copyparty", | ||
| 233 | required=True, | ||
| 234 | autocomplete=audio_autocomplete, | ||
| 235 | ), | ||
| 236 | ): | ||
| 237 | try: | ||
| 238 | croniter(cron_expression) | ||
| 239 | except Exception: | ||
| 240 | await ctx.respond( | ||
| 241 | "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", | ||
| 242 | ephemeral=True, | ||
| 243 | ) | ||
| 244 | return | ||
| 245 | |||
| 246 | task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 | ||
| 247 | |||
| 248 | task = { | ||
| 249 | "id": task_id, | ||
| 250 | "cron_expression": cron_expression, | ||
| 251 | "type": "audio", | ||
| 252 | "channel_id": channel.id, | ||
| 253 | "active": True, | ||
| 254 | } | ||
| 255 | |||
| 256 | task["audio_filename"] = audio_filename | ||
| 257 | |||
| 258 | self.scheduled_tasks.append(task) | ||
| 259 | self.save_tasks() | ||
| 260 | |||
| 261 | await ctx.respond( | ||
| 262 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" | ||
| 263 | ) | ||
| 264 | |||
| 265 | @trigger_group.command( | ||
| 266 | name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" | ||
| 267 | ) | ||
| 268 | async def cron_add_message( | ||
| 269 | self, | ||
| 270 | ctx, | ||
| 271 | cron_expression: str = discord.Option( | ||
| 272 | str, | ||
| 273 | "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", | ||
| 274 | required=True, | ||
| 275 | ), | ||
| 276 | channel: discord.TextChannel = discord.Option( | ||
| 277 | discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True | ||
| 278 | ), | ||
| 279 | message: str = discord.Option(str, "Wiadomość do wysłania", required=True), | ||
| 280 | ): | ||
| 281 | """Add a new cron job""" | ||
| 282 | |||
| 283 | try: | ||
| 284 | croniter(cron_expression) | ||
| 285 | except Exception: | ||
| 286 | await ctx.respond( | ||
| 287 | "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", | ||
| 288 | ephemeral=True, | ||
| 289 | ) | ||
| 290 | return | ||
| 291 | |||
| 292 | task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 | ||
| 293 | |||
| 294 | task = { | ||
| 295 | "id": task_id, | ||
| 296 | "cron_expression": cron_expression, | ||
| 297 | "type": "wiadomość", | ||
| 298 | "channel_id": channel.id, | ||
| 299 | "active": True, | ||
| 300 | "message": message, | ||
| 301 | } | ||
| 302 | |||
| 303 | self.scheduled_tasks.append(task) | ||
| 304 | self.save_tasks() | ||
| 305 | |||
| 306 | await ctx.respond( | ||
| 307 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" | ||
| 308 | ) | ||
| 309 | |||
| 310 | @trigger_group.command( | ||
| 311 | name="pokaż", description="Pokaż wszystkie zadania cron" | ||
| 312 | ) | ||
| 313 | async def cron_list(self, ctx): | ||
| 314 | """List all cron jobs""" | ||
| 315 | if not self.scheduled_tasks: | ||
| 316 | await ctx.respond("Brak zadań cron") | ||
| 317 | return | ||
| 318 | |||
| 319 | embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00) | ||
| 320 | |||
| 321 | for task in self.scheduled_tasks: | ||
| 322 | channel = self.bot.get_channel(task["channel_id"]) | ||
| 323 | channel_name = f"#{channel}" | ||
| 324 | status = "AKTYWNA" if task.get("active", True) else "PAUZA" | ||
| 325 | |||
| 326 | if task["type"] in ("zagraj_audio", "audio"): | ||
| 327 | action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`" | ||
| 328 | else: | ||
| 329 | msg = task.get("message", "") | ||
| 330 | action_desc = ( | ||
| 331 | f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`" | ||
| 332 | ) | ||
| 333 | |||
| 334 | embed.add_field( | ||
| 335 | name=f"Zadanie #{task['id']} ({status})", | ||
| 336 | value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}", | ||
| 337 | inline=False, | ||
| 338 | ) | ||
| 339 | |||
| 340 | await ctx.respond(embed=embed) | ||
| 341 | |||
| 342 | @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") | ||
| 343 | async def cron_remove( | ||
| 344 | self, | ||
| 345 | ctx, | ||
| 346 | job_id: int = discord.Option( | ||
| 347 | int, "ID zadania cron do usunięcia", required=True | ||
| 348 | ), | ||
| 349 | ): | ||
| 350 | """Remove a cron job by ID""" | ||
| 351 | for i, task in enumerate(self.scheduled_tasks): | ||
| 352 | if task["id"] == job_id: | ||
| 353 | self.scheduled_tasks.pop(i) | ||
| 354 | self.save_tasks() | ||
| 355 | await ctx.respond(f"Usunięto zadanie cron #{job_id}") | ||
| 356 | return | ||
| 357 | |||
| 358 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 359 | |||
| 360 | @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") | ||
| 361 | async def cron_pause( | ||
| 362 | self, | ||
| 363 | ctx, | ||
| 364 | job_id: int = discord.Option( | ||
| 365 | int, "ID zadania cron do zatrzymania", required=True | ||
| 366 | ), | ||
| 367 | ): | ||
| 368 | """Pause a cron job by ID""" | ||
| 369 | for task in self.scheduled_tasks: | ||
| 370 | if task["id"] == job_id: | ||
| 371 | task["active"] = False | ||
| 372 | self.save_tasks() | ||
| 373 | await ctx.respond(f"Zapauzowano zadanie cron #{job_id}") | ||
| 374 | return | ||
| 375 | |||
| 376 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 377 | |||
| 378 | @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") | ||
| 379 | async def cron_resume( | ||
| 380 | self, | ||
| 381 | ctx, | ||
| 382 | job_id: int = discord.Option( | ||
| 383 | int, "ID zadania cron do wznowienia", required=True | ||
| 384 | ), | ||
| 385 | ): | ||
| 386 | """Resume a paused cron job by ID""" | ||
| 387 | for task in self.scheduled_tasks: | ||
| 388 | if task["id"] == job_id: | ||
| 389 | task["active"] = True | ||
| 390 | self.save_tasks() | ||
| 391 | await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}") | ||
| 392 | return | ||
| 393 | |||
| 394 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 395 | |||
| 396 | def cog_unload(self): | ||
| 397 | """Clean up when cog is unloaded""" | ||
| 398 | if hasattr(self, "task"): | ||
| 399 | self.task.cancel() | ||
| 400 | |||
| 401 | |||
| 402 | def setup(bot): | ||
| 403 | bot.add_cog(Cron(bot)) | ||
diff --git a/cogs/downloader.py b/cogs/downloader.py deleted file mode 100644 index 900bafb..0000000 --- a/cogs/downloader.py +++ /dev/null | |||
| @@ -1,234 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import tempfile | ||
| 8 | import logging | ||
| 9 | import requests | ||
| 10 | import discord | ||
| 11 | import re | ||
| 12 | import yt_dlp | ||
| 13 | from discord.ext import commands | ||
| 14 | import aiofiles | ||
| 15 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 16 | |||
| 17 | logger = logging.getLogger(__name__) | ||
| 18 | |||
| 19 | |||
| 20 | class Downloader(commands.Cog): | ||
| 21 | def __init__(self, bot): | ||
| 22 | self.bot = bot | ||
| 23 | |||
| 24 | URL_PATTERN = re.compile( | ||
| 25 | r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*' | ||
| 26 | ) | ||
| 27 | |||
| 28 | @commands.message_command( | ||
| 29 | name="download", | ||
| 30 | description="Download and convert video/audio from internet" | ||
| 31 | ) | ||
| 32 | async def message_download(self, ctx, message: discord.Message): | ||
| 33 | # check if message has a valid url | ||
| 34 | url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)] | ||
| 35 | if url: | ||
| 36 | for url in url: | ||
| 37 | await self.download(ctx=ctx, url=url, fformat="mp4") | ||
| 38 | else: | ||
| 39 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 40 | |||
| 41 | @commands.slash_command( | ||
| 42 | name="download", | ||
| 43 | description="Download and convert video/audio from internet" | ||
| 44 | ) | ||
| 45 | async def download( | ||
| 46 | self, | ||
| 47 | ctx: discord.ApplicationContext, | ||
| 48 | url: str = discord.Option(str, description="Link media", required=True), | ||
| 49 | catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False), | ||
| 50 | fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"), | ||
| 51 | filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False) | ||
| 52 | ): | ||
| 53 | """Download and convert media from a URL to the specified format""" | ||
| 54 | await ctx.defer() | ||
| 55 | # Call the function and get the response | ||
| 56 | response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user) | ||
| 57 | if isinstance(response, dict): | ||
| 58 | if response["status"] == "success": | ||
| 59 | if response["type"] == "url": | ||
| 60 | await ctx.respond(f"pobioron i wysyłon: {response['content']}") | ||
| 61 | elif response["type"] == "file": | ||
| 62 | file_data = response["content"] | ||
| 63 | await ctx.respond(file=discord.File(file_data)) | ||
| 64 | else: | ||
| 65 | error_msg = response["content"] | ||
| 66 | if 'details' in response: | ||
| 67 | error_msg += f"\n{response['details']}" | ||
| 68 | await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) | ||
| 69 | logger.error(f"Error: {response['content']}") | ||
| 70 | if 'details' in response: | ||
| 71 | logger.error(f"Details: {response['details']}") | ||
| 72 | |||
| 73 | @staticmethod | ||
| 74 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): | ||
| 75 | try: | ||
| 76 | url = clean_url_for_ytdlp(url) | ||
| 77 | logger.info(f"Downloading {url} as {fformat}") | ||
| 78 | |||
| 79 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: | ||
| 80 | ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) | ||
| 81 | |||
| 82 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | ||
| 83 | ydl.download([url]) | ||
| 84 | |||
| 85 | actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user) | ||
| 86 | |||
| 87 | if Downloader._should_upload_to_server(actual_file, catbox): | ||
| 88 | return await Downloader._upload_to_server(actual_file, filename, temp_dir) | ||
| 89 | else: | ||
| 90 | return Downloader._return_file_response(actual_file, filename, temp_dir) | ||
| 91 | |||
| 92 | except Exception as e: | ||
| 93 | return Downloader._create_error_response("Download failed", str(e), None) | ||
| 94 | |||
| 95 | @staticmethod | ||
| 96 | def _sanitize_filename(name: str) -> str: | ||
| 97 | invalid = '<>:"/\\|?*' | ||
| 98 | for c in invalid: | ||
| 99 | name = name.replace(c, '') | ||
| 100 | return name.strip() | ||
| 101 | |||
| 102 | @staticmethod | ||
| 103 | def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict: | ||
| 104 | if filename: | ||
| 105 | filename = Downloader._sanitize_filename(filename)[:100] | ||
| 106 | ydl_opts = { | ||
| 107 | 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), | ||
| 108 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', | ||
| 109 | 'merge_output_format': 'mp4', | ||
| 110 | 'postprocessors': [], | ||
| 111 | 'remote-components': 'ejs:github', | ||
| 112 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, | ||
| 113 | } | ||
| 114 | |||
| 115 | if fformat in ['mp3', 'wav', 'm4a']: | ||
| 116 | ydl_opts['postprocessors'].append({ | ||
| 117 | 'key': 'FFmpegExtractAudio', | ||
| 118 | 'preferredcodec': fformat, | ||
| 119 | 'preferredquality': '192', | ||
| 120 | }) | ||
| 121 | elif fformat != 'mp4': | ||
| 122 | ydl_opts['postprocessors'].append({ | ||
| 123 | 'key': 'FFmpegVideoConvertor', | ||
| 124 | 'preferedformat': fformat, | ||
| 125 | }) | ||
| 126 | |||
| 127 | return ydl_opts | ||
| 128 | |||
| 129 | @staticmethod | ||
| 130 | def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple: | ||
| 131 | files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] | ||
| 132 | if not files: | ||
| 133 | raise FileNotFoundError("No file was downloaded") | ||
| 134 | |||
| 135 | actual_file = os.path.join(temp_dir, files[0]) | ||
| 136 | extension = actual_file.split('.')[-1] | ||
| 137 | extension = Downloader._sanitize_filename(extension) | ||
| 138 | |||
| 139 | if not filename_user: | ||
| 140 | filename = os.path.basename(actual_file) | ||
| 141 | filename = Downloader._sanitize_filename(filename) | ||
| 142 | if len(filename) > 100: | ||
| 143 | filename = filename[:100] | ||
| 144 | else: | ||
| 145 | filename_user = Downloader._sanitize_filename(filename_user)[:100] | ||
| 146 | filename = filename_user + '.' + extension | ||
| 147 | actual_file = os.path.join(temp_dir, filename) | ||
| 148 | |||
| 149 | if not os.path.exists(actual_file): | ||
| 150 | raise FileNotFoundError(f"Downloaded file not found: {actual_file}") | ||
| 151 | |||
| 152 | return actual_file, filename | ||
| 153 | |||
| 154 | @staticmethod | ||
| 155 | def _should_upload_to_server(actual_file: str, catbox: bool) -> bool: | ||
| 156 | return os.path.getsize(actual_file) > 1024*1024*10 or catbox | ||
| 157 | |||
| 158 | @staticmethod | ||
| 159 | async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict: | ||
| 160 | try: | ||
| 161 | async with aiofiles.open(actual_file, 'rb') as f: | ||
| 162 | content = await f.read() | ||
| 163 | files = {'f': (filename, content, 'application/octet-stream')} | ||
| 164 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 165 | params = {'j': '1', 'ck': 'b2'} | ||
| 166 | base_url = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 167 | |||
| 168 | response = requests.post(base_url, files=files, headers=headers, params=params) | ||
| 169 | |||
| 170 | if response.status_code in [200, 201]: | ||
| 171 | return Downloader._handle_upload_success(response, filename, temp_dir) | ||
| 172 | elif response.status_code == 413: | ||
| 173 | return Downloader._create_error_response( | ||
| 174 | "File too large for upload", | ||
| 175 | "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", | ||
| 176 | temp_dir | ||
| 177 | ) | ||
| 178 | else: | ||
| 179 | return Downloader._create_error_response( | ||
| 180 | f"Server returned {response.status_code}", | ||
| 181 | response.text or 'Unknown error', | ||
| 182 | temp_dir | ||
| 183 | ) | ||
| 184 | except Exception as upload_error: | ||
| 185 | return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir) | ||
| 186 | |||
| 187 | @staticmethod | ||
| 188 | def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict: | ||
| 189 | try: | ||
| 190 | result = response.json() | ||
| 191 | if 'fileurl' in result: | ||
| 192 | return { | ||
| 193 | "status": "success", | ||
| 194 | "type": "url", | ||
| 195 | "content": result['fileurl'], | ||
| 196 | "filename": filename, | ||
| 197 | "temp_dir": temp_dir | ||
| 198 | } | ||
| 199 | else: | ||
| 200 | return Downloader._create_error_response( | ||
| 201 | "Invalid response from file server", | ||
| 202 | str(result), | ||
| 203 | temp_dir | ||
| 204 | ) | ||
| 205 | except ValueError: | ||
| 206 | return Downloader._create_error_response( | ||
| 207 | "Failed to parse server response", | ||
| 208 | response.text[:200], | ||
| 209 | temp_dir | ||
| 210 | ) | ||
| 211 | |||
| 212 | @staticmethod | ||
| 213 | def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict: | ||
| 214 | return { | ||
| 215 | "status": "success", | ||
| 216 | "type": "file", | ||
| 217 | "content": actual_file, | ||
| 218 | "filename": filename, | ||
| 219 | "temp_dir": temp_dir | ||
| 220 | } | ||
| 221 | |||
| 222 | @staticmethod | ||
| 223 | def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: | ||
| 224 | return { | ||
| 225 | "status": "error", | ||
| 226 | "type": "error", | ||
| 227 | "content": content, | ||
| 228 | "details": details, | ||
| 229 | "temp_dir": temp_dir | ||
| 230 | } | ||
| 231 | |||
| 232 | |||
| 233 | def setup(bot): | ||
| 234 | bot.add_cog(Downloader(bot)) | ||
diff --git a/cogs/math_commands.py b/cogs/math_commands.py deleted file mode 100644 index e30c92b..0000000 --- a/cogs/math_commands.py +++ /dev/null | |||
| @@ -1,83 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import asyncio | ||
| 8 | import re | ||
| 9 | |||
| 10 | from discord import ApplicationContext | ||
| 11 | from discord.ext import commands | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$') | ||
| 16 | |||
| 17 | |||
| 18 | def sanitize_math_input(equation: str) -> str | None: | ||
| 19 | if not equation or len(equation) > 1000: | ||
| 20 | return None | ||
| 21 | equation = equation.strip() | ||
| 22 | if not equation: | ||
| 23 | return None | ||
| 24 | if not MATH_SANITIZE_PATTERN.match(equation): | ||
| 25 | return None | ||
| 26 | for char in equation: | ||
| 27 | if ord(char) < 32 or ord(char) > 126: | ||
| 28 | return None | ||
| 29 | if equation.count('(') != equation.count(')'): | ||
| 30 | return None | ||
| 31 | if equation.count('[') != equation.count(']'): | ||
| 32 | return None | ||
| 33 | if equation.count('{') != equation.count('}'): | ||
| 34 | return None | ||
| 35 | dangerous_patterns = [ | ||
| 36 | r'eval', r'exec', r'__import__', r'os\.', r'sys\.', | ||
| 37 | r'subprocess', r'open(', r'file', r'shutil', | ||
| 38 | r'lambda:', r'compile', r'dir(', r'vars(', | ||
| 39 | r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r', | ||
| 40 | r'\x', r'\0', r'\a', r'\b', r'\f', r'\v', | ||
| 41 | ] | ||
| 42 | for pattern in dangerous_patterns: | ||
| 43 | if re.search(pattern, equation, re.IGNORECASE): | ||
| 44 | return None | ||
| 45 | return equation | ||
| 46 | |||
| 47 | |||
| 48 | class Math(commands.Cog): | ||
| 49 | def __init__(self, bot): | ||
| 50 | self.bot = bot | ||
| 51 | |||
| 52 | @commands.slash_command( | ||
| 53 | name="math", | ||
| 54 | description="wrug szyskih matfisuf" | ||
| 55 | ) | ||
| 56 | async def math(self, ctx: ApplicationContext, equation: str): | ||
| 57 | sanitized = sanitize_math_input(equation) | ||
| 58 | if sanitized is None: | ||
| 59 | await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True) | ||
| 60 | logger.warning(f"Math command rejected invalid input: {equation}") | ||
| 61 | return | ||
| 62 | try: | ||
| 63 | math_proc = await asyncio.create_subprocess_exec( | ||
| 64 | "qalc", "-e", sanitized, | ||
| 65 | stdout=asyncio.subprocess.PIPE, | ||
| 66 | stderr=asyncio.subprocess.PIPE | ||
| 67 | ) | ||
| 68 | stdout, stderr = await math_proc.communicate() | ||
| 69 | if math_proc.returncode != 0: | ||
| 70 | error_output = stderr.decode('utf-8', errors='replace').strip() | ||
| 71 | await ctx.respond(f"Calculation error: {error_output}", ephemeral=True) | ||
| 72 | logger.error(f"qalc error: {error_output}") | ||
| 73 | return | ||
| 74 | result = stdout.decode('utf-8', errors='replace').strip() | ||
| 75 | await ctx.respond(f"{sanitized} = {result}") | ||
| 76 | logger.info(f"Math command executed: {sanitized}") | ||
| 77 | except Exception as e: | ||
| 78 | await ctx.respond(f"Error: {str(e)}", ephemeral=True) | ||
| 79 | logger.error(f"Error in math command: {e}", exc_info=True) | ||
| 80 | |||
| 81 | |||
| 82 | def setup(bot): | ||
| 83 | bot.add_cog(Math(bot)) | ||
diff --git a/cogs/music/join.py b/cogs/music/join.py deleted file mode 100644 index d68ca79..0000000 --- a/cogs/music/join.py +++ /dev/null | |||
| @@ -1,29 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | |||
| 8 | from discord.ext import commands | ||
| 9 | from cogs.voice import get_voice_manager | ||
| 10 | |||
| 11 | logger = logging.getLogger(__name__) | ||
| 12 | |||
| 13 | |||
| 14 | class Join(commands.Cog): | ||
| 15 | def __init__(self, bot): | ||
| 16 | self.bot = bot | ||
| 17 | |||
| 18 | @commands.slash_command(name="join", description="dołącza do kanalu") | ||
| 19 | async def join(self, ctx): | ||
| 20 | voice_mgr = get_voice_manager(self.bot) | ||
| 21 | channel = ctx.author.voice.channel | ||
| 22 | await voice_mgr.connect(channel) | ||
| 23 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 24 | await ctx.interaction.delete_original_response() | ||
| 25 | logger.info("Bot connected to voice channel") | ||
| 26 | |||
| 27 | |||
| 28 | def setup(bot): | ||
| 29 | bot.add_cog(Join(bot)) | ||
diff --git a/cogs/music/loop.py b/cogs/music/loop.py deleted file mode 100644 index 6e2c838..0000000 --- a/cogs/music/loop.py +++ /dev/null | |||
| @@ -1,40 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | |||
| 8 | from discord.ext import commands | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | ||
| 11 | |||
| 12 | |||
| 13 | class Loop(commands.Cog): | ||
| 14 | def __init__(self, bot): | ||
| 15 | self.bot = bot | ||
| 16 | self.looping = {} | ||
| 17 | |||
| 18 | def is_looping(self, guild_id): | ||
| 19 | return self.looping.get(guild_id, False) | ||
| 20 | |||
| 21 | def set_looping(self, guild_id, value): | ||
| 22 | self.looping[guild_id] = value | ||
| 23 | |||
| 24 | @commands.slash_command( | ||
| 25 | name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" | ||
| 26 | ) | ||
| 27 | async def loop(self, ctx): | ||
| 28 | guild_id = ctx.guild.id | ||
| 29 | looping = not self.is_looping(guild_id) | ||
| 30 | self.set_looping(guild_id, looping) | ||
| 31 | await ctx.respond( | ||
| 32 | f"looping jest teraz {'włączony' if looping else 'wyłączony'}." | ||
| 33 | ) | ||
| 34 | logger.info( | ||
| 35 | f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" | ||
| 36 | ) | ||
| 37 | |||
| 38 | |||
| 39 | def setup(bot): | ||
| 40 | bot.add_cog(Loop(bot)) | ||
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py deleted file mode 100644 index 91bd406..0000000 --- a/cogs/music/media_processing.py +++ /dev/null | |||
| @@ -1,135 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | |||
| 10 | import yt_dlp | ||
| 11 | from youtubesearchpython import VideosSearch | ||
| 12 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | |||
| 17 | class MediaDownloader: | ||
| 18 | """Handles downloading media from various sources.""" | ||
| 19 | |||
| 20 | @staticmethod | ||
| 21 | async def download(ctx, url): | ||
| 22 | """Download audio from YouTube URL.""" | ||
| 23 | from cogs.utils.temp import get_temp_dir | ||
| 24 | |||
| 25 | temp_dir = get_temp_dir("senator_sperma_music") | ||
| 26 | |||
| 27 | ytdl_opts = { | ||
| 28 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | ||
| 29 | "postprocessors": [ | ||
| 30 | { | ||
| 31 | "key": "FFmpegExtractAudio", | ||
| 32 | "preferredcodec": "mp3", | ||
| 33 | "preferredquality": "192", | ||
| 34 | } | ||
| 35 | ], | ||
| 36 | "force_ipv4": True, | ||
| 37 | } | ||
| 38 | |||
| 39 | try: | ||
| 40 | url = clean_url_for_ytdlp(url) | ||
| 41 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | ||
| 42 | info_dict = ydl.extract_info(url, download=True) | ||
| 43 | # Get the base filename without extension | ||
| 44 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 45 | # Add .mp3 extension since we're extracting audio | ||
| 46 | filename = f"{base_filename}.mp3" | ||
| 47 | logger.info(f"Downloaded file: {filename}") | ||
| 48 | return filename | ||
| 49 | except Exception as e: | ||
| 50 | logger.error(f"Error downloading video: {e}") | ||
| 51 | await ctx.respond("eror pobirajync wideło", ephemeral=True) | ||
| 52 | |||
| 53 | @staticmethod | ||
| 54 | async def get_url(ctx, query): | ||
| 55 | """Search YouTube and get URL for a query.""" | ||
| 56 | search = VideosSearch(query, limit=1) | ||
| 57 | if search.result()["result"]: | ||
| 58 | title = search.result()["result"][0]["title"] | ||
| 59 | url = search.result()["result"][0]["link"] | ||
| 60 | logger.info(f"Found video: {title}") | ||
| 61 | return url, title | ||
| 62 | else: | ||
| 63 | logger.warning("No results found for the query.") | ||
| 64 | await ctx.respond("nic ni ma", ephemeral=True) | ||
| 65 | return None | ||
| 66 | |||
| 67 | |||
| 68 | class AudioCompressor: | ||
| 69 | """Handles audio compression using FFmpeg.""" | ||
| 70 | |||
| 71 | @staticmethod | ||
| 72 | async def compress(ctx, filename): | ||
| 73 | """Compress audio file to opus format.""" | ||
| 74 | from cogs.utils.temp import get_temp_dir | ||
| 75 | |||
| 76 | if not filename: | ||
| 77 | await ctx.respond("dzie wideło", ephemeral=True) | ||
| 78 | return None | ||
| 79 | |||
| 80 | temp_dir = get_temp_dir("senator_sperma_compressed") | ||
| 81 | |||
| 82 | # Create a temporary output filename in the temp directory | ||
| 83 | base_name = os.path.basename(filename) | ||
| 84 | output_filename = os.path.join( | ||
| 85 | temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" | ||
| 86 | ) | ||
| 87 | |||
| 88 | # Ensure the output directory exists | ||
| 89 | os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) | ||
| 90 | |||
| 91 | # Use proper quoting for filenames with spaces | ||
| 92 | ffmpeg_command = [ | ||
| 93 | "ffmpeg", | ||
| 94 | "-y", # Overwrite output file if it exists | ||
| 95 | "-i", | ||
| 96 | filename, | ||
| 97 | "-ac", | ||
| 98 | "1", # Mono audio | ||
| 99 | "-b:a", | ||
| 100 | "500", # 500k bitrate | ||
| 101 | "-acodec", | ||
| 102 | "libopus", | ||
| 103 | output_filename, | ||
| 104 | ] | ||
| 105 | |||
| 106 | try: | ||
| 107 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") | ||
| 108 | process = await asyncio.create_subprocess_exec( | ||
| 109 | *ffmpeg_command, | ||
| 110 | stdout=asyncio.subprocess.PIPE, | ||
| 111 | stderr=asyncio.subprocess.PIPE, | ||
| 112 | ) | ||
| 113 | |||
| 114 | _, stderr = await process.communicate() | ||
| 115 | |||
| 116 | if process.returncode != 0: | ||
| 117 | error_msg = stderr.decode() | ||
| 118 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") | ||
| 119 | await ctx.respond("eror kompresujync pilk", ephemeral=True) | ||
| 120 | return None | ||
| 121 | |||
| 122 | if not os.path.exists(output_filename): | ||
| 123 | logger.error(f"Output file was not created: {output_filename}") | ||
| 124 | await ctx.respond( | ||
| 125 | "eror kompresujync pilk - output file not created", ephemeral=True | ||
| 126 | ) | ||
| 127 | return None | ||
| 128 | |||
| 129 | logger.info(f"Successfully compressed file: {output_filename}") | ||
| 130 | return output_filename | ||
| 131 | |||
| 132 | except Exception as e: | ||
| 133 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) | ||
| 134 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) | ||
| 135 | return None | ||
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py deleted file mode 100644 index c6a55b0..0000000 --- a/cogs/music/play_commands.py +++ /dev/null | |||
| @@ -1,320 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import os | ||
| 8 | import urllib.parse | ||
| 9 | import asyncio | ||
| 10 | from os import PathLike | ||
| 11 | from typing import Awaitable, Callable, List, Tuple | ||
| 12 | import re | ||
| 13 | import requests | ||
| 14 | from yt_dlp import YoutubeDL | ||
| 15 | |||
| 16 | import discord | ||
| 17 | from discord.commands import OptionChoice | ||
| 18 | from discord.ext import commands | ||
| 19 | |||
| 20 | from cogs.music import media_processing | ||
| 21 | from cogs.utils.file_search import UniversalSearcher | ||
| 22 | from cogs.utils.settings import get_uploadurl | ||
| 23 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 24 | from cogs.voice import get_voice_manager | ||
| 25 | |||
| 26 | logger = logging.getLogger(__name__) | ||
| 27 | |||
| 28 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 29 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 30 | ) | ||
| 31 | |||
| 32 | |||
| 33 | class PlayCommands(commands.Cog): | ||
| 34 | """Discord slash commands for music playback.""" | ||
| 35 | |||
| 36 | def __init__(self, bot): | ||
| 37 | from cogs.utils.temp import get_temp_dir | ||
| 38 | |||
| 39 | self.bot = bot | ||
| 40 | self.temp_dir = get_temp_dir() | ||
| 41 | self.path_mapping = {} # Maps display names to full paths | ||
| 42 | |||
| 43 | @staticmethod | ||
| 44 | async def _create_search_embed( | ||
| 45 | candidates: List[str], | ||
| 46 | ) -> Tuple[discord.Embed, discord.ui.View]: | ||
| 47 | """Create an embed and view for search results.""" | ||
| 48 | embed = discord.Embed( | ||
| 49 | title="Wyniki wyszukiwania", | ||
| 50 | description="Wybierz plik do odtworzenia:", | ||
| 51 | color=discord.Color.blue(), | ||
| 52 | ) | ||
| 53 | |||
| 54 | view = discord.ui.View(disable_on_timeout=True) | ||
| 55 | |||
| 56 | for i, candidate in enumerate(candidates, start=1): | ||
| 57 | filename = candidate.split("/")[-1].split("?")[0] | ||
| 58 | display_name = os.path.splitext(filename)[0] | ||
| 59 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | ||
| 60 | |||
| 61 | return embed, view | ||
| 62 | |||
| 63 | async def _handle_search_results( | ||
| 64 | self, | ||
| 65 | ctx: discord.ApplicationContext, | ||
| 66 | candidates: List[str], | ||
| 67 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]], | ||
| 68 | ) -> None: | ||
| 69 | """Handle the display of search results and button interactions.""" | ||
| 70 | if not candidates: | ||
| 71 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | ||
| 72 | return | ||
| 73 | |||
| 74 | embed, view = await self._create_search_embed(candidates) | ||
| 75 | |||
| 76 | # Add buttons for each candidate | ||
| 77 | for i, candidate in enumerate(candidates, start=1): | ||
| 78 | button = discord.ui.Button(label=f"Play {i}") | ||
| 79 | |||
| 80 | async def button_callback( | ||
| 81 | interaction: discord.Interaction, candidate_path=candidate | ||
| 82 | ): | ||
| 83 | await interaction.response.defer(ephemeral=True) | ||
| 84 | await ctx.interaction.delete_original_response() | ||
| 85 | try: | ||
| 86 | await play_callback(interaction, candidate_path) | ||
| 87 | except Exception as e: | ||
| 88 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) | ||
| 89 | await interaction.followup.send( | ||
| 90 | f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True | ||
| 91 | ) | ||
| 92 | |||
| 93 | button.callback = button_callback | ||
| 94 | view.add_item(button) | ||
| 95 | |||
| 96 | await ctx.respond(embed=embed, view=view, ephemeral=True) | ||
| 97 | |||
| 98 | async def _search_autocomplete( | ||
| 99 | self, ctx: discord.AutocompleteContext | ||
| 100 | ) -> List[OptionChoice]: | ||
| 101 | """Autocomplete handler for search queries.""" | ||
| 102 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 103 | return [] | ||
| 104 | |||
| 105 | query = ctx.value.lower() | ||
| 106 | try: | ||
| 107 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( | ||
| 108 | query, max_results=25 | ||
| 109 | ) | ||
| 110 | choices = [] | ||
| 111 | self.path_mapping = {} # Reset the mapping for each new query | ||
| 112 | |||
| 113 | for url in results: | ||
| 114 | filename = url.split("/")[-1] | ||
| 115 | filename = filename.split("?")[0] # Remove query params if any | ||
| 116 | filename = urllib.parse.unquote(filename) # Decode URL encoding | ||
| 117 | display = os.path.splitext(filename)[0] # Remove extension | ||
| 118 | |||
| 119 | display = display.strip() | ||
| 120 | |||
| 121 | if len(display) > 100: | ||
| 122 | display = display[:99] + "…" | ||
| 123 | |||
| 124 | self.path_mapping[display] = url | ||
| 125 | choices.append(OptionChoice(display, display)) | ||
| 126 | |||
| 127 | if len(choices) >= 25: | ||
| 128 | break | ||
| 129 | |||
| 130 | return choices | ||
| 131 | |||
| 132 | except Exception as e: | ||
| 133 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | ||
| 134 | return [] | ||
| 135 | |||
| 136 | async def _handle_compressed_file_play( | ||
| 137 | self, ctx: discord.ApplicationContext, url: str | ||
| 138 | ) -> None: | ||
| 139 | """Download from copyparty, compress, then play locally.""" | ||
| 140 | import aiohttp | ||
| 141 | |||
| 142 | local_path = None | ||
| 143 | compressed_path = None | ||
| 144 | try: | ||
| 145 | async with aiohttp.ClientSession() as session: | ||
| 146 | async with session.get(url) as response: | ||
| 147 | if response.status != 200: | ||
| 148 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 149 | return | ||
| 150 | |||
| 151 | filename = url.split("/")[-1].split("?")[0] | ||
| 152 | filename = urllib.parse.unquote(filename) | ||
| 153 | local_path = os.path.join(self.temp_dir, filename) | ||
| 154 | os.makedirs(self.temp_dir, exist_ok=True) | ||
| 155 | |||
| 156 | loop = asyncio.get_event_loop() | ||
| 157 | with open(local_path, "wb") as f: | ||
| 158 | async for chunk in response.content.iter_chunked(8192): | ||
| 159 | await loop.run_in_executor(None, f.write, chunk) | ||
| 160 | |||
| 161 | compressed_path = await media_processing.AudioCompressor.compress( | ||
| 162 | ctx, local_path | ||
| 163 | ) | ||
| 164 | if not compressed_path: | ||
| 165 | return | ||
| 166 | |||
| 167 | await get_voice_manager(self.bot).play( | ||
| 168 | ctx, compressed_path, delete_after_play=True | ||
| 169 | ) | ||
| 170 | except Exception as e: | ||
| 171 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) | ||
| 172 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) | ||
| 173 | finally: | ||
| 174 | if local_path and os.path.exists(local_path): | ||
| 175 | try: | ||
| 176 | os.remove(local_path) | ||
| 177 | except Exception: | ||
| 178 | pass | ||
| 179 | |||
| 180 | async def _handle_external_url_play( | ||
| 181 | self, ctx: discord.ApplicationContext, url: str | ||
| 182 | ) -> None: | ||
| 183 | """Download from external URL, upload to copyparty, then play.""" | ||
| 184 | local_path = None | ||
| 185 | try: | ||
| 186 | ytdl_opts = { | ||
| 187 | "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), | ||
| 188 | "postprocessors": [ | ||
| 189 | { | ||
| 190 | "key": "FFmpegExtractAudio", | ||
| 191 | "preferredcodec": "mp3", | ||
| 192 | "preferredquality": "192", | ||
| 193 | } | ||
| 194 | ], | ||
| 195 | "force_ipv4": True, | ||
| 196 | } | ||
| 197 | |||
| 198 | loop = asyncio.get_event_loop() | ||
| 199 | url = clean_url_for_ytdlp(url) | ||
| 200 | with YoutubeDL(ytdl_opts) as ydl: | ||
| 201 | info_dict = await loop.run_in_executor( | ||
| 202 | None, lambda: ydl.extract_info(url, download=True) | ||
| 203 | ) | ||
| 204 | title = info_dict.get("title", "nieznany") | ||
| 205 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 206 | local_path = f"{base_filename}.mp3" | ||
| 207 | |||
| 208 | if not os.path.exists(local_path): | ||
| 209 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 210 | return | ||
| 211 | |||
| 212 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | ||
| 213 | |||
| 214 | await get_voice_manager(self.bot).play( | ||
| 215 | ctx, local_path, delete_after_play=True | ||
| 216 | ) | ||
| 217 | |||
| 218 | try: | ||
| 219 | await asyncio.wait_for(upload_task, timeout=30.0) | ||
| 220 | except asyncio.TimeoutError: | ||
| 221 | logger.warning("Upload timed out") | ||
| 222 | except Exception as e: | ||
| 223 | logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) | ||
| 224 | if local_path and os.path.exists(local_path): | ||
| 225 | try: | ||
| 226 | os.remove(local_path) | ||
| 227 | except Exception: | ||
| 228 | pass | ||
| 229 | await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) | ||
| 230 | |||
| 231 | async def _upload_to_copyparty_async(self, file_path: str) -> None: | ||
| 232 | """Upload file to copyparty asynchronously (fire and forget).""" | ||
| 233 | try: | ||
| 234 | logger.info(f"Starting async upload: {file_path}") | ||
| 235 | baseurl = get_uploadurl() | ||
| 236 | password = os.getenv("COPYPARTY_PASSWORD", "") | ||
| 237 | |||
| 238 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | ||
| 239 | |||
| 240 | if baseurl == "http://localhost:3939": | ||
| 241 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 242 | |||
| 243 | with open(file_path, "rb") as f: | ||
| 244 | content = f.read() | ||
| 245 | |||
| 246 | files = {"f": (os.path.basename(file_path), content, "application/octet-stream")} | ||
| 247 | headers = {"Cookie": f"cppwd={password}"} | ||
| 248 | params = {"j": "1", "ck": "b2"} | ||
| 249 | |||
| 250 | logger.info(f"Posting to {baseurl}") | ||
| 251 | response = requests.post(baseurl, files=files, headers=headers, params=params) | ||
| 252 | text = response.text | ||
| 253 | logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}") | ||
| 254 | |||
| 255 | if response.status_code in [200, 201]: | ||
| 256 | result = response.json() | ||
| 257 | logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") | ||
| 258 | except Exception as e: | ||
| 259 | logger.error(f"Async upload failed: {e}", exc_info=True) | ||
| 260 | |||
| 261 | async def _handle_local_file_play( | ||
| 262 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | ||
| 263 | ) -> None: | ||
| 264 | """Handle file playback from copyparty URL.""" | ||
| 265 | url = self.path_mapping.get(file, file) # Look up URL from display name | ||
| 266 | |||
| 267 | if compressed: | ||
| 268 | await self._handle_compressed_file_play(ctx, url) | ||
| 269 | else: | ||
| 270 | await get_voice_manager(self.bot).play(ctx, url) | ||
| 271 | |||
| 272 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | ||
| 273 | async def play( | ||
| 274 | self, | ||
| 275 | ctx: discord.ApplicationContext, | ||
| 276 | melodia: str = discord.Option( | ||
| 277 | str, | ||
| 278 | "odtwórz plik z serwera bądź strumienia", | ||
| 279 | autocomplete=_search_autocomplete, | ||
| 280 | required=False, | ||
| 281 | ), | ||
| 282 | file: discord.Attachment = discord.Option( | ||
| 283 | discord.Attachment, "plik do zagrania", required=False | ||
| 284 | ), | ||
| 285 | compressed: bool = discord.Option( | ||
| 286 | bool, "Czy skompresować dźwięk?", default=False | ||
| 287 | ), | ||
| 288 | ): | ||
| 289 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" | ||
| 290 | await ctx.defer() | ||
| 291 | |||
| 292 | if melodia and file: | ||
| 293 | await ctx.respond( | ||
| 294 | "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True | ||
| 295 | ) | ||
| 296 | return | ||
| 297 | |||
| 298 | if not melodia and not file: | ||
| 299 | await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) | ||
| 300 | return | ||
| 301 | |||
| 302 | if file: | ||
| 303 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) | ||
| 304 | await file.save(file_path) | ||
| 305 | await get_voice_manager(self.bot).play(ctx, file_path) | ||
| 306 | else: | ||
| 307 | try: | ||
| 308 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): | ||
| 309 | await self._handle_external_url_play(ctx, melodia) | ||
| 310 | else: | ||
| 311 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 312 | except Exception as e: | ||
| 313 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 314 | await ctx.respond( | ||
| 315 | f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True | ||
| 316 | ) | ||
| 317 | |||
| 318 | |||
| 319 | def setup(bot): | ||
| 320 | bot.add_cog(PlayCommands(bot)) | ||
diff --git a/cogs/music/stop.py b/cogs/music/stop.py deleted file mode 100644 index 9c8d5cf..0000000 --- a/cogs/music/stop.py +++ /dev/null | |||
| @@ -1,29 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | from discord.ext import commands | ||
| 7 | |||
| 8 | from cogs.voice import get_voice_manager | ||
| 9 | |||
| 10 | import logging | ||
| 11 | |||
| 12 | logger = logging.getLogger(__name__) | ||
| 13 | |||
| 14 | |||
| 15 | class Stop(commands.Cog): | ||
| 16 | def __init__(self, bot): | ||
| 17 | self.bot = bot | ||
| 18 | |||
| 19 | @commands.slash_command(name="stop", description="stopuj bota") | ||
| 20 | async def stop(self, ctx): | ||
| 21 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 22 | await ctx.interaction.delete_original_response() | ||
| 23 | voice_mgr = get_voice_manager(self.bot) | ||
| 24 | await voice_mgr.disconnect(ctx.guild) | ||
| 25 | logger.info("Bot disconnected from voice channel") | ||
| 26 | |||
| 27 | |||
| 28 | def setup(bot): | ||
| 29 | bot.add_cog(Stop(bot)) | ||
diff --git a/cogs/qr.py b/cogs/qr.py deleted file mode 100644 index 32c574e..0000000 --- a/cogs/qr.py +++ /dev/null | |||
| @@ -1,154 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import tempfile | ||
| 10 | |||
| 11 | import aiohttp | ||
| 12 | import discord | ||
| 13 | import qrcode | ||
| 14 | from discord.ext import commands | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | _pil_image = None | ||
| 19 | _decode_func = None | ||
| 20 | _decode_available = False | ||
| 21 | |||
| 22 | |||
| 23 | def _ensure_qr(): | ||
| 24 | global _pil_image, _decode_func, _decode_available | ||
| 25 | |||
| 26 | if _pil_image is not None: | ||
| 27 | return _decode_available | ||
| 28 | |||
| 29 | try: | ||
| 30 | from PIL import Image | ||
| 31 | from pyzbar.pyzbar import decode as _decode_func | ||
| 32 | _pil_image = Image | ||
| 33 | _decode_available = True | ||
| 34 | except ImportError: | ||
| 35 | _decode_available = False | ||
| 36 | logger.warning("pyzbar or PIL not available, decode command will be disabled") | ||
| 37 | return _decode_available | ||
| 38 | |||
| 39 | |||
| 40 | class QR(commands.Cog): | ||
| 41 | def __init__(self, bot): | ||
| 42 | self.bot = bot | ||
| 43 | |||
| 44 | async def _download_image(self, url): | ||
| 45 | """Download image from URL to temporary file""" | ||
| 46 | async with aiohttp.ClientSession() as session: | ||
| 47 | async with session.get(url) as response: | ||
| 48 | if response.status == 200: | ||
| 49 | content = await response.read() | ||
| 50 | with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: | ||
| 51 | await asyncio.get_event_loop().run_in_executor(None, f.write, content) | ||
| 52 | return f.name | ||
| 53 | return None | ||
| 54 | |||
| 55 | @commands.slash_command( | ||
| 56 | name="qr_generuj", description="Generuje kod QR z podanego tekstu" | ||
| 57 | ) | ||
| 58 | async def qr( | ||
| 59 | self, | ||
| 60 | ctx, | ||
| 61 | text: str = discord.Option(str, "Tekst do zakodowania", required=True), | ||
| 62 | ): | ||
| 63 | await ctx.defer() | ||
| 64 | |||
| 65 | from cogs.utils.temp import get_temp_dir | ||
| 66 | temp_path = os.path.join( | ||
| 67 | get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" | ||
| 68 | ) | ||
| 69 | |||
| 70 | try: | ||
| 71 | loop = asyncio.get_event_loop() | ||
| 72 | await loop.run_in_executor(None, self._generate_qr, text, temp_path) | ||
| 73 | await ctx.respond(file=discord.File(temp_path, filename="qr.png")) | ||
| 74 | logger.info(f"Generated QR for text: {text}") | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"QR generation error: {e}") | ||
| 77 | await ctx.respond("Error generating QR code", ephemeral=True) | ||
| 78 | finally: | ||
| 79 | try: | ||
| 80 | if os.path.exists(temp_path): | ||
| 81 | os.unlink(temp_path) | ||
| 82 | except Exception as e: | ||
| 83 | logger.error(f"Error cleaning up temp file: {e}") | ||
| 84 | |||
| 85 | def _generate_qr(self, text, output_path): | ||
| 86 | """Generate QR code (runs in executor)""" | ||
| 87 | qr = qrcode.QRCode( | ||
| 88 | version=1, | ||
| 89 | error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] | ||
| 90 | box_size=10, | ||
| 91 | border=4, | ||
| 92 | ) | ||
| 93 | qr.add_data(text) | ||
| 94 | qr.make(fit=True) | ||
| 95 | img = qr.make_image(fill_color="black", back_color="white") | ||
| 96 | img.save(output_path) | ||
| 97 | |||
| 98 | @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka") | ||
| 99 | async def decode( | ||
| 100 | self, | ||
| 101 | ctx, | ||
| 102 | image: discord.Attachment = discord.Option( | ||
| 103 | discord.Attachment, "Obrazek z kodem QR", required=True | ||
| 104 | ), | ||
| 105 | ): | ||
| 106 | if not _ensure_qr(): | ||
| 107 | await ctx.respond( | ||
| 108 | "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True | ||
| 109 | ) | ||
| 110 | return | ||
| 111 | |||
| 112 | await ctx.defer() | ||
| 113 | |||
| 114 | try: | ||
| 115 | temp_path = await self._download_image(image.url) | ||
| 116 | if not temp_path: | ||
| 117 | await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True) | ||
| 118 | return | ||
| 119 | |||
| 120 | try: | ||
| 121 | loop = asyncio.get_event_loop() | ||
| 122 | decoded = await loop.run_in_executor(None, self._decode_qr, temp_path) | ||
| 123 | |||
| 124 | if decoded: | ||
| 125 | await ctx.respond(f"```\n{decoded}\n```") | ||
| 126 | logger.info(f"Decoded QR: {decoded}") | ||
| 127 | else: | ||
| 128 | await ctx.respond( | ||
| 129 | "Nie znaleziono kodu QR na obrazku", ephemeral=True | ||
| 130 | ) | ||
| 131 | finally: | ||
| 132 | if os.path.exists(temp_path): | ||
| 133 | os.unlink(temp_path) | ||
| 134 | except Exception as e: | ||
| 135 | logger.error(f"QR decode error: {e}") | ||
| 136 | await ctx.respond("Error dekodowania QR", ephemeral=True) | ||
| 137 | |||
| 138 | def _decode_qr(self, image_path): | ||
| 139 | """Decode QR code from image (runs in executor)""" | ||
| 140 | if not _ensure_qr() or _decode_func is None: | ||
| 141 | return None | ||
| 142 | |||
| 143 | try: | ||
| 144 | img = _pil_image.open(image_path) | ||
| 145 | decoded_list = _decode_func(img) | ||
| 146 | if decoded_list: | ||
| 147 | return decoded_list[0].data.decode("utf-8") | ||
| 148 | except Exception as e: | ||
| 149 | logger.error(f"Error decoding QR: {e}") | ||
| 150 | return None | ||
| 151 | |||
| 152 | |||
| 153 | def setup(bot): | ||
| 154 | bot.add_cog(QR(bot)) | ||
diff --git a/cogs/say.py b/cogs/say.py deleted file mode 100644 index 29d95fe..0000000 --- a/cogs/say.py +++ /dev/null | |||
| @@ -1,28 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | from discord.ext import commands | ||
| 7 | import discord | ||
| 8 | import logging | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | ||
| 11 | |||
| 12 | class Say(commands.Cog): | ||
| 13 | def __init__(self, bot): | ||
| 14 | self.bot = bot | ||
| 15 | |||
| 16 | @discord.slash_command(name="say", description="senator, gadaj") | ||
| 17 | async def say( | ||
| 18 | self, | ||
| 19 | ctx: discord.ApplicationContext, | ||
| 20 | text: str = discord.Option(str, "Tekst do przeczytania", required=True) | ||
| 21 | ): | ||
| 22 | await ctx.defer() | ||
| 23 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 24 | await ctx.interaction.delete_original_response() | ||
| 25 | await ctx.send(content=text) | ||
| 26 | |||
| 27 | def setup(bot): | ||
| 28 | bot.add_cog(Say(bot)) | ||
diff --git a/cogs/search.py b/cogs/search.py deleted file mode 100644 index a124cbf..0000000 --- a/cogs/search.py +++ /dev/null | |||
| @@ -1,73 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import urllib.parse | ||
| 7 | |||
| 8 | import discord | ||
| 9 | from discord.ext import commands | ||
| 10 | import logging | ||
| 11 | from discord import AutocompleteContext | ||
| 12 | from cogs.utils import file_search as search_utils | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | class Search(commands.Cog): | ||
| 17 | def __init__(self, bot): | ||
| 18 | self.bot = bot | ||
| 19 | self._cached_results = {} | ||
| 20 | |||
| 21 | async def file_autocomplete(self, ctx: AutocompleteContext): | ||
| 22 | """Autocomplete function for file search.""" | ||
| 23 | query = ctx.value.lower() if ctx.value else "" | ||
| 24 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 25 | |||
| 26 | user_id = ctx.interaction.user.id | ||
| 27 | self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 28 | |||
| 29 | choices = [] | ||
| 30 | for url in results[:25]: | ||
| 31 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 32 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 33 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 34 | return choices | ||
| 35 | |||
| 36 | @commands.slash_command( | ||
| 37 | name="search", | ||
| 38 | description="wyszukaj pliku" | ||
| 39 | ) | ||
| 40 | @discord.option( | ||
| 41 | name="filename", | ||
| 42 | description="Search for a file to send", | ||
| 43 | autocomplete=file_autocomplete | ||
| 44 | ) | ||
| 45 | async def search(self, ctx, filename: str): | ||
| 46 | """Send a file via URL from autocomplete selection.""" | ||
| 47 | if not filename: | ||
| 48 | await ctx.reply("Please select a file from the autocomplete options.") | ||
| 49 | return | ||
| 50 | |||
| 51 | await ctx.defer() | ||
| 52 | logger.info(f"Search command called by {ctx.author} for file: {filename}") | ||
| 53 | |||
| 54 | cached = self._cached_results.get(ctx.author.id) | ||
| 55 | if not cached: | ||
| 56 | await ctx.send_followup("Search results expired. Please search again.") | ||
| 57 | return | ||
| 58 | |||
| 59 | matching_url = cached.get(filename) | ||
| 60 | if not matching_url: | ||
| 61 | await ctx.send_followup(f"File '{filename}' not found.") | ||
| 62 | return | ||
| 63 | |||
| 64 | try: | ||
| 65 | await ctx.send_followup(matching_url) | ||
| 66 | logger.info(f"Successfully sent URL: {matching_url}") | ||
| 67 | del self._cached_results[ctx.author.id] | ||
| 68 | except Exception as e: | ||
| 69 | logger.error(f"Error sending URL {matching_url}: {str(e)}") | ||
| 70 | await ctx.send_followup(str(e)) | ||
| 71 | |||
| 72 | def setup(bot): | ||
| 73 | bot.add_cog(Search(bot)) | ||
diff --git a/cogs/trigger.py b/cogs/trigger.py deleted file mode 100644 index ce80d43..0000000 --- a/cogs/trigger.py +++ /dev/null | |||
| @@ -1,260 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import discord | ||
| 8 | from discord.ext import commands | ||
| 9 | import random | ||
| 10 | import logging | ||
| 11 | |||
| 12 | from cogs.utils.database import get_connection, init_database | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | init_database() | ||
| 17 | |||
| 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" | ||
| 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" | ||
| 20 | _SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" | ||
| 21 | _TRIGGER_NOT_FOUND_MSG = "Trigger not found." | ||
| 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" | ||
| 23 | |||
| 24 | |||
| 25 | class Trigger(commands.Cog): | ||
| 26 | def __init__(self, bot): | ||
| 27 | self.bot = bot | ||
| 28 | |||
| 29 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") | ||
| 30 | |||
| 31 | @commands.Cog.listener() | ||
| 32 | async def on_message(self, message): | ||
| 33 | if message.author == self.bot.user: | ||
| 34 | return | ||
| 35 | |||
| 36 | await self.trigger(message) | ||
| 37 | |||
| 38 | @staticmethod | ||
| 39 | async def trigger(message): | ||
| 40 | content = message.content.lower() | ||
| 41 | with get_connection() as conn: | ||
| 42 | c = conn.cursor() | ||
| 43 | c.execute("SELECT id, name FROM triggers") | ||
| 44 | for trigger_id, name in c.fetchall(): | ||
| 45 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 46 | keywords = [row[0] for row in c.fetchall()] | ||
| 47 | if any(keyword in content for keyword in keywords): | ||
| 48 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 49 | responses = [row[0] for row in c.fetchall()] | ||
| 50 | if responses: | ||
| 51 | await message.channel.send(random.choice(responses).replace('\\n', '\n')) | ||
| 52 | logger.info(f"{message.author} triggered {name}") | ||
| 53 | break | ||
| 54 | |||
| 55 | @staticmethod | ||
| 56 | def get_type(name): | ||
| 57 | with get_connection() as conn: | ||
| 58 | c = conn.cursor() | ||
| 59 | c.execute("SELECT name FROM triggers") | ||
| 60 | return [row[0] for row in c.fetchall()] | ||
| 61 | |||
| 62 | @trigger_group.command( | ||
| 63 | description="Create a new trigger", | ||
| 64 | ) | ||
| 65 | async def create( | ||
| 66 | self, | ||
| 67 | ctx: discord.ApplicationContext, | ||
| 68 | name = discord.Option( | ||
| 69 | str, | ||
| 70 | _TRIGGER_NAME_OPTION, | ||
| 71 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 72 | required=True | ||
| 73 | ) | ||
| 74 | ): | ||
| 75 | with get_connection() as conn: | ||
| 76 | c = conn.cursor() | ||
| 77 | c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) | ||
| 78 | conn.commit() | ||
| 79 | |||
| 80 | await ctx.respond("trigger added") | ||
| 81 | |||
| 82 | @trigger_group.command( | ||
| 83 | description="Add a trigger or response to the name", | ||
| 84 | ) | ||
| 85 | async def add( | ||
| 86 | self, | ||
| 87 | ctx: discord.ApplicationContext, | ||
| 88 | name = discord.Option( | ||
| 89 | str, | ||
| 90 | _TRIGGER_NAME_OPTION, | ||
| 91 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 92 | required=True | ||
| 93 | ), | ||
| 94 | trigger = discord.Option( | ||
| 95 | str, | ||
| 96 | "the trigger to add to the name", | ||
| 97 | required=False | ||
| 98 | ), | ||
| 99 | response = discord.Option( | ||
| 100 | str, | ||
| 101 | "the response to add to the name", | ||
| 102 | required=False | ||
| 103 | ) | ||
| 104 | ): | ||
| 105 | with get_connection() as conn: | ||
| 106 | c = conn.cursor() | ||
| 107 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 108 | row = c.fetchone() | ||
| 109 | if not row: | ||
| 110 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 111 | logger.warning(f"Trigger not found: {name}") | ||
| 112 | return | ||
| 113 | trigger_id = row[0] | ||
| 114 | if trigger: | ||
| 115 | for keyword in trigger.split('\\'): | ||
| 116 | keyword = keyword.strip() | ||
| 117 | if keyword: | ||
| 118 | c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword)) | ||
| 119 | if response: | ||
| 120 | for resp in response.split('\\'): | ||
| 121 | resp = resp.strip() | ||
| 122 | if resp: | ||
| 123 | c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) | ||
| 124 | conn.commit() | ||
| 125 | |||
| 126 | await ctx.respond("trigger updated") | ||
| 127 | logger.info(f"Trigger updated: {name}") | ||
| 128 | |||
| 129 | @trigger_group.command( | ||
| 130 | description="Remove a trigger or response from the name", | ||
| 131 | ) | ||
| 132 | async def remove( | ||
| 133 | self, | ||
| 134 | ctx: discord.ApplicationContext, | ||
| 135 | name = discord.Option( | ||
| 136 | str, | ||
| 137 | _TRIGGER_NAME_OPTION, | ||
| 138 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 139 | required=True | ||
| 140 | ), | ||
| 141 | trigger = discord.Option( | ||
| 142 | str, | ||
| 143 | "the trigger to remove from the name", | ||
| 144 | required=False | ||
| 145 | ), | ||
| 146 | response = discord.Option( | ||
| 147 | str, | ||
| 148 | "the response to remove from the name", | ||
| 149 | required=False | ||
| 150 | ) | ||
| 151 | ): | ||
| 152 | with get_connection() as conn: | ||
| 153 | c = conn.cursor() | ||
| 154 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 155 | row = c.fetchone() | ||
| 156 | if not row: | ||
| 157 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 158 | logger.warning(f"Trigger not found: {name}") | ||
| 159 | return | ||
| 160 | trigger_id = row[0] | ||
| 161 | if trigger: | ||
| 162 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger)) | ||
| 163 | if response: | ||
| 164 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) | ||
| 165 | conn.commit() | ||
| 166 | |||
| 167 | await ctx.respond("trigger removed") | ||
| 168 | logger.info(f"Trigger removed: {name}") | ||
| 169 | |||
| 170 | @trigger_group.command( | ||
| 171 | description="Delete the whole trigger", | ||
| 172 | ) | ||
| 173 | async def delete( | ||
| 174 | self, | ||
| 175 | ctx: discord.ApplicationContext, | ||
| 176 | name = discord.Option( | ||
| 177 | str, | ||
| 178 | _TRIGGER_NAME_OPTION, | ||
| 179 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 180 | required=True | ||
| 181 | ) | ||
| 182 | ): | ||
| 183 | with get_connection() as conn: | ||
| 184 | c = conn.cursor() | ||
| 185 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 186 | row = c.fetchone() | ||
| 187 | if not row: | ||
| 188 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 189 | return | ||
| 190 | trigger_id = row[0] | ||
| 191 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | ||
| 192 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | ||
| 193 | c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) | ||
| 194 | conn.commit() | ||
| 195 | |||
| 196 | await ctx.respond("trigger deleted") | ||
| 197 | logger.info(f"Trigger deleted: {name}") | ||
| 198 | |||
| 199 | |||
| 200 | @trigger_group.command( | ||
| 201 | description="List all triggers", | ||
| 202 | ) | ||
| 203 | async def list( | ||
| 204 | self, | ||
| 205 | ctx: discord.ApplicationContext, | ||
| 206 | name = discord.Option( | ||
| 207 | str, | ||
| 208 | _TRIGGER_NAME_OPTION, | ||
| 209 | autocomplete=discord.utils.basic_autocomplete(get_type), | ||
| 210 | required=True | ||
| 211 | ) | ||
| 212 | ): | ||
| 213 | with get_connection() as conn: | ||
| 214 | c = conn.cursor() | ||
| 215 | if name: | ||
| 216 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | ||
| 217 | row = c.fetchone() | ||
| 218 | if not row: | ||
| 219 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 220 | logger.warning(f"Trigger not found: {name}") | ||
| 221 | return | ||
| 222 | trigger_id = row[0] | ||
| 223 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 224 | triggers = [row[0] for row in c.fetchall()] | ||
| 225 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 226 | responses = [row[0] for row in c.fetchall()] | ||
| 227 | trigger_list = "\n".join(triggers) | ||
| 228 | response_list = "\n".join(responses) | ||
| 229 | message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}" | ||
| 230 | await ctx.respond(message) | ||
| 231 | logger.info(f"Trigger listed: {name}") | ||
| 232 | else: | ||
| 233 | c.execute("SELECT id, name FROM triggers") | ||
| 234 | messages = [] | ||
| 235 | for trigger_id, name in c.fetchall(): | ||
| 236 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 237 | triggers = ", ".join([row[0] for row in c.fetchall()]) | ||
| 238 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 239 | responses = ", ".join([row[0] for row in c.fetchall()]) | ||
| 240 | messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") | ||
| 241 | await ctx.respond("\n\n".join(messages)) | ||
| 242 | logger.info("Triggers listed") | ||
| 243 | |||
| 244 | @trigger_group.command( | ||
| 245 | description="how to use trigger group?" | ||
| 246 | ) | ||
| 247 | async def help( | ||
| 248 | self, | ||
| 249 | ctx: discord.ApplicationContext, | ||
| 250 | ): | ||
| 251 | await ctx.respond("Trigger create - creates a group for triggers/responses\n" | ||
| 252 | "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n" | ||
| 253 | "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n" | ||
| 254 | "Trigger delete - deletes the whole group\n" | ||
| 255 | "Trigger list - lists triggers/responses of a group\n" | ||
| 256 | "Trigger help - display this message\n") | ||
| 257 | |||
| 258 | |||
| 259 | def setup(bot): | ||
| 260 | bot.add_cog(Trigger(bot)) | ||
diff --git a/cogs/tts.py b/cogs/tts.py deleted file mode 100644 index ca81d3a..0000000 --- a/cogs/tts.py +++ /dev/null | |||
| @@ -1,111 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import json | ||
| 8 | import discord | ||
| 9 | import logging | ||
| 10 | import tempfile | ||
| 11 | from discord.ext import commands | ||
| 12 | from gtts import gTTS | ||
| 13 | import asyncio | ||
| 14 | from cogs.utils.settings import get_volume | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | class TTS(commands.Cog): | ||
| 19 | def __init__(self, bot): | ||
| 20 | self.bot = bot | ||
| 21 | |||
| 22 | async def _handle_file_output(self, ctx, temp_path, text): | ||
| 23 | """Handle TTS file output""" | ||
| 24 | try: | ||
| 25 | await ctx.respond(file=discord.File(temp_path, filename='tts.mp3')) | ||
| 26 | logger.info(f"Sent TTS for text: {text}") | ||
| 27 | except Exception as e: | ||
| 28 | logger.error(f"Error sending file: {e}") | ||
| 29 | await ctx.respond("eror :c", ephemeral=True) | ||
| 30 | |||
| 31 | def _get_voice_manager(self): | ||
| 32 | from cogs.voice import get_voice_manager | ||
| 33 | return get_voice_manager(self.bot) | ||
| 34 | |||
| 35 | async def _get_or_connect_voice_client(self, channel): | ||
| 36 | """Get existing voice client or connect to new channel""" | ||
| 37 | voice_mgr = self._get_voice_manager() | ||
| 38 | return await voice_mgr.join_channel(channel) | ||
| 39 | |||
| 40 | async def _handle_voice_output(self, ctx, temp_path, text, volume): | ||
| 41 | """Handle TTS voice channel output""" | ||
| 42 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 43 | await ctx.interaction.delete_original_response() | ||
| 44 | |||
| 45 | if not (ctx.author.voice and ctx.author.voice.channel): | ||
| 46 | logger.info("User is not in a voice channel") | ||
| 47 | return | ||
| 48 | |||
| 49 | channel = ctx.author.voice.channel | ||
| 50 | try: | ||
| 51 | voice_mgr = self._get_voice_manager() | ||
| 52 | |||
| 53 | if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild): | ||
| 54 | vc = await voice_mgr.get_client(channel.guild) | ||
| 55 | if vc and vc.is_playing(): | ||
| 56 | logger.info("Audio already playing, skipping TTS") | ||
| 57 | return | ||
| 58 | |||
| 59 | audio_source = discord.FFmpegPCMAudio( | ||
| 60 | temp_path, | ||
| 61 | options=f'-filter:a "volume={volume}"' | ||
| 62 | ) | ||
| 63 | logger.info(f"Playing TTS: {text}") | ||
| 64 | await voice_mgr.play_source(channel, audio_source) | ||
| 65 | |||
| 66 | except Exception as e: | ||
| 67 | logger.error(f"Error in voice channel handling: {e}") | ||
| 68 | await ctx.respond("eror :c", ephemeral=True) | ||
| 69 | |||
| 70 | async def _cleanup_temp_file(self, temp_path): | ||
| 71 | """Clean up temporary file""" | ||
| 72 | try: | ||
| 73 | if temp_path and os.path.exists(temp_path): | ||
| 74 | os.unlink(temp_path) | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"Error cleaning up temp file {temp_path}: {e}") | ||
| 77 | |||
| 78 | @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu") | ||
| 79 | async def tts(self, ctx, | ||
| 80 | text: str = discord.Option(str, "Tekst do przeczytania", required=True), | ||
| 81 | output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") | ||
| 82 | ): | ||
| 83 | await ctx.defer() | ||
| 84 | |||
| 85 | # Create temporary file in temp folder | ||
| 86 | from cogs.utils.temp import get_temp_dir | ||
| 87 | temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 88 | |||
| 89 | try: | ||
| 90 | # Generate and save TTS to temp file | ||
| 91 | tts = gTTS(text=text, lang='pl') | ||
| 92 | loop = asyncio.get_event_loop() | ||
| 93 | await loop.run_in_executor(None, tts.save, temp_path) | ||
| 94 | |||
| 95 | # Load settings | ||
| 96 | volume = get_volume() | ||
| 97 | |||
| 98 | # Handle output based on user choice | ||
| 99 | if output == "file": | ||
| 100 | await self._handle_file_output(ctx, temp_path, text) | ||
| 101 | elif output == "vc": | ||
| 102 | await self._handle_voice_output(ctx, temp_path, text, volume) | ||
| 103 | |||
| 104 | except Exception as e: | ||
| 105 | logger.error(f"TTS generation error: {e}") | ||
| 106 | await ctx.respond("Error generating TTS", ephemeral=True) | ||
| 107 | finally: | ||
| 108 | await self._cleanup_temp_file(temp_path) | ||
| 109 | |||
| 110 | def setup(bot): | ||
| 111 | bot.add_cog(TTS(bot)) | ||
diff --git a/cogs/uploader.py b/cogs/uploader.py deleted file mode 100644 index a5d0e05..0000000 --- a/cogs/uploader.py +++ /dev/null | |||
| @@ -1,156 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import aiofiles | ||
| 8 | import requests | ||
| 9 | import urllib.parse | ||
| 10 | from typing import Optional | ||
| 11 | import discord | ||
| 12 | from discord.ext import commands | ||
| 13 | from discord import AutocompleteContext | ||
| 14 | from cogs.utils import file_search as search_utils | ||
| 15 | from cogs.utils.settings import get_uploadurl | ||
| 16 | import logging | ||
| 17 | |||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 20 | |||
| 21 | class Uploader(commands.Cog): | ||
| 22 | def __init__(self, bot): | ||
| 23 | from cogs.utils.temp import get_temp_dir | ||
| 24 | self.bot = bot | ||
| 25 | self.temp_dir = get_temp_dir() | ||
| 26 | self._cached_urls = {} | ||
| 27 | self._uploadurl = get_uploadurl() | ||
| 28 | |||
| 29 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 30 | query = ctx.value.lower() if ctx.value else "" | ||
| 31 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 32 | |||
| 33 | user_id = ctx.interaction.user.id | ||
| 34 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 35 | |||
| 36 | choices = [] | ||
| 37 | for url in results[:25]: | ||
| 38 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 39 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 40 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 41 | return choices | ||
| 42 | |||
| 43 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 44 | """Download file from copyparty URL and return local path""" | ||
| 45 | try: | ||
| 46 | response = requests.get(url) | ||
| 47 | if response.status_code != 200: | ||
| 48 | return None | ||
| 49 | |||
| 50 | filename = url.split('/')[-1].split('?')[0] | ||
| 51 | filename = urllib.parse.unquote(filename) | ||
| 52 | file_path = os.path.join(self.temp_dir, filename) | ||
| 53 | |||
| 54 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 55 | await f.write(response.content) | ||
| 56 | return file_path | ||
| 57 | except Exception as e: | ||
| 58 | logger.error(f"Error downloading from {url}: {e}") | ||
| 59 | return None | ||
| 60 | |||
| 61 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 62 | """Upload file to copyparty and return download URL""" | ||
| 63 | try: | ||
| 64 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 65 | file_content = await f.read() | ||
| 66 | |||
| 67 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 68 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 69 | params = {'j': '1', 'ck': 'b2'} | ||
| 70 | |||
| 71 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 72 | |||
| 73 | if response.status_code in [200, 201]: | ||
| 74 | result = response.json() | ||
| 75 | return result.get('fileurl') | ||
| 76 | except Exception as e: | ||
| 77 | logger.error(f"Upload error: {e}") | ||
| 78 | return None | ||
| 79 | |||
| 80 | async def _upload_file(self, file_path: str) -> Optional[str]: | ||
| 81 | """Upload a file and return the URL""" | ||
| 82 | if self._uploadurl == "http://localhost:3939": | ||
| 83 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 84 | return None | ||
| 85 | |||
| 86 | return await self._upload_to_copyparty(file_path) | ||
| 87 | |||
| 88 | @commands.message_command( | ||
| 89 | name="upload", | ||
| 90 | description="wyślij załączniki na copyparty", | ||
| 91 | ) | ||
| 92 | async def message_upload(self, ctx, message: discord.Message): | ||
| 93 | if not message.attachments: | ||
| 94 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 95 | return | ||
| 96 | |||
| 97 | await ctx.defer() | ||
| 98 | |||
| 99 | uploaded_urls = [] | ||
| 100 | failed_count = 0 | ||
| 101 | |||
| 102 | for attachment in message.attachments: | ||
| 103 | file_path = os.path.join(self.temp_dir, attachment.filename) | ||
| 104 | await attachment.save(file_path) | ||
| 105 | |||
| 106 | url = await self._upload_file(file_path) | ||
| 107 | if url: | ||
| 108 | uploaded_urls.append(url) | ||
| 109 | else: | ||
| 110 | failed_count += 1 | ||
| 111 | |||
| 112 | if os.path.exists(file_path): | ||
| 113 | os.remove(file_path) | ||
| 114 | |||
| 115 | if uploaded_urls: | ||
| 116 | response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) | ||
| 117 | if failed_count > 0: | ||
| 118 | response += f"\n\nNie wysłano {failed_count} plików" | ||
| 119 | await ctx.respond(response) | ||
| 120 | else: | ||
| 121 | await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 122 | |||
| 123 | @commands.slash_command( | ||
| 124 | name="upload", | ||
| 125 | description="wyślij plik na copyparty", | ||
| 126 | ) | ||
| 127 | @discord.option( | ||
| 128 | name="zalacznik", | ||
| 129 | description="plik z discorda", | ||
| 130 | required=True, | ||
| 131 | type=discord.Attachment | ||
| 132 | ) | ||
| 133 | async def upload(self, ctx, zalacznik: discord.Attachment = None): | ||
| 134 | await ctx.defer() | ||
| 135 | |||
| 136 | file_path = None | ||
| 137 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 138 | await zalacznik.save(file_path) | ||
| 139 | |||
| 140 | if not file_path or not os.path.exists(file_path): | ||
| 141 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 142 | return | ||
| 143 | |||
| 144 | try: | ||
| 145 | dl_url = await self._upload_file(file_path) | ||
| 146 | if dl_url: | ||
| 147 | await ctx.respond(dl_url) | ||
| 148 | else: | ||
| 149 | await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) | ||
| 150 | finally: | ||
| 151 | if file_path and os.path.exists(file_path): | ||
| 152 | os.remove(file_path) | ||
| 153 | |||
| 154 | |||
| 155 | def setup(bot): | ||
| 156 | bot.add_cog(Uploader(bot)) | ||
diff --git a/cogs/utils/database.py b/cogs/utils/database.py deleted file mode 100644 index ea7d356..0000000 --- a/cogs/utils/database.py +++ /dev/null | |||
| @@ -1,126 +0,0 @@ | |||
| 1 | import os | ||
| 2 | import sqlite3 | ||
| 3 | import logging | ||
| 4 | from contextlib import contextmanager | ||
| 5 | |||
| 6 | logger = logging.getLogger(__name__) | ||
| 7 | |||
| 8 | DB_PATH = "data/senator.db" | ||
| 9 | |||
| 10 | _initialized = False | ||
| 11 | |||
| 12 | |||
| 13 | def get_connection(): | ||
| 14 | conn = sqlite3.connect(get_db_path()) | ||
| 15 | return _ConnectionContextManager(conn) | ||
| 16 | |||
| 17 | |||
| 18 | def get_db_path(): | ||
| 19 | return os.getenv("SENATOR_DB_PATH", DB_PATH) | ||
| 20 | |||
| 21 | |||
| 22 | def set_db_path(path): | ||
| 23 | global DB_PATH | ||
| 24 | DB_PATH = path | ||
| 25 | |||
| 26 | |||
| 27 | class _ConnectionContextManager: | ||
| 28 | """Context manager for sqlite3 connections that auto-closes.""" | ||
| 29 | def __init__(self, conn): | ||
| 30 | self.conn = conn | ||
| 31 | |||
| 32 | def __enter__(self): | ||
| 33 | return self.conn | ||
| 34 | |||
| 35 | def __exit__(self, exc_type, exc_val, exc_tb): | ||
| 36 | self.conn.close() | ||
| 37 | return False | ||
| 38 | |||
| 39 | |||
| 40 | def init_database(): | ||
| 41 | global _initialized | ||
| 42 | if _initialized: | ||
| 43 | return | ||
| 44 | |||
| 45 | _initialized = True | ||
| 46 | |||
| 47 | db_path = get_db_path() | ||
| 48 | db_dir = os.path.dirname(db_path) | ||
| 49 | if db_dir and not os.path.exists(db_dir): | ||
| 50 | os.makedirs(db_dir) | ||
| 51 | |||
| 52 | conn = sqlite3.connect(db_path) | ||
| 53 | try: | ||
| 54 | c = conn.cursor() | ||
| 55 | |||
| 56 | c.execute("PRAGMA foreign_keys = ON") | ||
| 57 | |||
| 58 | c.execute(""" | ||
| 59 | CREATE TABLE IF NOT EXISTS settings ( | ||
| 60 | key TEXT PRIMARY KEY, | ||
| 61 | value TEXT | ||
| 62 | )""") | ||
| 63 | |||
| 64 | c.execute(""" | ||
| 65 | CREATE TABLE IF NOT EXISTS cron_jobs ( | ||
| 66 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 67 | cron_expression TEXT NOT NULL, | ||
| 68 | type TEXT NOT NULL, | ||
| 69 | channel_id INTEGER NOT NULL, | ||
| 70 | active INTEGER DEFAULT 1, | ||
| 71 | audio_filename TEXT, | ||
| 72 | message TEXT, | ||
| 73 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 74 | )""") | ||
| 75 | |||
| 76 | c.execute(""" | ||
| 77 | CREATE TABLE IF NOT EXISTS voice_activity ( | ||
| 78 | user_id TEXT PRIMARY KEY, | ||
| 79 | join_time REAL, | ||
| 80 | total_active REAL DEFAULT 0, | ||
| 81 | total_afk REAL DEFAULT 0, | ||
| 82 | is_afk INTEGER DEFAULT 0, | ||
| 83 | afk_start REAL, | ||
| 84 | active_start REAL, | ||
| 85 | channel_name TEXT, | ||
| 86 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 87 | )""") | ||
| 88 | |||
| 89 | c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") | ||
| 90 | c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") | ||
| 91 | |||
| 92 | c.execute(""" | ||
| 93 | CREATE TABLE IF NOT EXISTS triggers ( | ||
| 94 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 95 | name TEXT NOT NULL UNIQUE, | ||
| 96 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 97 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 98 | )""") | ||
| 99 | |||
| 100 | c.execute(""" | ||
| 101 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 102 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 103 | trigger_id INTEGER NOT NULL, | ||
| 104 | keyword TEXT NOT NULL, | ||
| 105 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 106 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 107 | UNIQUE(trigger_id, keyword) | ||
| 108 | )""") | ||
| 109 | |||
| 110 | c.execute(""" | ||
| 111 | CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 112 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 113 | trigger_id INTEGER NOT NULL, | ||
| 114 | response_text TEXT NOT NULL, | ||
| 115 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 116 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | ||
| 117 | )""") | ||
| 118 | |||
| 119 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") | ||
| 120 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") | ||
| 121 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") | ||
| 122 | |||
| 123 | conn.commit() | ||
| 124 | logger.info("Database initialized successfully") | ||
| 125 | finally: | ||
| 126 | conn.close() | ||
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py deleted file mode 100644 index 788f50c..0000000 --- a/cogs/utils/file_search.py +++ /dev/null | |||
| @@ -1,167 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import json | ||
| 7 | import logging | ||
| 8 | import aiohttp | ||
| 9 | import os | ||
| 10 | from typing import List | ||
| 11 | from cogs.utils.settings import get_baseurl | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | |||
| 16 | class UniversalSearcher: | ||
| 17 | """Handles searching for files via copyparty API.""" | ||
| 18 | |||
| 19 | @staticmethod | ||
| 20 | async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None): | ||
| 21 | """Search for files using copyparty API (jPOST method). | ||
| 22 | |||
| 23 | Args: | ||
| 24 | query: Search query string | ||
| 25 | max_results: Maximum number of results to return | ||
| 26 | file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav']) | ||
| 27 | |||
| 28 | Returns: | ||
| 29 | List of matching file URLs | ||
| 30 | """ | ||
| 31 | if not query: | ||
| 32 | return [] | ||
| 33 | |||
| 34 | query = query.strip() | ||
| 35 | logger.info(f"Starting copyparty search for query: '{query}'") | ||
| 36 | |||
| 37 | tokens = query.split() | ||
| 38 | if not tokens: | ||
| 39 | return [] | ||
| 40 | |||
| 41 | token_queries = [] | ||
| 42 | for token in tokens: | ||
| 43 | path_query = f'path like "*{token}*"' | ||
| 44 | name_query = f'name like "*{token}*"' | ||
| 45 | token_queries.append(f"({path_query} or {name_query})") | ||
| 46 | |||
| 47 | if len(token_queries) == 1: | ||
| 48 | name_query = token_queries[0] | ||
| 49 | else: | ||
| 50 | name_query = " and ".join(token_queries) | ||
| 51 | |||
| 52 | if file_extensions: | ||
| 53 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] | ||
| 54 | ext_query = " or ".join(ext_filters) | ||
| 55 | query = f"{name_query} and ({ext_query})" | ||
| 56 | else: | ||
| 57 | query = name_query | ||
| 58 | |||
| 59 | try: | ||
| 60 | baseurl = get_baseurl() | ||
| 61 | password = os.getenv("COPYPARTY_PASSWORD") | ||
| 62 | |||
| 63 | async with aiohttp.ClientSession() as session: | ||
| 64 | headers = {"Content-Type": "application/json"} | ||
| 65 | if password: | ||
| 66 | headers["PW"] = password | ||
| 67 | async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response: | ||
| 68 | if response.status == 200: | ||
| 69 | data = await response.json() | ||
| 70 | files = UniversalSearcher._extract_files_from_response(data) | ||
| 71 | results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) | ||
| 72 | |||
| 73 | logger.info(f"Copyparty search complete. Found {len(results)} matches.") | ||
| 74 | return results | ||
| 75 | else: | ||
| 76 | logger.error(f"Copyparty API returned status {response.status}") | ||
| 77 | return [] | ||
| 78 | |||
| 79 | except aiohttp.ClientError as e: | ||
| 80 | logger.error(f"HTTP error during copyparty search: {e}") | ||
| 81 | return [] | ||
| 82 | except Exception as e: | ||
| 83 | logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) | ||
| 84 | return [] | ||
| 85 | |||
| 86 | @staticmethod | ||
| 87 | def _extract_files_from_response(data): | ||
| 88 | """Extract files list from copyparty API response.""" | ||
| 89 | if isinstance(data, dict): | ||
| 90 | if 'hits' in data: | ||
| 91 | return data['hits'] | ||
| 92 | elif 'files' in data: | ||
| 93 | return data['files'] | ||
| 94 | elif 'results' in data: | ||
| 95 | return data['results'] | ||
| 96 | else: | ||
| 97 | return data if isinstance(data, list) else [] | ||
| 98 | elif isinstance(data, list): | ||
| 99 | return data | ||
| 100 | else: | ||
| 101 | return [] | ||
| 102 | |||
| 103 | @staticmethod | ||
| 104 | def _convert_files_to_urls(files, baseurl, max_results): | ||
| 105 | """Convert file info objects to URLs.""" | ||
| 106 | results = [] | ||
| 107 | |||
| 108 | for file_info in files[:max_results]: | ||
| 109 | if isinstance(file_info, dict): | ||
| 110 | file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) | ||
| 111 | if file_url: | ||
| 112 | results.append(file_url) | ||
| 113 | elif isinstance(file_info, str): | ||
| 114 | if file_info.startswith('http'): | ||
| 115 | results.append(file_info) | ||
| 116 | else: | ||
| 117 | results.append(f"{baseurl}/{file_info.lstrip('/')}") | ||
| 118 | |||
| 119 | return results | ||
| 120 | |||
| 121 | @staticmethod | ||
| 122 | def _extract_url_from_dict(file_info, baseurl): | ||
| 123 | """Extract URL from file info dictionary.""" | ||
| 124 | if 'url' in file_info: | ||
| 125 | return file_info['url'] | ||
| 126 | elif 'rp' in file_info: | ||
| 127 | return f"{baseurl}/{file_info['rp']}" | ||
| 128 | elif 'path' in file_info: | ||
| 129 | return f"{baseurl}/{file_info['path'].lstrip('/')}" | ||
| 130 | elif 'name' in file_info: | ||
| 131 | return f"{baseurl}/{file_info['name'].lstrip('/')}" | ||
| 132 | else: | ||
| 133 | return None | ||
| 134 | |||
| 135 | @staticmethod | ||
| 136 | async def search_copyparty_autocomplete(query: str, max_results: int = 10): | ||
| 137 | """Wrapper for autocomplete searches using copyparty (general search - no file type filtering). | ||
| 138 | |||
| 139 | Args: | ||
| 140 | query: Search query string | ||
| 141 | max_results: Maximum number of results to return | ||
| 142 | |||
| 143 | Returns: | ||
| 144 | List of matching file URLs | ||
| 145 | """ | ||
| 146 | if not query: | ||
| 147 | return [] | ||
| 148 | |||
| 149 | return await UniversalSearcher.search_copyparty(query, max_results) | ||
| 150 | |||
| 151 | @staticmethod | ||
| 152 | async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10): | ||
| 153 | """Wrapper for autocomplete searches using copyparty (audio files only). | ||
| 154 | |||
| 155 | Args: | ||
| 156 | query: Search query string | ||
| 157 | max_results: Maximum number of results to return | ||
| 158 | |||
| 159 | Returns: | ||
| 160 | List of matching file URLs (audio files only) | ||
| 161 | """ | ||
| 162 | if not query: | ||
| 163 | return [] | ||
| 164 | |||
| 165 | # Audio file extensions for music commands | ||
| 166 | audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"] | ||
| 167 | return await UniversalSearcher.search_copyparty(query, max_results, audio_extensions) | ||
diff --git a/cogs/utils/llm.py b/cogs/utils/llm.py deleted file mode 100644 index 2b05b12..0000000 --- a/cogs/utils/llm.py +++ /dev/null | |||
| @@ -1,62 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import httpx | ||
| 8 | import logging | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | ||
| 11 | |||
| 12 | BACKWARD_COMPAT_MSG = ( | ||
| 13 | "OPENROUTER_API_KEY/OPENROUTER_MODEL are deprecated. " | ||
| 14 | "Use LLM_API_KEY, LLM_MODEL instead." | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def _get_config(): | ||
| 19 | api_url = os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions") | ||
| 20 | api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") or "" | ||
| 21 | model = os.getenv("LLM_MODEL") or os.getenv("OPENROUTER_MODEL") or "" | ||
| 22 | |||
| 23 | if os.getenv("OPENROUTER_API_KEY") and not os.getenv("LLM_API_KEY"): | ||
| 24 | logger.warning(BACKWARD_COMPAT_MSG) | ||
| 25 | if os.getenv("OPENROUTER_MODEL") and not os.getenv("LLM_MODEL"): | ||
| 26 | logger.warning(BACKWARD_COMPAT_MSG) | ||
| 27 | |||
| 28 | return api_url, api_key, model | ||
| 29 | |||
| 30 | |||
| 31 | async def llm_completion( | ||
| 32 | messages: list[dict], | ||
| 33 | model: str | None = None, | ||
| 34 | api_url: str | None = None, | ||
| 35 | api_key: str | None = None, | ||
| 36 | extra: dict | None = None, | ||
| 37 | timeout: float = 180.0, | ||
| 38 | ) -> str | None: | ||
| 39 | url = api_url or os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions") | ||
| 40 | model = model or os.getenv("LLM_MODEL") or os.getenv("OPENROUTER_MODEL") or "" | ||
| 41 | api_key = api_key or os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") or "" | ||
| 42 | |||
| 43 | headers = {"Content-Type": "application/json"} | ||
| 44 | if api_key: | ||
| 45 | headers["Authorization"] = f"Bearer {api_key}" | ||
| 46 | |||
| 47 | payload: dict = {"model": model, "messages": messages} | ||
| 48 | if extra: | ||
| 49 | payload.update(extra) | ||
| 50 | |||
| 51 | verify = os.getenv("LLM_API_VERIFY", "true").lower() in ("true", "1", "yes") | ||
| 52 | |||
| 53 | try: | ||
| 54 | async with httpx.AsyncClient(timeout=timeout, verify=verify) as client: | ||
| 55 | resp = await client.post(url, headers=headers, json=payload) | ||
| 56 | resp.raise_for_status() | ||
| 57 | data = resp.json() | ||
| 58 | logger.info("LLM response: %s", data) | ||
| 59 | return data["choices"][0]["message"]["content"] | ||
| 60 | except httpx.HTTPError as e: | ||
| 61 | logger.error("LLM API request failed (url=%s, model=%s): %s", url, model, e, exc_info=True) | ||
| 62 | return None | ||
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py deleted file mode 100644 index b1a585c..0000000 --- a/cogs/utils/settings.py +++ /dev/null | |||
| @@ -1,114 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import sqlite3 | ||
| 8 | import os | ||
| 9 | from dataclasses import dataclass, field | ||
| 10 | |||
| 11 | from cogs.utils.database import get_connection, init_database | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | DEFAULT_BASEURL = "http://localhost:3939" | ||
| 16 | |||
| 17 | init_database() | ||
| 18 | |||
| 19 | |||
| 20 | @dataclass | ||
| 21 | class Settings: | ||
| 22 | volume: float = 1.0 | ||
| 23 | baseurl: str = DEFAULT_BASEURL | ||
| 24 | _data: dict = field(default_factory=dict, init=False) | ||
| 25 | |||
| 26 | async def load(self) -> "Settings": | ||
| 27 | default_settings = { | ||
| 28 | "volume": 1.0, | ||
| 29 | "baseurl": DEFAULT_BASEURL, | ||
| 30 | } | ||
| 31 | |||
| 32 | try: | ||
| 33 | with get_connection() as conn: | ||
| 34 | c = conn.cursor() | ||
| 35 | c.execute("SELECT key, value FROM settings") | ||
| 36 | rows = c.fetchall() | ||
| 37 | |||
| 38 | if rows: | ||
| 39 | self._data = {row[0]: row[1] for row in rows} | ||
| 40 | self._sync_from_data() | ||
| 41 | logger.info("Settings loaded from database") | ||
| 42 | else: | ||
| 43 | self._data = default_settings.copy() | ||
| 44 | self._sync_from_data() | ||
| 45 | await self.save() | ||
| 46 | logger.info("Created default settings in database") | ||
| 47 | except Exception as e: | ||
| 48 | logger.error(f"Error loading settings: {e}") | ||
| 49 | self._data = default_settings.copy() | ||
| 50 | self._sync_from_data() | ||
| 51 | |||
| 52 | return self | ||
| 53 | |||
| 54 | def _sync_from_data(self): | ||
| 55 | try: | ||
| 56 | self.volume = float(self._data.get("volume", 1.0)) | ||
| 57 | except (ValueError, TypeError): | ||
| 58 | self.volume = 1.0 | ||
| 59 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) | ||
| 60 | |||
| 61 | def _sync_to_data(self): | ||
| 62 | self._data["volume"] = str(self.volume) | ||
| 63 | self._data["baseurl"] = self.baseurl | ||
| 64 | |||
| 65 | async def save(self): | ||
| 66 | self._sync_to_data() | ||
| 67 | with get_connection() as conn: | ||
| 68 | c = conn.cursor() | ||
| 69 | c.execute("PRAGMA foreign_keys = ON") | ||
| 70 | for key, value in self._data.items(): | ||
| 71 | c.execute( | ||
| 72 | "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", | ||
| 73 | (key, value), | ||
| 74 | ) | ||
| 75 | conn.commit() | ||
| 76 | |||
| 77 | |||
| 78 | _settings_instance: Settings | None = None | ||
| 79 | |||
| 80 | |||
| 81 | async def load_settings() -> Settings: | ||
| 82 | global _settings_instance | ||
| 83 | _settings_instance = Settings() | ||
| 84 | await _settings_instance.load() | ||
| 85 | return _settings_instance | ||
| 86 | |||
| 87 | |||
| 88 | async def save_settings(): | ||
| 89 | if _settings_instance: | ||
| 90 | await _settings_instance.save() | ||
| 91 | |||
| 92 | |||
| 93 | def get_settings() -> Settings | None: | ||
| 94 | return _settings_instance | ||
| 95 | |||
| 96 | |||
| 97 | def get_volume() -> float: | ||
| 98 | return _settings_instance.volume if _settings_instance else 1.0 | ||
| 99 | |||
| 100 | |||
| 101 | def get_baseurl() -> str: | ||
| 102 | url = os.getenv("COPYPARTY_URL") | ||
| 103 | if not url: | ||
| 104 | logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") | ||
| 105 | return DEFAULT_BASEURL | ||
| 106 | return url.rstrip("/") | ||
| 107 | |||
| 108 | |||
| 109 | def get_uploadurl() -> str: | ||
| 110 | url = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 111 | if not url: | ||
| 112 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") | ||
| 113 | return DEFAULT_BASEURL | ||
| 114 | return url.rstrip("/") | ||
diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py deleted file mode 100644 index 0b2c8d7..0000000 --- a/cogs/utils/temp.py +++ /dev/null | |||
| @@ -1,18 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import tempfile | ||
| 8 | |||
| 9 | def get_temp_dir(subdir: str = "senator_sperma") -> str: | ||
| 10 | """Get or create a temp subdirectory. | ||
| 11 | |||
| 12 | Uses system temp directory (/tmp on Linux, %TEMP% on Windows). | ||
| 13 | OS handles cleanup automatically. | ||
| 14 | """ | ||
| 15 | base_temp = tempfile.gettempdir() | ||
| 16 | temp_path = os.path.join(base_temp, subdir) | ||
| 17 | os.makedirs(temp_path, exist_ok=True) | ||
| 18 | return temp_path | ||
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py deleted file mode 100644 index eac06f1..0000000 --- a/cogs/utils/url_helper.py +++ /dev/null | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | |||
| 7 | def clean_url_for_ytdlp(url: str) -> str: | ||
| 8 | """Clean URL by removing everything after the first '&'. | ||
| 9 | |||
| 10 | This prevents yt-dlp from interpreting additional query parameters | ||
| 11 | (like &list=...) as playlist URLs. | ||
| 12 | |||
| 13 | Example: | ||
| 14 | Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& | ||
| 15 | Output: https://www.youtube.com/watch?v=lPGipwoJiOM | ||
| 16 | """ | ||
| 17 | # Find the first '&' and truncate | ||
| 18 | first_ampersand = url.find('&') | ||
| 19 | if first_ampersand != -1: | ||
| 20 | return url[:first_ampersand] | ||
| 21 | return url | ||
diff --git a/cogs/vclogs.py b/cogs/vclogs.py deleted file mode 100644 index 35826d4..0000000 --- a/cogs/vclogs.py +++ /dev/null | |||
| @@ -1,316 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import datetime | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import asyncio | ||
| 10 | import discord | ||
| 11 | from discord.ext import commands | ||
| 12 | from gtts import gTTS | ||
| 13 | from cogs.voice import get_voice_manager | ||
| 14 | from cogs.utils.settings import get_volume, save_settings | ||
| 15 | from cogs.utils.database import get_connection, init_database | ||
| 16 | |||
| 17 | import tempfile | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 20 | |||
| 21 | init_database() | ||
| 22 | |||
| 23 | |||
| 24 | def load_activity_data(): | ||
| 25 | try: | ||
| 26 | with get_connection() as conn: | ||
| 27 | c = conn.cursor() | ||
| 28 | c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") | ||
| 29 | rows = c.fetchall() | ||
| 30 | |||
| 31 | return { | ||
| 32 | row[0]: { | ||
| 33 | "join_time": row[1], | ||
| 34 | "total_active": row[2], | ||
| 35 | "total_afk": row[3], | ||
| 36 | "is_afk": bool(row[4]), | ||
| 37 | "afk_start": row[5], | ||
| 38 | "active_start": row[6], | ||
| 39 | "channel_name": row[7], | ||
| 40 | } | ||
| 41 | for row in rows | ||
| 42 | } | ||
| 43 | except Exception as e: | ||
| 44 | logger.error(f"Error loading voice activity: {e}") | ||
| 45 | return {} | ||
| 46 | |||
| 47 | |||
| 48 | def save_activity_data(data): | ||
| 49 | try: | ||
| 50 | with get_connection() as conn: | ||
| 51 | c = conn.cursor() | ||
| 52 | c.execute("PRAGMA foreign_keys = ON") | ||
| 53 | |||
| 54 | c.execute("DELETE FROM voice_activity") | ||
| 55 | |||
| 56 | for user_id, user_data in data.items(): | ||
| 57 | c.execute( | ||
| 58 | """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) | ||
| 59 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", | ||
| 60 | ( | ||
| 61 | user_id, | ||
| 62 | user_data.get("join_time"), | ||
| 63 | user_data.get("total_active", 0), | ||
| 64 | user_data.get("total_afk", 0), | ||
| 65 | int(user_data.get("is_afk", False)), | ||
| 66 | user_data.get("afk_start"), | ||
| 67 | user_data.get("active_start"), | ||
| 68 | user_data.get("channel_name"), | ||
| 69 | ), | ||
| 70 | ) | ||
| 71 | conn.commit() | ||
| 72 | logger.info(f"Saved voice activity for {len(data)} users") | ||
| 73 | except Exception as e: | ||
| 74 | logger.error(f"Error saving voice activity: {e}") | ||
| 75 | |||
| 76 | |||
| 77 | def format_duration(seconds): | ||
| 78 | hours, remainder = divmod(int(seconds), 3600) | ||
| 79 | minutes, seconds = divmod(remainder, 60) | ||
| 80 | if hours > 0: | ||
| 81 | return f"{hours}h {minutes}m {seconds}s" | ||
| 82 | elif minutes > 0: | ||
| 83 | return f"{minutes}m {seconds}s" | ||
| 84 | return f"{seconds}s" | ||
| 85 | |||
| 86 | |||
| 87 | class VcLogs(commands.Cog): | ||
| 88 | def __init__(self, bot): | ||
| 89 | self.bot = bot | ||
| 90 | self._voice_manager = None | ||
| 91 | |||
| 92 | def get_voice_manager(self): | ||
| 93 | if self._voice_manager is None: | ||
| 94 | self._voice_manager = get_voice_manager(self.bot) | ||
| 95 | return self._voice_manager | ||
| 96 | |||
| 97 | @staticmethod | ||
| 98 | async def handle_state_change(member, channel, state_type, is_enabled, log_channel): | ||
| 99 | """Helper method to handle voice state changes""" | ||
| 100 | state_messages = { | ||
| 101 | 'deaf': { | ||
| 102 | True: f"{member.display_name} jest bity przez ojca", | ||
| 103 | False: f"{member.display_name} uciek od ojca" | ||
| 104 | }, | ||
| 105 | 'mute': { | ||
| 106 | True: f"{member.display_name} idzie robić jajecznice", | ||
| 107 | False: f"{member.display_name} zrobił jajecznice" | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | tts_text = state_messages[state_type][is_enabled] | ||
| 112 | return tts_text, channel | ||
| 113 | |||
| 114 | async def play_tts(self, channel, text): | ||
| 115 | from cogs.utils.temp import get_temp_dir | ||
| 116 | |||
| 117 | voice_mgr = self.get_voice_manager() | ||
| 118 | temp_dir = get_temp_dir() | ||
| 119 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 120 | tts = gTTS(text=text, lang='pl') | ||
| 121 | logger.info("saving file") | ||
| 122 | |||
| 123 | loop = asyncio.get_event_loop() | ||
| 124 | await loop.run_in_executor(None, tts.save, tts_file) | ||
| 125 | |||
| 126 | try: | ||
| 127 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 128 | voice_client = voice_status[0] if voice_status else None | ||
| 129 | is_connected = voice_status[1] if voice_status else False | ||
| 130 | is_playing = voice_status[2] if voice_status else False | ||
| 131 | |||
| 132 | if is_connected: | ||
| 133 | from cogs.voice import is_voice_client_alive | ||
| 134 | if not is_voice_client_alive(voice_client, channel.guild): | ||
| 135 | logger.warning("Stale voice client in play_tts, reconnecting") | ||
| 136 | try: | ||
| 137 | await voice_client.disconnect() | ||
| 138 | except Exception: | ||
| 139 | pass | ||
| 140 | is_connected = False | ||
| 141 | |||
| 142 | if not is_connected: | ||
| 143 | voice_client = await voice_mgr.join_channel(channel) | ||
| 144 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 145 | voice_client = voice_status[0] if voice_status else None | ||
| 146 | is_connected = voice_status[1] if voice_status else False | ||
| 147 | is_playing = voice_status[2] if voice_status else False | ||
| 148 | |||
| 149 | if not is_connected or voice_client is None: | ||
| 150 | logger.error("Failed to join voice channel") | ||
| 151 | return | ||
| 152 | |||
| 153 | volume = get_volume() | ||
| 154 | logger.info(f"connected: {voice_client}") | ||
| 155 | |||
| 156 | if voice_client: | ||
| 157 | if is_playing: | ||
| 158 | logger.info("voice already playing, skipping TTS") | ||
| 159 | return | ||
| 160 | |||
| 161 | logger.info("preparing to play audio") | ||
| 162 | audio_source = discord.FFmpegPCMAudio(tts_file) | ||
| 163 | await voice_mgr.play_source(channel, audio_source, volume=volume) | ||
| 164 | except Exception as e: | ||
| 165 | logger.error(f"TTS playback error: {e}") | ||
| 166 | finally: | ||
| 167 | if os.path.exists(tts_file): | ||
| 168 | os.remove(tts_file) | ||
| 169 | |||
| 170 | @commands.slash_command( | ||
| 171 | name="log_volume" | ||
| 172 | ) | ||
| 173 | async def volume(self, ctx: discord.ApplicationContext, volume: int): | ||
| 174 | from cogs.utils.settings import get_settings | ||
| 175 | settings = get_settings() | ||
| 176 | settings.volume = volume | ||
| 177 | await save_settings() | ||
| 178 | await ctx.followup.send(f"Volume has been set to: {volume}") | ||
| 179 | logger.info(f"Volume changed to: {volume}") | ||
| 180 | |||
| 181 | @commands.Cog.listener() | ||
| 182 | async def on_voice_state_update(self, member, before, after): | ||
| 183 | log_channel_id = os.getenv("STATUS_CHANNEL_ID") | ||
| 184 | if not log_channel_id: | ||
| 185 | logger.warning("STATUS_CHANNEL_ID not set in environment") | ||
| 186 | return | ||
| 187 | |||
| 188 | log_channel = self.bot.get_channel(int(log_channel_id)) | ||
| 189 | if log_channel is None: | ||
| 190 | logger.warning("Log channel not found or not accessible") | ||
| 191 | return | ||
| 192 | |||
| 193 | current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | ||
| 194 | current_ts = datetime.datetime.now().timestamp() | ||
| 195 | tts_text = None | ||
| 196 | tts_channel = None | ||
| 197 | |||
| 198 | if member.bot: | ||
| 199 | return | ||
| 200 | |||
| 201 | user_id = str(member.id) | ||
| 202 | activity_data = load_activity_data() | ||
| 203 | |||
| 204 | if before.channel is None and after.channel is not None: | ||
| 205 | is_afk = after.self_mute or after.self_deaf | ||
| 206 | activity_data[user_id] = { | ||
| 207 | 'join_time': current_ts, | ||
| 208 | 'total_active': 0, | ||
| 209 | 'total_afk': 0, | ||
| 210 | 'is_afk': is_afk, | ||
| 211 | 'afk_start': current_ts if is_afk else None, | ||
| 212 | 'active_start': current_ts if not is_afk else None, | ||
| 213 | 'channel_name': after.channel.name | ||
| 214 | } | ||
| 215 | save_activity_data(activity_data) | ||
| 216 | |||
| 217 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>" | ||
| 218 | tts_text = f"{member.display_name} sie zespawnił" | ||
| 219 | tts_channel = after.channel | ||
| 220 | await log_channel.send(message) | ||
| 221 | logger.info(f"User joined voice channel: {message}") | ||
| 222 | |||
| 223 | elif before.channel is not None and after.channel is None: | ||
| 224 | if user_id in activity_data: | ||
| 225 | data = activity_data[user_id] | ||
| 226 | |||
| 227 | if data.get('is_afk') and data.get('afk_start'): | ||
| 228 | afk_duration = current_ts - data['afk_start'] | ||
| 229 | data['total_afk'] += afk_duration | ||
| 230 | elif data.get('active_start'): | ||
| 231 | active_since_start = current_ts - data['active_start'] | ||
| 232 | data['total_active'] += active_since_start | ||
| 233 | |||
| 234 | active_str = format_duration(data['total_active']) | ||
| 235 | afk_str = format_duration(data['total_afk']) | ||
| 236 | |||
| 237 | message = ( | ||
| 238 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 239 | f"Aktywny: {active_str} | AFK: {afk_str}" | ||
| 240 | ) | ||
| 241 | tts_text = f"{member.display_name} jest chujem" | ||
| 242 | tts_channel = before.channel | ||
| 243 | await log_channel.send(message) | ||
| 244 | logger.info(f"User left voice channel: {message}") | ||
| 245 | |||
| 246 | del activity_data[user_id] | ||
| 247 | save_activity_data(activity_data) | ||
| 248 | else: | ||
| 249 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>" | ||
| 250 | tts_text = f"{member.display_name} jest chujem" | ||
| 251 | tts_channel = before.channel | ||
| 252 | await log_channel.send(message) | ||
| 253 | logger.info(f"User left voice channel: {message}") | ||
| 254 | |||
| 255 | elif user_id in activity_data: | ||
| 256 | data = activity_data[user_id] | ||
| 257 | |||
| 258 | if data.get('is_afk') and not (after.self_mute or after.self_deaf): | ||
| 259 | afk_duration = current_ts - data['afk_start'] | ||
| 260 | data['total_afk'] += afk_duration | ||
| 261 | data['afk_start'] = None | ||
| 262 | data['is_afk'] = False | ||
| 263 | data['active_start'] = current_ts | ||
| 264 | save_activity_data(activity_data) | ||
| 265 | |||
| 266 | afk_str = format_duration(afk_duration) | ||
| 267 | |||
| 268 | if after.self_mute: | ||
| 269 | activity = "odciszył" | ||
| 270 | else: | ||
| 271 | activity = "odgłuchnął" | ||
| 272 | |||
| 273 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 274 | f"AFK: {afk_str}" ) | ||
| 275 | await log_channel.send(message) | ||
| 276 | logger.info(f"User returned from AFK: {message}") | ||
| 277 | elif not data.get('is_afk') and (after.self_mute or after.self_deaf): | ||
| 278 | active_since_start = 0 | ||
| 279 | if data.get('active_start'): | ||
| 280 | active_since_start = current_ts - data['active_start'] | ||
| 281 | data['total_active'] += active_since_start | ||
| 282 | data['afk_start'] = current_ts | ||
| 283 | data['is_afk'] = True | ||
| 284 | data['active_start'] = None | ||
| 285 | save_activity_data(activity_data) | ||
| 286 | |||
| 287 | if after.self_mute: | ||
| 288 | activity = "wyciszył" | ||
| 289 | else: | ||
| 290 | activity = "ogłuchnął" | ||
| 291 | |||
| 292 | active_str = format_duration(active_since_start) | ||
| 293 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 294 | f"Aktywny: {active_str}" ) | ||
| 295 | await log_channel.send(message) | ||
| 296 | logger.info(f"User went AFK: {message}") | ||
| 297 | |||
| 298 | if before.self_deaf != after.self_deaf: | ||
| 299 | tts_text, tts_channel = await self.handle_state_change( | ||
| 300 | member, after.channel, 'deaf', after.self_deaf, log_channel | ||
| 301 | ) | ||
| 302 | if tts_text and tts_channel: | ||
| 303 | await self.play_tts(tts_channel, tts_text) | ||
| 304 | return | ||
| 305 | |||
| 306 | if before.self_mute != after.self_mute: | ||
| 307 | tts_text, tts_channel = await self.handle_state_change( | ||
| 308 | member, after.channel, 'mute', after.self_mute, log_channel | ||
| 309 | ) | ||
| 310 | |||
| 311 | if tts_text and tts_channel: | ||
| 312 | await self.play_tts(tts_channel, tts_text) | ||
| 313 | |||
| 314 | |||
| 315 | def setup(bot): | ||
| 316 | bot.add_cog(VcLogs(bot)) | ||
diff --git a/cogs/voice.py b/cogs/voice.py deleted file mode 100644 index a224336..0000000 --- a/cogs/voice.py +++ /dev/null | |||
| @@ -1,444 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | import logging | ||
| 8 | import asyncio | ||
| 9 | import os | ||
| 10 | import urllib.parse | ||
| 11 | from dataclasses import dataclass, field | ||
| 12 | from typing import Any | ||
| 13 | |||
| 14 | import mutagen | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | |||
| 19 | _voice_managers: dict[int, "VoiceManager"] = {} | ||
| 20 | |||
| 21 | |||
| 22 | def get_voice_manager(bot) -> "VoiceManager": | ||
| 23 | """Get or create VoiceManager for a specific bot instance.""" | ||
| 24 | bot_id = bot.user.id if bot.user else id(bot) | ||
| 25 | if bot_id not in _voice_managers: | ||
| 26 | _voice_managers[bot_id] = VoiceManager(bot) | ||
| 27 | return _voice_managers[bot_id] | ||
| 28 | |||
| 29 | |||
| 30 | def is_voice_client_alive(voice_client, guild): | ||
| 31 | """Check if a voice client connection is healthy.""" | ||
| 32 | if voice_client is None: | ||
| 33 | return False | ||
| 34 | if not voice_client.is_connected(): | ||
| 35 | return False | ||
| 36 | if voice_client.channel is None: | ||
| 37 | return False | ||
| 38 | if guild.get_channel(voice_client.channel.id) is None: | ||
| 39 | return False | ||
| 40 | |||
| 41 | try: | ||
| 42 | ws = voice_client._connection | ||
| 43 | s = ws.socket | ||
| 44 | if s is None or s.fileno() == -1: | ||
| 45 | return False | ||
| 46 | except Exception: | ||
| 47 | return False | ||
| 48 | |||
| 49 | return True | ||
| 50 | |||
| 51 | |||
| 52 | @dataclass | ||
| 53 | class VoiceManager: | ||
| 54 | bot: Any | ||
| 55 | _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) | ||
| 56 | _connecting: set[int] = field(default_factory=set, init=False) | ||
| 57 | |||
| 58 | # --- Connection ----------------------------------------------------------- | ||
| 59 | |||
| 60 | async def _ensure_voice_client(self, channel): | ||
| 61 | """Unified connect/move/reconnect logic. | ||
| 62 | |||
| 63 | Returns a healthy VoiceClient connected to *channel*, or None on failure. | ||
| 64 | """ | ||
| 65 | guild = channel.guild | ||
| 66 | guild_id = guild.id | ||
| 67 | |||
| 68 | async with self._lock: | ||
| 69 | if guild_id in self._connecting: | ||
| 70 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") | ||
| 71 | return None | ||
| 72 | |||
| 73 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 74 | |||
| 75 | # --- resolve existing client -------------------------------------- | ||
| 76 | if vc is not None: | ||
| 77 | if vc.is_connected(): | ||
| 78 | if vc.channel == channel: | ||
| 79 | if is_voice_client_alive(vc, guild): | ||
| 80 | logger.info(f"Already connected to {channel}") | ||
| 81 | return vc | ||
| 82 | logger.warning(f"Stale connection to {channel}, reconnecting") | ||
| 83 | # Disconnect resets the state machine to 'disconnected' | ||
| 84 | # but keeps the VoiceClient alive. Reusing it avoids a | ||
| 85 | # race where Discord sends VOICE_SERVER_UPDATE between | ||
| 86 | # the destroy and the fresh-connect below, causing the | ||
| 87 | # new client to timeout waiting for the event (since the | ||
| 88 | # old one already consumed it). | ||
| 89 | try: | ||
| 90 | await vc.disconnect() | ||
| 91 | except Exception: | ||
| 92 | pass | ||
| 93 | try: | ||
| 94 | await vc.connect( | ||
| 95 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 96 | ) | ||
| 97 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 98 | return vc | ||
| 99 | except Exception as e: | ||
| 100 | logger.error(f"Failed to reconnect stale voice client: {e}") | ||
| 101 | try: | ||
| 102 | channel._state._remove_voice_client(guild.id) | ||
| 103 | except Exception: | ||
| 104 | pass | ||
| 105 | vc = None | ||
| 106 | else: | ||
| 107 | try: | ||
| 108 | await vc.move_to(channel) | ||
| 109 | logger.info(f"Moved to voice channel: {channel}") | ||
| 110 | return vc | ||
| 111 | except Exception as e: | ||
| 112 | logger.error(f"Failed to move voice client: {e}") | ||
| 113 | try: | ||
| 114 | await vc.disconnect() | ||
| 115 | except Exception: | ||
| 116 | pass | ||
| 117 | try: | ||
| 118 | await vc.connect( | ||
| 119 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 120 | ) | ||
| 121 | logger.info(f"Reconnected after failed move: {channel}") | ||
| 122 | return vc | ||
| 123 | except Exception: | ||
| 124 | pass | ||
| 125 | try: | ||
| 126 | channel._state._remove_voice_client(guild.id) | ||
| 127 | except Exception: | ||
| 128 | pass | ||
| 129 | vc = None | ||
| 130 | else: | ||
| 131 | logger.info("Voice client exists but disconnected, reconnecting") | ||
| 132 | try: | ||
| 133 | await vc.disconnect() | ||
| 134 | except Exception: | ||
| 135 | pass | ||
| 136 | try: | ||
| 137 | await vc.connect( | ||
| 138 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 139 | ) | ||
| 140 | logger.info(f"Reconnected disconnected client: {channel}") | ||
| 141 | return vc | ||
| 142 | except Exception as e: | ||
| 143 | logger.error(f"Failed to reconnect disconnected client: {e}") | ||
| 144 | try: | ||
| 145 | channel._state._remove_voice_client(guild.id) | ||
| 146 | except Exception: | ||
| 147 | pass | ||
| 148 | vc = None | ||
| 149 | |||
| 150 | # --- create fresh connection -------------------------------------- | ||
| 151 | self._connecting.add(guild_id) | ||
| 152 | try: | ||
| 153 | await asyncio.sleep(0.3) | ||
| 154 | try: | ||
| 155 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 156 | logger.info(f"Connected to voice channel: {channel}") | ||
| 157 | return vc | ||
| 158 | except discord.ClientException as e: | ||
| 159 | if "Already connected" in str(e): | ||
| 160 | logger.warning(f"Stale voice state detected, forcing reconnect: {e}") | ||
| 161 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 162 | if vc: | ||
| 163 | try: | ||
| 164 | await vc.disconnect() | ||
| 165 | except Exception: | ||
| 166 | pass | ||
| 167 | else: | ||
| 168 | try: | ||
| 169 | channel._state._remove_voice_client(guild.id) | ||
| 170 | except Exception: | ||
| 171 | pass | ||
| 172 | await asyncio.sleep(0.3) | ||
| 173 | try: | ||
| 174 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 175 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 176 | return vc | ||
| 177 | except Exception as e2: | ||
| 178 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | ||
| 179 | return None | ||
| 180 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 181 | return None | ||
| 182 | except Exception as e: | ||
| 183 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 184 | return None | ||
| 185 | finally: | ||
| 186 | self._connecting.discard(guild_id) | ||
| 187 | |||
| 188 | async def connect(self, channel): | ||
| 189 | """Join a voice channel (public alias).""" | ||
| 190 | return await self._ensure_voice_client(channel) | ||
| 191 | |||
| 192 | async def join_channel(self, channel): | ||
| 193 | """Backward-compatible alias for connect().""" | ||
| 194 | return await self.connect(channel) | ||
| 195 | |||
| 196 | async def join_by_channel_id(self, channel_id): | ||
| 197 | """Join a voice channel by channel ID.""" | ||
| 198 | channel = self.bot.get_channel(channel_id) | ||
| 199 | if not channel: | ||
| 200 | logger.error(f"Channel {channel_id} not found") | ||
| 201 | return None | ||
| 202 | if not isinstance(channel, discord.VoiceChannel): | ||
| 203 | logger.error(f"Channel {channel_id} is not a voice channel") | ||
| 204 | return None | ||
| 205 | return await self.connect(channel) | ||
| 206 | |||
| 207 | async def get_client(self, guild): | ||
| 208 | """Get existing voice client for a guild.""" | ||
| 209 | return discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 210 | |||
| 211 | async def get_voice_client(self, guild): | ||
| 212 | """Backward-compatible alias for get_client().""" | ||
| 213 | return await self.get_client(guild) | ||
| 214 | |||
| 215 | async def get_status(self, guild): | ||
| 216 | """Get voice client status. | ||
| 217 | |||
| 218 | Returns: | ||
| 219 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) | ||
| 220 | """ | ||
| 221 | vc = await self.get_client(guild) | ||
| 222 | if vc is None: | ||
| 223 | return None, False, False | ||
| 224 | return vc, vc.is_connected(), vc.is_playing() | ||
| 225 | |||
| 226 | async def get_voice_status(self, guild): | ||
| 227 | """Backward-compatible alias for get_status().""" | ||
| 228 | return await self.get_status(guild) | ||
| 229 | |||
| 230 | async def disconnect(self, guild): | ||
| 231 | """Stop playback and disconnect from voice.""" | ||
| 232 | vc = await self.get_client(guild) | ||
| 233 | if vc: | ||
| 234 | vc.stop() | ||
| 235 | logger.info("Disconnecting from voice") | ||
| 236 | await vc.disconnect() | ||
| 237 | logger.info(f"Disconnected from guild {guild}") | ||
| 238 | |||
| 239 | async def stop(self, guild): | ||
| 240 | """Backward-compatible alias for disconnect().""" | ||
| 241 | await self.disconnect(guild) | ||
| 242 | |||
| 243 | # --- Playback ------------------------------------------------------------ | ||
| 244 | |||
| 245 | async def _play_await(self, vc, audio_source): | ||
| 246 | """Play an audio source and wait for completion using an asyncio.Future. | ||
| 247 | |||
| 248 | Returns True on success, False on error. No polling, no magic sleeps. | ||
| 249 | """ | ||
| 250 | loop = asyncio.get_event_loop() | ||
| 251 | future = loop.create_future() | ||
| 252 | |||
| 253 | def after(error): | ||
| 254 | if not future.done(): | ||
| 255 | if error: | ||
| 256 | future.set_exception(Exception(str(error))) | ||
| 257 | else: | ||
| 258 | future.set_result(True) | ||
| 259 | |||
| 260 | vc.play(audio_source, after=after) | ||
| 261 | |||
| 262 | try: | ||
| 263 | await future | ||
| 264 | return True | ||
| 265 | except Exception as e: | ||
| 266 | logger.error(f"Playback error: {e}") | ||
| 267 | return False | ||
| 268 | |||
| 269 | async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): | ||
| 270 | """Connect to *channel* and play a pre-made audio source. | ||
| 271 | |||
| 272 | Returns True on success, False on failure. | ||
| 273 | """ | ||
| 274 | vc = await self.connect(channel) | ||
| 275 | if not vc: | ||
| 276 | logger.error("play_source: could not connect to voice channel") | ||
| 277 | return False | ||
| 278 | |||
| 279 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 280 | vc.stop() | ||
| 281 | |||
| 282 | if abs(volume - 1.0) > 1e-9: | ||
| 283 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 284 | audio_source.volume = volume | ||
| 285 | |||
| 286 | logger.info("Playing via play_source") | ||
| 287 | return await self._play_await(vc, audio_source) | ||
| 288 | |||
| 289 | # --- Source preparation (folded from AudioPlaybackManager) --------------- | ||
| 290 | |||
| 291 | @staticmethod | ||
| 292 | async def validate_voice_channel(ctx): | ||
| 293 | """Validate user is in a voice channel. Returns the channel or None.""" | ||
| 294 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 295 | await ctx.respond( | ||
| 296 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True | ||
| 297 | ) | ||
| 298 | return None | ||
| 299 | return ctx.author.voice.channel | ||
| 300 | |||
| 301 | @staticmethod | ||
| 302 | async def prepare_file_source(source): | ||
| 303 | """Validate a local file and return (display_name, absolute_path) or (None, None).""" | ||
| 304 | logger.info(f"Attempting to play local file: {source}") | ||
| 305 | |||
| 306 | if not os.path.isabs(source): | ||
| 307 | source = os.path.abspath(source) | ||
| 308 | logger.debug(f"Converted to absolute path: {source}") | ||
| 309 | |||
| 310 | if not os.path.exists(source): | ||
| 311 | logger.error(f"File not found: {source}") | ||
| 312 | return None, None | ||
| 313 | |||
| 314 | filename = os.path.basename(source) | ||
| 315 | display_name = os.path.splitext(filename)[0] | ||
| 316 | return display_name, source | ||
| 317 | |||
| 318 | @staticmethod | ||
| 319 | async def prepare_audio_source(source, is_url, display_name): | ||
| 320 | """Prepare FFmpegPCMAudio from a URL or local file. | ||
| 321 | |||
| 322 | Returns (audio_source, final_display_name). | ||
| 323 | """ | ||
| 324 | if is_url: | ||
| 325 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" | ||
| 326 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) | ||
| 327 | filename = os.path.basename(source.split("?")[0]) | ||
| 328 | filename = urllib.parse.unquote(filename) | ||
| 329 | clean_filename = os.path.splitext(filename)[0] | ||
| 330 | return audio_source, clean_filename | ||
| 331 | else: | ||
| 332 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 333 | try: | ||
| 334 | metadata = mutagen.open(source) | ||
| 335 | if metadata: | ||
| 336 | title = ( | ||
| 337 | metadata.get("title", [None])[0] | ||
| 338 | if hasattr(metadata, "get") | ||
| 339 | else None | ||
| 340 | ) | ||
| 341 | if title: | ||
| 342 | display_name = title | ||
| 343 | logger.debug(f"Read metadata - title: {title}") | ||
| 344 | else: | ||
| 345 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 346 | except Exception as e: | ||
| 347 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 348 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 349 | |||
| 350 | return audio_source, display_name | ||
| 351 | |||
| 352 | @staticmethod | ||
| 353 | async def prepare_source(source): | ||
| 354 | """Determine whether *source* is a URL or local file. | ||
| 355 | |||
| 356 | Returns (source_path, display_name, is_url) or (None, None, None) on failure. | ||
| 357 | """ | ||
| 358 | is_url = source.startswith(("http://", "https://")) | ||
| 359 | |||
| 360 | if is_url: | ||
| 361 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 362 | return source, source, is_url | ||
| 363 | |||
| 364 | display_name, absolute_source = await VoiceManager.prepare_file_source(source) | ||
| 365 | if not display_name: | ||
| 366 | return None, None, None | ||
| 367 | |||
| 368 | return absolute_source, display_name, is_url | ||
| 369 | |||
| 370 | @staticmethod | ||
| 371 | async def cleanup_temp_file(source): | ||
| 372 | """Remove a temporary file if it exists.""" | ||
| 373 | if source and os.path.exists(source): | ||
| 374 | try: | ||
| 375 | os.remove(source) | ||
| 376 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 377 | except Exception as e: | ||
| 378 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 379 | |||
| 380 | @staticmethod | ||
| 381 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 382 | """Conditionally clean up a file after playback.""" | ||
| 383 | if delete_after_play and not is_url: | ||
| 384 | await VoiceManager.cleanup_temp_file(source) | ||
| 385 | |||
| 386 | # --- High-level play (full flow) ----------------------------------------- | ||
| 387 | |||
| 388 | async def play(self, ctx, source, delete_after_play=False, interrupt=True): | ||
| 389 | """High-level playback: validate context, prepare source, connect, play, cleanup. | ||
| 390 | |||
| 391 | Sends ``grom: {name}`` immediately on start, and an error follow-up | ||
| 392 | if playback fails. | ||
| 393 | |||
| 394 | Args: | ||
| 395 | ctx: Discord application context (used for channel resolution & error messages). | ||
| 396 | source: Local file path or URL to stream. | ||
| 397 | delete_after_play: Remove local file after playback (no-op for URLs). | ||
| 398 | interrupt: Stop currently playing audio before starting. | ||
| 399 | |||
| 400 | Returns: | ||
| 401 | bool: True if playback completed successfully, False otherwise. | ||
| 402 | """ | ||
| 403 | prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) | ||
| 404 | if not prepared_source: | ||
| 405 | await VoiceManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 406 | return False | ||
| 407 | |||
| 408 | target_channel = await VoiceManager.validate_voice_channel(ctx) | ||
| 409 | if not target_channel: | ||
| 410 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 411 | return False | ||
| 412 | |||
| 413 | vc = await self.connect(target_channel) | ||
| 414 | if not vc: | ||
| 415 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 416 | return False | ||
| 417 | |||
| 418 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 419 | vc.stop() | ||
| 420 | |||
| 421 | audio_source, final_display_name = await VoiceManager.prepare_audio_source( | ||
| 422 | prepared_source, is_url, display_name | ||
| 423 | ) | ||
| 424 | |||
| 425 | try: | ||
| 426 | await ctx.followup.send(f"grom: {final_display_name}") | ||
| 427 | except Exception as e: | ||
| 428 | logger.error(e) | ||
| 429 | |||
| 430 | success = await self._play_await(vc, audio_source) | ||
| 431 | |||
| 432 | if success: | ||
| 433 | logger.info(f"Playback completed: {final_display_name}") | ||
| 434 | else: | ||
| 435 | logger.error(f"Playback failed: {prepared_source}") | ||
| 436 | try: | ||
| 437 | await ctx.followup.send( | ||
| 438 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | ||
| 439 | ) | ||
| 440 | except Exception: | ||
| 441 | pass | ||
| 442 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 443 | |||
| 444 | return success | ||
diff --git a/cogs/web.py b/cogs/web.py deleted file mode 100644 index fd22f9f..0000000 --- a/cogs/web.py +++ /dev/null | |||
| @@ -1,90 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | from discord import Embed | ||
| 8 | from discord.ext import commands | ||
| 9 | import requests | ||
| 10 | from bs4 import BeautifulSoup | ||
| 11 | import urllib.parse | ||
| 12 | import yt_dlp | ||
| 13 | import logging | ||
| 14 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | |||
| 19 | class Web(commands.Cog): | ||
| 20 | def __init__(self, bot): | ||
| 21 | self.bot = bot | ||
| 22 | |||
| 23 | def _extract_youtube_title(self, url): | ||
| 24 | """Extract YouTube video title if URL is a YouTube link.""" | ||
| 25 | if "youtube.com/watch" not in url: | ||
| 26 | return None | ||
| 27 | |||
| 28 | url = clean_url_for_ytdlp(url) | ||
| 29 | logger.info("youtube link") | ||
| 30 | try: | ||
| 31 | ydl_opts = {"quiet": True, "nowarnings": True} | ||
| 32 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | ||
| 33 | info = ydl.extract_info(url, download=False) | ||
| 34 | return info.get("title") if info else None | ||
| 35 | except Exception: | ||
| 36 | pass | ||
| 37 | return None | ||
| 38 | |||
| 39 | def _process_search_results(self, links, query): | ||
| 40 | """Process search results and create embed.""" | ||
| 41 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) | ||
| 42 | view = discord.ui.View(disable_on_timeout=True) | ||
| 43 | |||
| 44 | for i, link in enumerate(links[:5], start=1): | ||
| 45 | title = link.text | ||
| 46 | youtube_title = self._extract_youtube_title(link.get("href")) | ||
| 47 | if youtube_title: | ||
| 48 | title = youtube_title | ||
| 49 | |||
| 50 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) | ||
| 51 | |||
| 52 | return embed, view | ||
| 53 | |||
| 54 | @commands.slash_command( | ||
| 55 | name="web", | ||
| 56 | description="Search the web using DuckDuckGo" | ||
| 57 | ) | ||
| 58 | async def web( | ||
| 59 | self, | ||
| 60 | ctx: discord.ApplicationContext, | ||
| 61 | query: str | ||
| 62 | ): | ||
| 63 | await ctx.defer() | ||
| 64 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} | ||
| 65 | encoded_query = urllib.parse.quote_plus(query) | ||
| 66 | url = f"https://duckduckgo.com/html/?q={encoded_query}" | ||
| 67 | response = requests.get(url, headers=headers) | ||
| 68 | |||
| 69 | logger.info(f"Query: {query}") | ||
| 70 | |||
| 71 | if response.status_code != 200: | ||
| 72 | await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True) | ||
| 73 | logger.error(f"Error: {response.status_code}") | ||
| 74 | return | ||
| 75 | |||
| 76 | soup = BeautifulSoup(response.text, "html.parser") | ||
| 77 | links = soup.find_all("a", class_="result__a") | ||
| 78 | |||
| 79 | if not links: | ||
| 80 | await ctx.respond(content="No results found.", ephemeral=True) | ||
| 81 | logger.warning("No results found") | ||
| 82 | return | ||
| 83 | |||
| 84 | embed, view = self._process_search_results(links, query) | ||
| 85 | await ctx.respond(embed=embed, view=view) | ||
| 86 | logger.info("Search results sent") | ||
| 87 | |||
| 88 | |||
| 89 | def setup(bot): | ||
| 90 | bot.add_cog(Web(bot)) | ||
diff --git a/main.py b/main.py deleted file mode 100644 index cab1f54..0000000 --- a/main.py +++ /dev/null | |||
| @@ -1,96 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import sys | ||
| 10 | import argparse | ||
| 11 | |||
| 12 | import discord | ||
| 13 | from discord.ext import commands | ||
| 14 | from dotenv import load_dotenv | ||
| 15 | |||
| 16 | from cogs.utils import settings | ||
| 17 | |||
| 18 | |||
| 19 | def setup_logging(debug: bool = False): | ||
| 20 | """Configure logging based on debug flag.""" | ||
| 21 | level = logging.DEBUG if debug else logging.INFO | ||
| 22 | |||
| 23 | class DecryptionFilter(logging.Filter): | ||
| 24 | def filter(self, record): | ||
| 25 | return "Decryption failed" not in record.getMessage() | ||
| 26 | |||
| 27 | logging.basicConfig( | ||
| 28 | level=level, | ||
| 29 | datefmt="%m-%d %H:%M:%S", | ||
| 30 | format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s", | ||
| 31 | handlers=[logging.StreamHandler(sys.stdout)], | ||
| 32 | ) | ||
| 33 | logging.getLogger().addFilter(DecryptionFilter()) | ||
| 34 | |||
| 35 | |||
| 36 | # Create a new event loop and set it as the current loop | ||
| 37 | loop = asyncio.new_event_loop() | ||
| 38 | asyncio.set_event_loop(loop) | ||
| 39 | |||
| 40 | intents = discord.Intents.all() | ||
| 41 | bot = commands.Bot(intents=intents, loop=loop) | ||
| 42 | |||
| 43 | |||
| 44 | @bot.event | ||
| 45 | async def on_ready(): | ||
| 46 | logger = logging.getLogger(__name__) | ||
| 47 | logger.info("ŁUKASZ SPERMA AKTYWACJA") | ||
| 48 | |||
| 49 | await settings.load_settings() | ||
| 50 | |||
| 51 | |||
| 52 | if __name__ == "__main__": | ||
| 53 | parser = argparse.ArgumentParser(description="Run Senator Sperma bot") | ||
| 54 | parser.add_argument("--debug", action="store_true", help="Enable debug logging") | ||
| 55 | args, unknown = parser.parse_known_args() | ||
| 56 | |||
| 57 | load_dotenv() | ||
| 58 | setup_logging(args.debug) | ||
| 59 | |||
| 60 | extensions = [ | ||
| 61 | "cogs.vclogs", | ||
| 62 | "cogs.compression", | ||
| 63 | "cogs.tts", | ||
| 64 | "cogs.web", | ||
| 65 | "cogs.ai", | ||
| 66 | "cogs.conversion", | ||
| 67 | "cogs.math_commands", | ||
| 68 | "cogs.downloader", | ||
| 69 | "cogs.say", | ||
| 70 | "cogs.assistant", | ||
| 71 | "cogs.music.play_commands", | ||
| 72 | "cogs.music.stop", | ||
| 73 | "cogs.music.join", | ||
| 74 | "cogs.search", | ||
| 75 | "cogs.trigger", | ||
| 76 | "cogs.cron", | ||
| 77 | "cogs.qr", | ||
| 78 | "cogs.uploader", | ||
| 79 | ] | ||
| 80 | |||
| 81 | try: | ||
| 82 | for i, extension in enumerate(extensions, 1): | ||
| 83 | bot.load_extension(extension) | ||
| 84 | logging.getLogger(__name__).info(f"Loaded {i}/{len(extensions)} extensions") | ||
| 85 | |||
| 86 | token = os.getenv("TOKEN") | ||
| 87 | if not token: | ||
| 88 | raise ValueError( | ||
| 89 | "No token provided. Please set the TOKEN environment variable." | ||
| 90 | ) | ||
| 91 | |||
| 92 | bot.run(token) | ||
| 93 | |||
| 94 | except Exception as e: | ||
| 95 | logging.getLogger(__name__).error(f"Error starting the bot: {e}") | ||
| 96 | sys.exit(1) | ||
diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 38f1ed5..0000000 --- a/pyrightconfig.json +++ /dev/null | |||
| @@ -1,19 +0,0 @@ | |||
| 1 | { | ||
| 2 | "include": ["cogs", "main.py", "cogs/utils", "tests"], | ||
| 3 | "exclude": ["pycord", ".venv", "temp", "data"], | ||
| 4 | "typeCheckingMode": "basic", | ||
| 5 | "reportMissingTypeStubs": false, | ||
| 6 | "reportUnknownMemberType": "none", | ||
| 7 | "reportUnknownParameterType": "none", | ||
| 8 | "reportUnknownArgumentType": "none", | ||
| 9 | "reportUnknownVariableType": "none", | ||
| 10 | "reportMissingParameterType": false, | ||
| 11 | "reportAny": false, | ||
| 12 | "reportOptionalMemberAccess": false, | ||
| 13 | "reportOptionalSubscript": false, | ||
| 14 | "reportCallIssue": false, | ||
| 15 | "reportAttributeAccessIssue": false, | ||
| 16 | "reportPossiblyUnboundVariable": false, | ||
| 17 | "reportArgumentType": false, | ||
| 18 | "reportUnreachable": false, | ||
| 19 | } | ||
diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 23e8e9c..0000000 --- a/pytest.ini +++ /dev/null | |||
| @@ -1,9 +0,0 @@ | |||
| 1 | [pytest] | ||
| 2 | markers = | ||
| 3 | slow: marks tests as slow (deselect with '-m "not slow"') | ||
| 4 | asyncio_mode = strict | ||
| 5 | asyncio_default_fixture_loop_scope = function | ||
| 6 | filterwarnings = | ||
| 7 | ignore::RuntimeWarning | ||
| 8 | ignore::pytest.PytestUnraisableExceptionWarning | ||
| 9 | ignore::DeprecationWarning:discord.client \ No newline at end of file | ||
diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index ed5f5cc..0000000 --- a/requirements-dev.txt +++ /dev/null | |||
| @@ -1,18 +0,0 @@ | |||
| 1 | pytest==9.0.3 | ||
| 2 | pytest-asyncio==1.3.0 | ||
| 3 | basedpyright==1.39.5 | ||
| 4 | aiofiles==25.1.0 | ||
| 5 | aiohttp==3.13.5 | ||
| 6 | beautifulsoup4==4.14.3 | ||
| 7 | croniter==6.2.2 | ||
| 8 | gTTS==2.5.4 | ||
| 9 | httpx==0.28.1 | ||
| 10 | mutagen==1.47.0 | ||
| 11 | pillow==12.2.0 | ||
| 12 | py-cord==2.8.0 | ||
| 13 | python-dotenv==1.2.2 | ||
| 14 | pyzbar==0.1.9 | ||
| 15 | qrcode==8.2 | ||
| 16 | requests==2.34.2 | ||
| 17 | yt-dlp==2026.3.17 | ||
| 18 | youtube-search-python==1.6.6 | ||
diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 5fb918e..0000000 --- a/requirements.txt +++ /dev/null | |||
| @@ -1,15 +0,0 @@ | |||
| 1 | aiofiles==25.1.0 | ||
| 2 | aiohttp==3.13.5 | ||
| 3 | beautifulsoup4==4.14.3 | ||
| 4 | croniter==6.2.2 | ||
| 5 | gTTS==2.5.4 | ||
| 6 | httpx==0.28.1 | ||
| 7 | mutagen==1.47.0 | ||
| 8 | pillow==12.2.0 | ||
| 9 | py-cord==2.8.0 | ||
| 10 | python-dotenv==1.2.2 | ||
| 11 | pyzbar==0.1.9 | ||
| 12 | qrcode==8.2 | ||
| 13 | requests==2.34.2 | ||
| 14 | yt-dlp==2026.3.17 | ||
| 15 | youtube-search-python==1.6.6 | ||
diff --git a/scripts/bump_reqs.py b/scripts/bump_reqs.py deleted file mode 100644 index 5d253e7..0000000 --- a/scripts/bump_reqs.py +++ /dev/null | |||
| @@ -1,50 +0,0 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Bump all pinned versions in requirements*.txt to latest from PyPI.""" | ||
| 3 | |||
| 4 | import json | ||
| 5 | import re | ||
| 6 | import urllib.request | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | REQS = sorted(Path().glob("requirements*.txt")) | ||
| 10 | PYPI = "https://pypi.org/pypi/{}/json" | ||
| 11 | |||
| 12 | |||
| 13 | def latest_version(package: str) -> str | None: | ||
| 14 | url = PYPI.format(package) | ||
| 15 | try: | ||
| 16 | with urllib.request.urlopen(url, timeout=10) as resp: | ||
| 17 | data = json.load(resp) | ||
| 18 | return data["info"]["version"] | ||
| 19 | except Exception: | ||
| 20 | return None | ||
| 21 | |||
| 22 | |||
| 23 | def bump_file(path: Path): | ||
| 24 | lines = path.read_text().splitlines() | ||
| 25 | changed = [] | ||
| 26 | for line in lines: | ||
| 27 | m = re.match(r"^([\w-]+(?:\[[\w-]+])?)==[\w.]+$", line) | ||
| 28 | if m: | ||
| 29 | pkg = m.group(1) | ||
| 30 | latest = latest_version(pkg) | ||
| 31 | if latest: | ||
| 32 | changed.append(f"{pkg}=={latest}") | ||
| 33 | if f"{pkg}=={latest}" != line: | ||
| 34 | print(f" {pkg}: {line.split('==')[1]} -> {latest}") | ||
| 35 | else: | ||
| 36 | changed.append(line) | ||
| 37 | print(f" {pkg}: failed to fetch, keeping current") | ||
| 38 | else: | ||
| 39 | changed.append(line) | ||
| 40 | path.write_text("\n".join(changed) + "\n") | ||
| 41 | |||
| 42 | |||
| 43 | def main(): | ||
| 44 | for req in REQS: | ||
| 45 | print(f"\n{req}:") | ||
| 46 | bump_file(req) | ||
| 47 | |||
| 48 | |||
| 49 | if __name__ == "__main__": | ||
| 50 | main() | ||
diff --git a/src/commands/ai.rs b/src/commands/ai.rs new file mode 100644 index 0000000..a75f164 --- /dev/null +++ b/src/commands/ai.rs | |||
| @@ -0,0 +1,76 @@ | |||
| 1 | use crate::Data; | ||
| 2 | use crate::error::Error; | ||
| 3 | use crate::lang; | ||
| 4 | |||
| 5 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 6 | |||
| 7 | /// Query the AI senator via OpenRouter | ||
| 8 | #[poise::command(slash_command)] | ||
| 9 | pub async fn senator( | ||
| 10 | ctx: Context<'_>, | ||
| 11 | #[description = "Wiadomość do wysłania"] text: String, | ||
| 12 | #[description = "Przemówić w kanale głosowym?"] tts_vc: Option<bool>, | ||
| 13 | ) -> crate::error::Result<()> { | ||
| 14 | ctx.defer().await?; | ||
| 15 | |||
| 16 | let api_key = ctx | ||
| 17 | .data() | ||
| 18 | .config | ||
| 19 | .openrouter_api_key | ||
| 20 | .as_ref() | ||
| 21 | .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?; | ||
| 22 | let model = ctx | ||
| 23 | .data() | ||
| 24 | .config | ||
| 25 | .openrouter_model | ||
| 26 | .as_deref() | ||
| 27 | .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"); | ||
| 28 | |||
| 29 | let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string()); | ||
| 30 | let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; | ||
| 31 | |||
| 32 | if tts_vc.unwrap_or(false) { | ||
| 33 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 34 | let channel_id = ctx | ||
| 35 | .serenity_context() | ||
| 36 | .cache | ||
| 37 | .guild(guild_id) | ||
| 38 | .and_then(|g| { | ||
| 39 | g.voice_states | ||
| 40 | .get(&ctx.author().id) | ||
| 41 | .and_then(|vs| vs.channel_id) | ||
| 42 | }) | ||
| 43 | .ok_or_else(|| lang::err_not_in_voice())?; | ||
| 44 | |||
| 45 | let tts_url = format!( | ||
| 46 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | ||
| 47 | crate::commands::tts::urlencoding(&response) | ||
| 48 | ); | ||
| 49 | let audio_data = ctx | ||
| 50 | .data() | ||
| 51 | .http_client | ||
| 52 | .get(&tts_url) | ||
| 53 | .send() | ||
| 54 | .await? | ||
| 55 | .bytes() | ||
| 56 | .await?; | ||
| 57 | |||
| 58 | let call = ctx | ||
| 59 | .data() | ||
| 60 | .voice_manager | ||
| 61 | .connect(guild_id, channel_id) | ||
| 62 | .await?; | ||
| 63 | let input = crate::voice::decode_audio(&audio_data)?; | ||
| 64 | let mut call_lock = call.lock().await; | ||
| 65 | if call_lock.current_channel().is_some() { | ||
| 66 | call_lock.play_input(input); | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | ctx.say(response).await?; | ||
| 71 | Ok(()) | ||
| 72 | } | ||
| 73 | |||
| 74 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 75 | vec![senator()] | ||
| 76 | } | ||
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs new file mode 100644 index 0000000..497aec9 --- /dev/null +++ b/src/commands/autocomplete.rs | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use crate::Data; | ||
| 4 | use crate::copyparty::{html_decode, percent_decode}; | ||
| 5 | use crate::error::Error; | ||
| 6 | |||
| 7 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 8 | |||
| 9 | const MAX_CHOICE_LEN: usize = 99; | ||
| 10 | |||
| 11 | fn filename_label(raw_path: &str) -> Option<String> { | ||
| 12 | let raw_name = raw_path.rsplit('/').next()?; | ||
| 13 | if raw_name.is_empty() { | ||
| 14 | return None; | ||
| 15 | } | ||
| 16 | let decoded = html_decode(&percent_decode(raw_name)); | ||
| 17 | if decoded.len() > MAX_CHOICE_LEN { | ||
| 18 | let mut s: String = decoded.chars().take(MAX_CHOICE_LEN - 1).collect(); | ||
| 19 | s.push('\u{2026}'); | ||
| 20 | Some(s) | ||
| 21 | } else { | ||
| 22 | Some(decoded) | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | pub async fn audio_file<'a>( | ||
| 27 | ctx: Context<'_>, | ||
| 28 | partial: &'a str, | ||
| 29 | ) -> Vec<serenity::AutocompleteChoice> { | ||
| 30 | eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); | ||
| 31 | let results = ctx | ||
| 32 | .data() | ||
| 33 | .copyparty | ||
| 34 | .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) | ||
| 35 | .await; | ||
| 36 | match results { | ||
| 37 | Ok(hits) => { | ||
| 38 | eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); | ||
| 39 | let mut choices = Vec::new(); | ||
| 40 | for (i, hit) in hits.into_iter().enumerate() { | ||
| 41 | let rp = match &hit.rp { | ||
| 42 | Some(p) => p.clone(), | ||
| 43 | None => continue, | ||
| 44 | }; | ||
| 45 | eprintln!( | ||
| 46 | "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", | ||
| 47 | hit.url, hit.name, hit.path | ||
| 48 | ); | ||
| 49 | let Some(label) = filename_label(&rp) else { | ||
| 50 | continue; | ||
| 51 | }; | ||
| 52 | ctx.data() | ||
| 53 | .ac_stash(ctx.author().id, label.clone(), hit) | ||
| 54 | .await; | ||
| 55 | eprintln!("[DEBUG] -> cached as {label:?}"); | ||
| 56 | choices.push(serenity::AutocompleteChoice::new(label.clone(), rp)); | ||
| 57 | if choices.len() >= 25 { | ||
| 58 | break; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | eprintln!("[DEBUG] audio_file returning {} choices", choices.len()); | ||
| 62 | choices | ||
| 63 | } | ||
| 64 | Err(e) => { | ||
| 65 | eprintln!("[DEBUG] audio_file search failed: {e}"); | ||
| 66 | vec![] | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<serenity::AutocompleteChoice> { | ||
| 72 | eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); | ||
| 73 | let results = ctx.data().copyparty.search(partial, 25, &[]).await; | ||
| 74 | match results { | ||
| 75 | Ok(hits) => { | ||
| 76 | eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); | ||
| 77 | let mut choices = Vec::new(); | ||
| 78 | for (i, hit) in hits.into_iter().enumerate() { | ||
| 79 | let rp = match &hit.rp { | ||
| 80 | Some(p) => p.clone(), | ||
| 81 | None => continue, | ||
| 82 | }; | ||
| 83 | eprintln!( | ||
| 84 | "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", | ||
| 85 | hit.url, hit.name, hit.path | ||
| 86 | ); | ||
| 87 | let Some(label) = filename_label(&rp) else { | ||
| 88 | continue; | ||
| 89 | }; | ||
| 90 | ctx.data() | ||
| 91 | .ac_stash(ctx.author().id, label.clone(), hit) | ||
| 92 | .await; | ||
| 93 | eprintln!("[DEBUG] -> cached as {label:?}"); | ||
| 94 | choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); | ||
| 95 | if choices.len() >= 25 { | ||
| 96 | break; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | eprintln!("[DEBUG] any_file returning {} choices", choices.len()); | ||
| 100 | choices | ||
| 101 | } | ||
| 102 | Err(e) => { | ||
| 103 | eprintln!("[DEBUG] any_file search failed: {e}"); | ||
| 104 | vec![] | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String> { | ||
| 110 | eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); | ||
| 111 | let db_pool = &ctx.data().db_pool; | ||
| 112 | match crate::database::list_triggers(db_pool).await { | ||
| 113 | Ok(triggers) => { | ||
| 114 | let names: Vec<_> = triggers | ||
| 115 | .into_iter() | ||
| 116 | .map(|t| t.name) | ||
| 117 | .filter(|name| partial.is_empty() || name.starts_with(partial)) | ||
| 118 | .take(25) | ||
| 119 | .collect(); | ||
| 120 | eprintln!("[DEBUG] trigger_name returning {} names", names.len()); | ||
| 121 | names | ||
| 122 | } | ||
| 123 | Err(e) => { | ||
| 124 | eprintln!("[DEBUG] trigger_name query failed: {e}"); | ||
| 125 | vec![] | ||
| 126 | } | ||
| 127 | } | ||
| 128 | } | ||
diff --git a/src/commands/cron.rs b/src/commands/cron.rs new file mode 100644 index 0000000..48f6ff1 --- /dev/null +++ b/src/commands/cron.rs | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use super::autocomplete::audio_file; | ||
| 4 | use crate::Data; | ||
| 5 | use crate::database; | ||
| 6 | use crate::error::{Error, Result}; | ||
| 7 | use crate::lang; | ||
| 8 | |||
| 9 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 10 | |||
| 11 | /// Cron job management | ||
| 12 | #[poise::command( | ||
| 13 | slash_command, | ||
| 14 | subcommands("dodaj_audio", "dodaj_wiadomość", "pokaż", "usuń", "pauza", "wznów",) | ||
| 15 | )] | ||
| 16 | pub async fn cron(_ctx: Context<'_>) -> Result<()> { | ||
| 17 | Ok(()) | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Schedule audio playback | ||
| 21 | #[poise::command(slash_command)] | ||
| 22 | pub async fn dodaj_audio( | ||
| 23 | ctx: Context<'_>, | ||
| 24 | #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, | ||
| 25 | #[description = "Target voice channel"] channel: serenity::Channel, | ||
| 26 | #[description = "Copyparty URL of audio file"] | ||
| 27 | #[autocomplete = "audio_file"] | ||
| 28 | audio_filename: String, | ||
| 29 | ) -> Result<()> { | ||
| 30 | let channel_id = match channel { | ||
| 31 | serenity::Channel::Guild(ch) => ch.id.get() as i64, | ||
| 32 | _ => return Err(lang::err_bad_channel_type()), | ||
| 33 | }; | ||
| 34 | |||
| 35 | database::add_cron_job( | ||
| 36 | &ctx.data().db_pool, | ||
| 37 | &cron_expression, | ||
| 38 | "audio", | ||
| 39 | channel_id, | ||
| 40 | Some(&audio_filename), | ||
| 41 | None, | ||
| 42 | ) | ||
| 43 | .await?; | ||
| 44 | |||
| 45 | ctx.say(lang::fmt_cron_audio_queued(&cron_expression)) | ||
| 46 | .await?; | ||
| 47 | Ok(()) | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Schedule a periodic message | ||
| 51 | #[poise::command(slash_command)] | ||
| 52 | pub async fn dodaj_wiadomość( | ||
| 53 | ctx: Context<'_>, | ||
| 54 | #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, | ||
| 55 | #[description = "Docelowy kanał tekstowy"] channel: serenity::Channel, | ||
| 56 | #[description = "Zawartość wiadomości"] message: String, | ||
| 57 | ) -> Result<()> { | ||
| 58 | let channel_id = match channel { | ||
| 59 | serenity::Channel::Guild(ch) => ch.id.get() as i64, | ||
| 60 | _ => return Err(lang::err_bad_channel_type()), | ||
| 61 | }; | ||
| 62 | |||
| 63 | database::add_cron_job( | ||
| 64 | &ctx.data().db_pool, | ||
| 65 | &cron_expression, | ||
| 66 | "wiadomość", | ||
| 67 | channel_id, | ||
| 68 | None, | ||
| 69 | Some(&message), | ||
| 70 | ) | ||
| 71 | .await?; | ||
| 72 | |||
| 73 | ctx.say(lang::L_CRON_QUEUED).await?; | ||
| 74 | Ok(()) | ||
| 75 | } | ||
| 76 | |||
| 77 | /// List all cron jobs | ||
| 78 | #[poise::command(slash_command)] | ||
| 79 | pub async fn pokaż(ctx: Context<'_>) -> Result<()> { | ||
| 80 | let jobs = database::list_cron_jobs(&ctx.data().db_pool).await?; | ||
| 81 | if jobs.is_empty() { | ||
| 82 | ctx.say(lang::L_CRON_NONE).await?; | ||
| 83 | return Ok(()); | ||
| 84 | } | ||
| 85 | |||
| 86 | let mut embed = serenity::CreateEmbed::default() | ||
| 87 | .title(lang::L_CRON_TITLE) | ||
| 88 | .color(0x00aaff); | ||
| 89 | |||
| 90 | for job in &jobs { | ||
| 91 | let status = if job.active == 1 { | ||
| 92 | lang::L_CRON_ACTIVE | ||
| 93 | } else { | ||
| 94 | lang::L_CRON_PAUSED | ||
| 95 | }; | ||
| 96 | let desc = match job.r#type.as_str() { | ||
| 97 | "audio" => lang::fmt_cron_label_audio(job.audio_filename.as_deref().unwrap_or("?")), | ||
| 98 | _ => lang::fmt_cron_label_msg(job.message.as_deref().unwrap_or("?")), | ||
| 99 | }; | ||
| 100 | embed = embed.field( | ||
| 101 | lang::fmt_cron_job_status(job.id, status), | ||
| 102 | lang::fmt_cron_field(&job.cron_expression, job.channel_id, &desc), | ||
| 103 | false, | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | ctx.send(poise::CreateReply::default().embed(embed)).await?; | ||
| 108 | Ok(()) | ||
| 109 | } | ||
| 110 | |||
| 111 | /// Remove a cron job | ||
| 112 | #[poise::command(slash_command)] | ||
| 113 | pub async fn usuń( | ||
| 114 | ctx: Context<'_>, | ||
| 115 | #[description = "ID pracy do usunięcia"] job_id: i64, | ||
| 116 | ) -> Result<()> { | ||
| 117 | if database::delete_cron_job(&ctx.data().db_pool, job_id).await? { | ||
| 118 | ctx.say(lang::fmt_cron_deleted(job_id)).await?; | ||
| 119 | } else { | ||
| 120 | ctx.say(lang::fmt_cron_not_found(job_id)).await?; | ||
| 121 | } | ||
| 122 | Ok(()) | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Pause a cron job | ||
| 126 | #[poise::command(slash_command)] | ||
| 127 | pub async fn pauza( | ||
| 128 | ctx: Context<'_>, | ||
| 129 | #[description = "ID pracy do zapauzowania"] job_id: i64, | ||
| 130 | ) -> Result<()> { | ||
| 131 | if database::set_cron_job_active(&ctx.data().db_pool, job_id, false).await? { | ||
| 132 | ctx.say(lang::fmt_cron_paused(job_id)).await?; | ||
| 133 | } else { | ||
| 134 | ctx.say(lang::fmt_cron_not_found(job_id)).await?; | ||
| 135 | } | ||
| 136 | Ok(()) | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Resume a paused cron job | ||
| 140 | #[poise::command(slash_command)] | ||
| 141 | pub async fn wznów( | ||
| 142 | ctx: Context<'_>, | ||
| 143 | #[description = "ID pracy cron do wznowienia"] job_id: i64, | ||
| 144 | ) -> Result<()> { | ||
| 145 | if database::set_cron_job_active(&ctx.data().db_pool, job_id, true).await? { | ||
| 146 | ctx.say(lang::fmt_cron_resumed(job_id)).await?; | ||
| 147 | } else { | ||
| 148 | ctx.say(lang::fmt_cron_not_found(job_id)).await?; | ||
| 149 | } | ||
| 150 | Ok(()) | ||
| 151 | } | ||
| 152 | |||
| 153 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 154 | vec![cron()] | ||
| 155 | } | ||
diff --git a/src/commands/math.rs b/src/commands/math.rs new file mode 100644 index 0000000..43a7de8 --- /dev/null +++ b/src/commands/math.rs | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | use crate::lang; | ||
| 2 | use crate::Data; | ||
| 3 | use crate::error::{Error, Result}; | ||
| 4 | |||
| 5 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 6 | |||
| 7 | /// Evaluate a math expression via qalc | ||
| 8 | #[poise::command(slash_command)] | ||
| 9 | pub async fn math( | ||
| 10 | ctx: Context<'_>, | ||
| 11 | #[description = "Ekspresja matematyczn"] equation: String, | ||
| 12 | ) -> Result<()> { | ||
| 13 | if equation.len() > 1000 { | ||
| 14 | ctx.say(lang::L_MATH_TOO_LONG).await?; | ||
| 15 | return Ok(()); | ||
| 16 | } | ||
| 17 | |||
| 18 | if !equation.is_ascii() { | ||
| 19 | ctx.say(lang::L_MATH_NOT_ASCII).await?; | ||
| 20 | return Ok(()); | ||
| 21 | } | ||
| 22 | |||
| 23 | let mut depth = 0i32; | ||
| 24 | for c in equation.chars() { | ||
| 25 | match c { | ||
| 26 | '(' | '[' | '{' => depth += 1, | ||
| 27 | ')' | ']' | '}' => depth -= 1, | ||
| 28 | _ => {} | ||
| 29 | } | ||
| 30 | if depth < 0 { | ||
| 31 | ctx.say(lang::L_MATH_UNBALANCED).await?; | ||
| 32 | return Ok(()); | ||
| 33 | } | ||
| 34 | } | ||
| 35 | if depth != 0 { | ||
| 36 | ctx.say(lang::L_MATH_UNBALANCED).await?; | ||
| 37 | return Ok(()); | ||
| 38 | } | ||
| 39 | |||
| 40 | let dangerous = [ | ||
| 41 | "eval", | ||
| 42 | "exec", | ||
| 43 | "os.", | ||
| 44 | "subprocess", | ||
| 45 | "import", | ||
| 46 | "__", | ||
| 47 | "system", | ||
| 48 | "shutil", | ||
| 49 | "typecast", | ||
| 50 | "shell", | ||
| 51 | "fork", | ||
| 52 | "execve", | ||
| 53 | ]; | ||
| 54 | let lower = equation.to_lowercase(); | ||
| 55 | if dangerous.iter().any(|d| lower.contains(d)) { | ||
| 56 | ctx.say(lang::L_MATH_DANGEROUS).await?; | ||
| 57 | return Ok(()); | ||
| 58 | } | ||
| 59 | |||
| 60 | let output = tokio::process::Command::new("qalc") | ||
| 61 | .arg(&equation) | ||
| 62 | .output() | ||
| 63 | .await | ||
| 64 | .map_err(|e| Error::Custom(lang::fmt_math_qalc_err(&e)))?; | ||
| 65 | |||
| 66 | let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); | ||
| 67 | if result.is_empty() { | ||
| 68 | ctx.say(lang::L_MATH_NO_RESULT).await?; | ||
| 69 | } else { | ||
| 70 | ctx.say(lang::fmt_math_result(&result)).await?; | ||
| 71 | } | ||
| 72 | Ok(()) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 76 | vec![math()] | ||
| 77 | } | ||
diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..9d6a1d0 --- /dev/null +++ b/src/commands/mod.rs | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | pub mod music; | ||
| 2 | pub mod tts; | ||
| 3 | pub mod ai; | ||
| 4 | pub mod web; | ||
| 5 | pub mod math; | ||
| 6 | pub mod qr; | ||
| 7 | pub mod cron; | ||
| 8 | pub mod trigger; | ||
| 9 | pub mod utilities; | ||
| 10 | pub mod autocomplete; | ||
| 11 | |||
| 12 | use crate::Data; | ||
| 13 | |||
| 14 | pub fn all_commands() -> Vec<poise::Command<Data, crate::error::Error>> { | ||
| 15 | let mut cmds = Vec::new(); | ||
| 16 | cmds.extend(music::commands()); | ||
| 17 | cmds.extend(tts::commands()); | ||
| 18 | cmds.extend(ai::commands()); | ||
| 19 | cmds.extend(web::commands()); | ||
| 20 | cmds.extend(math::commands()); | ||
| 21 | cmds.extend(qr::commands()); | ||
| 22 | cmds.extend(cron::commands()); | ||
| 23 | cmds.extend(trigger::commands()); | ||
| 24 | cmds.extend(utilities::commands()); | ||
| 25 | cmds | ||
| 26 | } | ||
diff --git a/src/commands/music.rs b/src/commands/music.rs new file mode 100644 index 0000000..313577d --- /dev/null +++ b/src/commands/music.rs | |||
| @@ -0,0 +1,414 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use tracing::{error, info}; | ||
| 3 | |||
| 4 | use super::autocomplete::audio_file; | ||
| 5 | use crate::Data; | ||
| 6 | use crate::copyparty::{html_decode, percent_decode}; | ||
| 7 | use crate::error::{Error, Result}; | ||
| 8 | use crate::lang; | ||
| 9 | |||
| 10 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 11 | |||
| 12 | // ── Source classification ────────────────────────────────────────── | ||
| 13 | |||
| 14 | /// What kind of audio source are we dealing with? | ||
| 15 | /// | ||
| 16 | /// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` | ||
| 17 | /// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while | ||
| 18 | /// *every other* URL is streamed directly via ffmpeg (radio, HLS, | ||
| 19 | /// playlists, direct media files). | ||
| 20 | #[derive(Debug, Clone, PartialEq)] | ||
| 21 | enum SourceKind { | ||
| 22 | /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve | ||
| 23 | /// raw audio at a direct URL. Needs yt-dlp to extract + download the | ||
| 24 | /// media to memory before decoding. | ||
| 25 | YtDlp, | ||
| 26 | /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`, | ||
| 27 | /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or | ||
| 28 | /// directly-accessible media files. Streamed via an ffmpeg subprocess | ||
| 29 | /// — NO full download. ffmpeg handles playlist parsing, Icecast | ||
| 30 | /// metadata, reconnection, and codec/container detection universally. | ||
| 31 | Stream, | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Play music from Copyparty, URL, or file upload | ||
| 35 | #[poise::command(slash_command)] | ||
| 36 | pub async fn grajcuj( | ||
| 37 | ctx: Context<'_>, | ||
| 38 | #[description = "Plik do odtworzenia"] | ||
| 39 | #[autocomplete = "audio_file"] | ||
| 40 | melodia: Option<String>, | ||
| 41 | #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, | ||
| 42 | #[description = "Chujowa kompresja!?"] compressed: Option<bool>, | ||
| 43 | ) -> Result<()> { | ||
| 44 | let compressed = compressed.unwrap_or(false); | ||
| 45 | eprintln!( | ||
| 46 | "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", | ||
| 47 | melodia, | ||
| 48 | file.as_ref().map(|f| &f.filename) | ||
| 49 | ); | ||
| 50 | let result = play_impl(ctx, melodia, file, compressed).await; | ||
| 51 | if let Err(e) = &result { | ||
| 52 | eprintln!("[DEBUG] play() error: {e}"); | ||
| 53 | error!("play command failed: {e}"); | ||
| 54 | } | ||
| 55 | result | ||
| 56 | } | ||
| 57 | |||
| 58 | struct ResolvedSource { | ||
| 59 | url: String, | ||
| 60 | display_name: String, | ||
| 61 | kind: SourceKind, | ||
| 62 | } | ||
| 63 | |||
| 64 | fn clean_name(raw: &str) -> String { | ||
| 65 | html_decode(&percent_decode(raw)) | ||
| 66 | } | ||
| 67 | |||
| 68 | // ── URL classification helpers ────────────────────────────────────── | ||
| 69 | |||
| 70 | /// URLs that require yt-dlp extraction (platforms that don't serve | ||
| 71 | /// raw audio at a direct URL). These MUST be downloaded first. | ||
| 72 | /// | ||
| 73 | /// Matches the Python original's `EXTERNAL_URL_PATTERN`: | ||
| 74 | /// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch). | ||
| 75 | fn needs_ytdlp(url: &str) -> bool { | ||
| 76 | let lower = url.to_lowercase(); | ||
| 77 | lower.contains("youtube.com/") | ||
| 78 | || lower.contains("youtu.be/") | ||
| 79 | || lower.contains("twitch.tv/") | ||
| 80 | || lower.contains("soundcloud.com/") | ||
| 81 | || lower.contains("bandcamp.com/") | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Heuristic: does this URL *look* like a live internet radio stream? | ||
| 85 | /// (Used for logging / display only — dispatch is controlled by `SourceKind`.) | ||
| 86 | fn is_live_stream_hint(url: &str) -> bool { | ||
| 87 | let lower = url.to_lowercase(); | ||
| 88 | // Common Icecast/Shoutcast mount patterns | ||
| 89 | lower.contains("/stream") | ||
| 90 | || lower.contains("/live") | ||
| 91 | || lower.contains("/radio") | ||
| 92 | || lower.contains(";stream") | ||
| 93 | || lower.contains("icecast") | ||
| 94 | || lower.contains("shoutcast") | ||
| 95 | || lower.ends_with(".m3u8") | ||
| 96 | || lower.ends_with(".m3u") | ||
| 97 | || lower.ends_with(".pls") | ||
| 98 | || lower.contains(":8000/") // common Icecast port | ||
| 99 | || lower.contains(":8080/") // common alt port | ||
| 100 | } | ||
| 101 | |||
| 102 | async fn resolve_source( | ||
| 103 | ctx: Context<'_>, | ||
| 104 | melodia: Option<String>, | ||
| 105 | file: Option<serenity::Attachment>, | ||
| 106 | ) -> Result<ResolvedSource> { | ||
| 107 | // ── File attachment ────────────────────────────────────────── | ||
| 108 | // Discord attachment URLs are direct media file URLs — ffmpeg streams | ||
| 109 | // them without a full download. | ||
| 110 | if let Some(file) = file { | ||
| 111 | return Ok(ResolvedSource { | ||
| 112 | url: file.url.clone(), | ||
| 113 | display_name: file.filename.clone(), | ||
| 114 | kind: SourceKind::Stream, | ||
| 115 | }); | ||
| 116 | } | ||
| 117 | |||
| 118 | let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; | ||
| 119 | |||
| 120 | // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ | ||
| 121 | if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { | ||
| 122 | let path = hit.url_path().unwrap_or(""); | ||
| 123 | let path = if path.starts_with('/') { | ||
| 124 | path.to_string() | ||
| 125 | } else { | ||
| 126 | format!("/{path}") | ||
| 127 | }; | ||
| 128 | let url = ctx.data().copyparty.build_url(&path); | ||
| 129 | let display_name = clean_name(hit.filename().unwrap_or(&name)); | ||
| 130 | return Ok(ResolvedSource { | ||
| 131 | url, | ||
| 132 | display_name, | ||
| 133 | kind: SourceKind::Stream, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | |||
| 137 | // ── Direct Copyparty path (starts with '/') ────────────────── | ||
| 138 | if name.starts_with('/') { | ||
| 139 | let url = ctx.data().copyparty.build_url(&name); | ||
| 140 | let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name)); | ||
| 141 | return Ok(ResolvedSource { | ||
| 142 | url, | ||
| 143 | display_name, | ||
| 144 | kind: SourceKind::Stream, | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | |||
| 148 | // ── Direct HTTP(S) URL ─────────────────────────────────────── | ||
| 149 | if name.starts_with("http://") || name.starts_with("https://") { | ||
| 150 | let url = name.clone(); | ||
| 151 | let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); | ||
| 152 | |||
| 153 | // yt-dlp platforms → download; everything else (radio, HLS, | ||
| 154 | // playlists, Copyparty, direct media) → ffmpeg stream. | ||
| 155 | let kind = if needs_ytdlp(&url) { | ||
| 156 | SourceKind::YtDlp | ||
| 157 | } else { | ||
| 158 | SourceKind::Stream | ||
| 159 | }; | ||
| 160 | return Ok(ResolvedSource { | ||
| 161 | url, | ||
| 162 | display_name, | ||
| 163 | kind, | ||
| 164 | }); | ||
| 165 | } | ||
| 166 | |||
| 167 | // ── Fallback: search Copyparty ─────────────────────────────── | ||
| 168 | info!(query = %name, "searching copyparty for audio"); | ||
| 169 | let results = ctx | ||
| 170 | .data() | ||
| 171 | .copyparty | ||
| 172 | .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) | ||
| 173 | .await | ||
| 174 | .map_err(|e| { | ||
| 175 | error!(query = %name, error = %e, "copyparty search failed"); | ||
| 176 | Error::Custom(lang::fmt_music_search_failed(&e)) | ||
| 177 | })?; | ||
| 178 | let hit = results | ||
| 179 | .first() | ||
| 180 | .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?; | ||
| 181 | let path = hit | ||
| 182 | .url_path() | ||
| 183 | .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?; | ||
| 184 | let path = if path.starts_with('/') { | ||
| 185 | path.to_string() | ||
| 186 | } else { | ||
| 187 | format!("/{path}") | ||
| 188 | }; | ||
| 189 | let url = ctx.data().copyparty.build_url(&path); | ||
| 190 | let display_name = clean_name(hit.filename().unwrap_or("unknown")); | ||
| 191 | Ok(ResolvedSource { | ||
| 192 | url, | ||
| 193 | display_name, | ||
| 194 | kind: SourceKind::Stream, | ||
| 195 | }) | ||
| 196 | } | ||
| 197 | |||
| 198 | /// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging. | ||
| 199 | fn redact_url(url: &str) -> &str { | ||
| 200 | let Some(q) = url.find('?') else { | ||
| 201 | return url; | ||
| 202 | }; | ||
| 203 | let before = &url[..q]; | ||
| 204 | let after = &url[q..]; | ||
| 205 | // Strip `?pw=...` or `&pw=...` | ||
| 206 | if after.strip_prefix("?pw=").is_some() { | ||
| 207 | return before; | ||
| 208 | } | ||
| 209 | if let Some(idx) = after.find("&pw=") { | ||
| 210 | return &url[..q + idx]; | ||
| 211 | } | ||
| 212 | url | ||
| 213 | } | ||
| 214 | |||
| 215 | /// Strip tracking/playlist params (`&...`) from YouTube URLs. | ||
| 216 | /// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. | ||
| 217 | pub(crate) fn sanitize_yt_url(url: &str) -> String { | ||
| 218 | let lower = url.to_lowercase(); | ||
| 219 | if lower.contains("youtube.com/") || lower.contains("youtu.be/") { | ||
| 220 | if let Some(pos) = url.find('&') { | ||
| 221 | return url[..pos].to_string(); | ||
| 222 | } | ||
| 223 | } | ||
| 224 | url.to_string() | ||
| 225 | } | ||
| 226 | |||
| 227 | // ── Playlist resolution is handled by ffmpeg natively ────────────── | ||
| 228 | // ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment | ||
| 229 | // fetching, PLS/M3U entry parsing) — no manual resolution needed. | ||
| 230 | // This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach. | ||
| 231 | |||
| 232 | async fn play_impl( | ||
| 233 | ctx: Context<'_>, | ||
| 234 | melodia: Option<String>, | ||
| 235 | file: Option<serenity::Attachment>, | ||
| 236 | compressed: bool, | ||
| 237 | ) -> Result<()> { | ||
| 238 | ctx.defer().await?; | ||
| 239 | |||
| 240 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 241 | |||
| 242 | let channel_id = ctx | ||
| 243 | .serenity_context() | ||
| 244 | .cache | ||
| 245 | .guild(guild_id) | ||
| 246 | .and_then(|g| { | ||
| 247 | g.voice_states | ||
| 248 | .get(&ctx.author().id) | ||
| 249 | .and_then(|vs| vs.channel_id) | ||
| 250 | }) | ||
| 251 | .ok_or_else(|| lang::err_not_in_voice())?; | ||
| 252 | |||
| 253 | let src = resolve_source(ctx, melodia, file).await?; | ||
| 254 | |||
| 255 | let call; | ||
| 256 | let input; | ||
| 257 | |||
| 258 | if compressed { | ||
| 259 | // ── Compressed path: download → compress → decode ────────── | ||
| 260 | // Defer stopping current track until compression is done. | ||
| 261 | let audio_bytes = match src.kind { | ||
| 262 | SourceKind::YtDlp => { | ||
| 263 | let clean_url = sanitize_yt_url(&src.url); | ||
| 264 | info!(url = %clean_url, "downloading via yt-dlp for compression"); | ||
| 265 | crate::voice::ytdlp_download(&clean_url).await? | ||
| 266 | } | ||
| 267 | SourceKind::Stream => { | ||
| 268 | if is_live_stream_hint(&src.url) { | ||
| 269 | ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?; | ||
| 270 | // Fall through to normal streaming below | ||
| 271 | let live = true; | ||
| 272 | info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg"); | ||
| 273 | // Stop current, connect, stream | ||
| 274 | { | ||
| 275 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 276 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 277 | let _ = handle.stop(); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | let (call_res, input_res) = tokio::join!( | ||
| 281 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 282 | crate::voice::ffmpeg_stream_input(&src.url) | ||
| 283 | ); | ||
| 284 | call = call_res?; | ||
| 285 | input = input_res?; | ||
| 286 | // skip the rest of the compressed path | ||
| 287 | let handle = { | ||
| 288 | let mut call_lock = call.lock().await; | ||
| 289 | call_lock.play_input(input) | ||
| 290 | }; | ||
| 291 | ctx.data() | ||
| 292 | .music_tracks | ||
| 293 | .lock() | ||
| 294 | .await | ||
| 295 | .insert(guild_id.get(), handle); | ||
| 296 | ctx.say(lang::fmt_music_playing(&src.display_name)).await?; | ||
| 297 | return Ok(()); | ||
| 298 | } | ||
| 299 | info!(url = %redact_url(&src.url), "downloading for compression"); | ||
| 300 | crate::voice::download_url(&ctx.data().http_client, &src.url).await? | ||
| 301 | } | ||
| 302 | }; | ||
| 303 | |||
| 304 | info!("degrading audio ({} bytes)", audio_bytes.len()); | ||
| 305 | let degraded = crate::voice::degrade_audio(&audio_bytes).await?; | ||
| 306 | |||
| 307 | // Deferred stop: only interrupt current track now that we're ready. | ||
| 308 | { | ||
| 309 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 310 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 311 | let _ = handle.stop(); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | call = ctx | ||
| 315 | .data() | ||
| 316 | .voice_manager | ||
| 317 | .connect(guild_id, channel_id) | ||
| 318 | .await?; | ||
| 319 | input = degraded; | ||
| 320 | } else { | ||
| 321 | // ── Normal path: stop first, then stream/download ────────── | ||
| 322 | { | ||
| 323 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 324 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 325 | let _ = handle.stop(); | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | match src.kind { | ||
| 330 | SourceKind::YtDlp => { | ||
| 331 | let clean_url = sanitize_yt_url(&src.url); | ||
| 332 | info!(url = %clean_url, "downloading via yt-dlp"); | ||
| 333 | let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; | ||
| 334 | let (call_res, input_res) = tokio::join!( | ||
| 335 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 336 | tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) | ||
| 337 | ); | ||
| 338 | call = call_res?; | ||
| 339 | input = input_res??; | ||
| 340 | } | ||
| 341 | SourceKind::Stream => { | ||
| 342 | let live = is_live_stream_hint(&src.url); | ||
| 343 | info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); | ||
| 344 | let (call_res, input_res) = tokio::join!( | ||
| 345 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 346 | crate::voice::ffmpeg_stream_input(&src.url) | ||
| 347 | ); | ||
| 348 | call = call_res?; | ||
| 349 | input = input_res?; | ||
| 350 | } | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | let handle = { | ||
| 355 | let mut call_lock = call.lock().await; | ||
| 356 | call_lock.play_input(input) | ||
| 357 | }; | ||
| 358 | |||
| 359 | ctx.data() | ||
| 360 | .music_tracks | ||
| 361 | .lock() | ||
| 362 | .await | ||
| 363 | .insert(guild_id.get(), handle); | ||
| 364 | |||
| 365 | ctx.say(lang::fmt_music_playing(&src.display_name)).await?; | ||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Stop playback and disconnect from voice | ||
| 370 | #[poise::command(slash_command)] | ||
| 371 | pub async fn stop(ctx: Context<'_>) -> Result<()> { | ||
| 372 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 373 | ctx.data().voice_manager.disconnect(guild_id).await?; | ||
| 374 | ctx.data().music_tracks.lock().await.remove(&guild_id.get()); | ||
| 375 | ctx.send( | ||
| 376 | poise::CreateReply::default() | ||
| 377 | .content(lang::L_MUSIC_STOPPED) | ||
| 378 | .ephemeral(true), | ||
| 379 | ) | ||
| 380 | .await?; | ||
| 381 | Ok(()) | ||
| 382 | } | ||
| 383 | |||
| 384 | /// Join your voice channel | ||
| 385 | #[poise::command(slash_command)] | ||
| 386 | pub async fn join(ctx: Context<'_>) -> Result<()> { | ||
| 387 | ctx.defer().await?; | ||
| 388 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 389 | |||
| 390 | let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { | ||
| 391 | g.voice_states | ||
| 392 | .get(&ctx.author().id) | ||
| 393 | .and_then(|vs| vs.channel_id) | ||
| 394 | }); | ||
| 395 | |||
| 396 | match channel_id { | ||
| 397 | Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await { | ||
| 398 | Ok(_) => { | ||
| 399 | ctx.say(lang::fmt_voice_joined(&cid)).await?; | ||
| 400 | } | ||
| 401 | Err(e) => { | ||
| 402 | ctx.say(lang::fmt_voice_join_failed(&e)).await?; | ||
| 403 | } | ||
| 404 | }, | ||
| 405 | None => { | ||
| 406 | ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?; | ||
| 407 | } | ||
| 408 | } | ||
| 409 | Ok(()) | ||
| 410 | } | ||
| 411 | |||
| 412 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 413 | vec![grajcuj(), stop(), join()] | ||
| 414 | } | ||
diff --git a/src/commands/qr.rs b/src/commands/qr.rs new file mode 100644 index 0000000..26232ee --- /dev/null +++ b/src/commands/qr.rs | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use crate::Data; | ||
| 4 | use crate::error::{Error, Result}; | ||
| 5 | use crate::lang; | ||
| 6 | |||
| 7 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 8 | |||
| 9 | /// QR group | ||
| 10 | #[poise::command(slash_command, subcommands("dekodowanie", "generowanie",))] | ||
| 11 | pub async fn qr(_ctx: Context<'_>) -> Result<()> { | ||
| 12 | Ok(()) | ||
| 13 | } | ||
| 14 | |||
| 15 | /// Generate a QR code from text | ||
| 16 | #[poise::command(slash_command)] | ||
| 17 | pub async fn generowanie( | ||
| 18 | ctx: Context<'_>, | ||
| 19 | #[description = "Zawartość kodu QR"] text: String, | ||
| 20 | ) -> Result<()> { | ||
| 21 | ctx.defer().await?; | ||
| 22 | |||
| 23 | let code = qrcode::QrCode::new(text.as_bytes())?; | ||
| 24 | let image = code.render::<image::Luma<u8>>().build(); | ||
| 25 | |||
| 26 | let scaled = image::imageops::resize( | ||
| 27 | &image, | ||
| 28 | image.width() * 10, | ||
| 29 | image.height() * 10, | ||
| 30 | image::imageops::Nearest, | ||
| 31 | ); | ||
| 32 | |||
| 33 | let mut buf = std::io::Cursor::new(Vec::new()); | ||
| 34 | scaled.write_to(&mut buf, image::ImageFormat::Png)?; | ||
| 35 | |||
| 36 | ctx.send( | ||
| 37 | poise::CreateReply::default() | ||
| 38 | .content(lang::L_QR_GENERATED) | ||
| 39 | .attachment(serenity::CreateAttachment::bytes( | ||
| 40 | buf.into_inner(), | ||
| 41 | "qrcode.png", | ||
| 42 | )), | ||
| 43 | ) | ||
| 44 | .await?; | ||
| 45 | Ok(()) | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Decode a QR code from an uploaded image | ||
| 49 | #[poise::command(slash_command)] | ||
| 50 | pub async fn dekodowanie( | ||
| 51 | ctx: Context<'_>, | ||
| 52 | #[description = "Obraz zawierający kod QR"] image: serenity::Attachment, | ||
| 53 | ) -> Result<()> { | ||
| 54 | ctx.defer().await?; | ||
| 55 | |||
| 56 | let img_data = ctx | ||
| 57 | .data() | ||
| 58 | .http_client | ||
| 59 | .get(&image.url) | ||
| 60 | .send() | ||
| 61 | .await? | ||
| 62 | .bytes() | ||
| 63 | .await?; | ||
| 64 | let img = image::load_from_memory(&img_data)?; | ||
| 65 | let gray = img.to_luma8(); | ||
| 66 | |||
| 67 | let decoded = decode_qr_simple(&gray); | ||
| 68 | match decoded { | ||
| 69 | Some(text) => { | ||
| 70 | ctx.say(lang::fmt_qr_decoded(&text)).await?; | ||
| 71 | } | ||
| 72 | None => { | ||
| 73 | ctx.say(lang::L_QR_DECODE_FAILED).await?; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | Ok(()) | ||
| 77 | } | ||
| 78 | |||
| 79 | fn decode_qr_simple(img: &image::ImageBuffer<image::Luma<u8>, Vec<u8>>) -> Option<String> { | ||
| 80 | rxing::helpers::detect_in_luma(img.as_raw().to_vec(), img.width(), img.height(), None) | ||
| 81 | .ok() | ||
| 82 | .map(|r| r.getText().to_owned()) | ||
| 83 | } | ||
| 84 | |||
| 85 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 86 | vec![qr()] | ||
| 87 | } | ||
diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs new file mode 100644 index 0000000..7453e03 --- /dev/null +++ b/src/commands/trigger.rs | |||
| @@ -0,0 +1,199 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use super::autocomplete::trigger_name; | ||
| 4 | use crate::Data; | ||
| 5 | use crate::database; | ||
| 6 | use crate::error::{Error, Result}; | ||
| 7 | use crate::lang; | ||
| 8 | |||
| 9 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 10 | |||
| 11 | /// Trigger keyword management | ||
| 12 | #[poise::command( | ||
| 13 | slash_command, | ||
| 14 | subcommands("create", "add", "remove", "delete", "list", "help",) | ||
| 15 | )] | ||
| 16 | pub async fn trigger(_ctx: Context<'_>) -> Result<()> { | ||
| 17 | Ok(()) | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Create a new trigger group | ||
| 21 | #[poise::command(slash_command)] | ||
| 22 | pub async fn create( | ||
| 23 | ctx: Context<'_>, | ||
| 24 | #[description = "Logical group name"] | ||
| 25 | #[autocomplete = "trigger_name"] | ||
| 26 | name: String, | ||
| 27 | ) -> Result<()> { | ||
| 28 | match database::create_trigger(&ctx.data().db_pool, &name).await { | ||
| 29 | Ok(_) => { | ||
| 30 | ctx.data().reload_triggers().await; | ||
| 31 | ctx.say(lang::fmt_trigger_created(&name)).await?; | ||
| 32 | } | ||
| 33 | Err(e) => { | ||
| 34 | ctx.say(lang::fmt_trigger_failed(&e)).await?; | ||
| 35 | } | ||
| 36 | } | ||
| 37 | Ok(()) | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Add keywords and/or responses to a trigger group | ||
| 41 | #[poise::command(slash_command)] | ||
| 42 | pub async fn add( | ||
| 43 | ctx: Context<'_>, | ||
| 44 | #[description = "Existing group name"] | ||
| 45 | #[autocomplete = "trigger_name"] | ||
| 46 | name: String, | ||
| 47 | #[description = "Keywords separated by \\"] trigger_keywords: Option<String>, | ||
| 48 | #[description = "Responses separated by \\"] response: Option<String>, | ||
| 49 | ) -> Result<()> { | ||
| 50 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | ||
| 51 | .await? | ||
| 52 | .ok_or_else(|| lang::err_trigger_not_found())?; | ||
| 53 | |||
| 54 | let mut modified = false; | ||
| 55 | if let Some(kw) = trigger_keywords { | ||
| 56 | for keyword in kw.split('\\') { | ||
| 57 | let k = keyword.trim(); | ||
| 58 | if !k.is_empty() { | ||
| 59 | database::add_keyword(&ctx.data().db_pool, t.id, k).await?; | ||
| 60 | modified = true; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | if let Some(r) = response { | ||
| 65 | for resp in r.split('\\') { | ||
| 66 | let r = resp.trim(); | ||
| 67 | if !r.is_empty() { | ||
| 68 | database::add_response(&ctx.data().db_pool, t.id, r).await?; | ||
| 69 | modified = true; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | if modified { | ||
| 75 | ctx.data().reload_triggers().await; | ||
| 76 | } | ||
| 77 | ctx.say(lang::L_TRIGGER_UPDATED).await?; | ||
| 78 | Ok(()) | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Remove keyword or response from a group | ||
| 82 | #[poise::command(slash_command)] | ||
| 83 | pub async fn remove( | ||
| 84 | ctx: Context<'_>, | ||
| 85 | #[description = "Group name"] | ||
| 86 | #[autocomplete = "trigger_name"] | ||
| 87 | name: String, | ||
| 88 | #[description = "Keyword to remove"] trigger_keyword: Option<String>, | ||
| 89 | #[description = "Response to remove"] response: Option<String>, | ||
| 90 | ) -> Result<()> { | ||
| 91 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | ||
| 92 | .await? | ||
| 93 | .ok_or_else(|| lang::err_trigger_not_found())?; | ||
| 94 | |||
| 95 | let mut modified = false; | ||
| 96 | if let Some(kw) = trigger_keyword { | ||
| 97 | database::remove_keyword(&ctx.data().db_pool, t.id, &kw).await?; | ||
| 98 | modified = true; | ||
| 99 | } | ||
| 100 | if let Some(r) = response { | ||
| 101 | database::remove_response(&ctx.data().db_pool, t.id, &r).await?; | ||
| 102 | modified = true; | ||
| 103 | } | ||
| 104 | |||
| 105 | if modified { | ||
| 106 | ctx.data().reload_triggers().await; | ||
| 107 | } | ||
| 108 | ctx.say(lang::L_TRIGGER_UPDATED).await?; | ||
| 109 | Ok(()) | ||
| 110 | } | ||
| 111 | |||
| 112 | /// Delete an entire trigger group | ||
| 113 | #[poise::command(slash_command)] | ||
| 114 | pub async fn delete( | ||
| 115 | ctx: Context<'_>, | ||
| 116 | #[description = "Group name"] | ||
| 117 | #[autocomplete = "trigger_name"] | ||
| 118 | name: String, | ||
| 119 | ) -> Result<()> { | ||
| 120 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | ||
| 121 | .await? | ||
| 122 | .ok_or_else(|| lang::err_trigger_not_found())?; | ||
| 123 | |||
| 124 | database::delete_trigger(&ctx.data().db_pool, t.id).await?; | ||
| 125 | ctx.data().reload_triggers().await; | ||
| 126 | ctx.say(lang::fmt_trigger_deleted(&name)).await?; | ||
| 127 | Ok(()) | ||
| 128 | } | ||
| 129 | |||
| 130 | /// List keywords and responses for a group | ||
| 131 | #[poise::command(slash_command)] | ||
| 132 | pub async fn list( | ||
| 133 | ctx: Context<'_>, | ||
| 134 | #[description = "Group name"] | ||
| 135 | #[autocomplete = "trigger_name"] | ||
| 136 | name: String, | ||
| 137 | ) -> Result<()> { | ||
| 138 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | ||
| 139 | .await? | ||
| 140 | .ok_or_else(|| lang::err_trigger_not_found())?; | ||
| 141 | |||
| 142 | let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?; | ||
| 143 | let responses = database::get_responses(&ctx.data().db_pool, t.id).await?; | ||
| 144 | |||
| 145 | let mut embed = serenity::CreateEmbed::default() | ||
| 146 | .title(lang::fmt_trigger_title(&name)) | ||
| 147 | .color(0x00aaff); | ||
| 148 | |||
| 149 | embed = embed.field( | ||
| 150 | "Keywords", | ||
| 151 | if keywords.is_empty() { | ||
| 152 | lang::L_TRIGGER_KW_NONE.into() | ||
| 153 | } else { | ||
| 154 | keywords | ||
| 155 | .iter() | ||
| 156 | .map(|r| format!("{r}")) | ||
| 157 | .collect::<Vec<_>>() | ||
| 158 | .join("\n") | ||
| 159 | }, | ||
| 160 | false, | ||
| 161 | ); | ||
| 162 | embed = embed.field( | ||
| 163 | "Responses", | ||
| 164 | if responses.is_empty() { | ||
| 165 | lang::L_TRIGGER_KW_NONE.into() | ||
| 166 | } else { | ||
| 167 | responses | ||
| 168 | .iter() | ||
| 169 | .map(|r| format!("{r}")) | ||
| 170 | .collect::<Vec<_>>() | ||
| 171 | .join("\n") | ||
| 172 | }, | ||
| 173 | false, | ||
| 174 | ); | ||
| 175 | |||
| 176 | ctx.send(poise::CreateReply::default().embed(embed)).await?; | ||
| 177 | Ok(()) | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Display trigger command usage info | ||
| 181 | #[poise::command(slash_command)] | ||
| 182 | pub async fn help(ctx: Context<'_>) -> Result<()> { | ||
| 183 | let help = [ | ||
| 184 | "**Trigger Commands**", | ||
| 185 | "`/trigger create <name>` — Create new trigger group", | ||
| 186 | "`/trigger add <name> <trigger> <response>` — Add keywords/responses (separate with \\)", | ||
| 187 | "`/trigger remove <name> <trigger> <response>` — Remove keywords/responses", | ||
| 188 | "`/trigger delete <name>` — Delete entire group", | ||
| 189 | "`/trigger list <name>` — View group details", | ||
| 190 | ] | ||
| 191 | .join("\n"); | ||
| 192 | |||
| 193 | ctx.say(help).await?; | ||
| 194 | Ok(()) | ||
| 195 | } | ||
| 196 | |||
| 197 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 198 | vec![trigger()] | ||
| 199 | } | ||
diff --git a/src/commands/tts.rs b/src/commands/tts.rs new file mode 100644 index 0000000..ee13596 --- /dev/null +++ b/src/commands/tts.rs | |||
| @@ -0,0 +1,106 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use crate::Data; | ||
| 4 | use crate::error::{Error, Result}; | ||
| 5 | use crate::lang; | ||
| 6 | |||
| 7 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 8 | |||
| 9 | #[derive(poise::ChoiceParameter)] | ||
| 10 | pub enum OutputMode { | ||
| 11 | #[name = "File"] | ||
| 12 | File, | ||
| 13 | #[name = "Voice Channel"] | ||
| 14 | Vc, | ||
| 15 | } | ||
| 16 | |||
| 17 | /// Generate Google TTS (Polish) and output to file or voice channel | ||
| 18 | #[poise::command(slash_command)] | ||
| 19 | pub async fn tts( | ||
| 20 | ctx: Context<'_>, | ||
| 21 | #[description = "Text to synthesize"] text: String, | ||
| 22 | #[description = "Output destination (file or vc)"] output: Option<OutputMode>, | ||
| 23 | ) -> Result<()> { | ||
| 24 | ctx.defer().await?; | ||
| 25 | let lang = "pl"; | ||
| 26 | let tts_url = format!( | ||
| 27 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob", | ||
| 28 | urlencoding(&text), | ||
| 29 | lang | ||
| 30 | ); | ||
| 31 | |||
| 32 | let output = output.unwrap_or(OutputMode::Vc); | ||
| 33 | |||
| 34 | let audio_data = ctx | ||
| 35 | .data() | ||
| 36 | .http_client | ||
| 37 | .get(&tts_url) | ||
| 38 | .send() | ||
| 39 | .await? | ||
| 40 | .bytes() | ||
| 41 | .await?; | ||
| 42 | |||
| 43 | match output { | ||
| 44 | OutputMode::File => { | ||
| 45 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 46 | .await | ||
| 47 | .ok(); | ||
| 48 | let path = format!( | ||
| 49 | "{}/tts_{}.mp3", | ||
| 50 | crate::config::TEMP_PATH, | ||
| 51 | uuid::Uuid::new_v4() | ||
| 52 | ); | ||
| 53 | tokio::fs::write(&path, &audio_data).await?; | ||
| 54 | ctx.send( | ||
| 55 | poise::CreateReply::default() | ||
| 56 | .content(lang::L_TTS_SAVED) | ||
| 57 | .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), | ||
| 58 | ) | ||
| 59 | .await?; | ||
| 60 | } | ||
| 61 | OutputMode::Vc => { | ||
| 62 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 63 | let channel_id = ctx | ||
| 64 | .serenity_context() | ||
| 65 | .cache | ||
| 66 | .guild(guild_id) | ||
| 67 | .and_then(|g| { | ||
| 68 | g.voice_states | ||
| 69 | .get(&ctx.author().id) | ||
| 70 | .and_then(|vs| vs.channel_id) | ||
| 71 | }) | ||
| 72 | .ok_or_else(|| lang::err_not_in_voice())?; | ||
| 73 | |||
| 74 | let call = ctx | ||
| 75 | .data() | ||
| 76 | .voice_manager | ||
| 77 | .connect(guild_id, channel_id) | ||
| 78 | .await?; | ||
| 79 | let input = crate::voice::decode_audio(&audio_data)?; | ||
| 80 | let mut call_lock = call.lock().await; | ||
| 81 | if call_lock.current_channel().is_some() { | ||
| 82 | call_lock.play_input(input); | ||
| 83 | } | ||
| 84 | ctx.say(lang::L_TTS_PLAYING).await?; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | Ok(()) | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn urlencoding(s: &str) -> String { | ||
| 91 | let mut out = String::with_capacity(s.len()); | ||
| 92 | for &byte in s.as_bytes() { | ||
| 93 | match byte { | ||
| 94 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { | ||
| 95 | out.push(byte as char) | ||
| 96 | } | ||
| 97 | b' ' => out.push('+'), | ||
| 98 | _ => out.push_str(&format!("%{:02X}", byte)), | ||
| 99 | } | ||
| 100 | } | ||
| 101 | out | ||
| 102 | } | ||
| 103 | |||
| 104 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 105 | vec![tts()] | ||
| 106 | } | ||
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs new file mode 100644 index 0000000..10ce403 --- /dev/null +++ b/src/commands/utilities.rs | |||
| @@ -0,0 +1,918 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use tracing::warn; | ||
| 3 | |||
| 4 | use super::autocomplete::any_file; | ||
| 5 | use super::music::sanitize_yt_url; | ||
| 6 | use crate::Data; | ||
| 7 | use crate::copyparty::{html_decode, percent_decode}; | ||
| 8 | use crate::error::{Error, Result}; | ||
| 9 | use crate::lang; | ||
| 10 | |||
| 11 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 12 | |||
| 13 | // Above this size (or when `serwer` is forced) the result is uploaded to | ||
| 14 | // copyparty instead of being attached to the Discord reply. Discord's own | ||
| 15 | // attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python | ||
| 16 | // original and keeps replies snappy. | ||
| 17 | const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; | ||
| 18 | |||
| 19 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 20 | enum FileType { | ||
| 21 | Video, | ||
| 22 | Audio, | ||
| 23 | Image, | ||
| 24 | Unknown, | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Classify a file by its extension. | ||
| 28 | /// | ||
| 29 | /// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without | ||
| 30 | /// pulling in a mime crate: the set of extensions below covers everything the | ||
| 31 | /// original bot handled. | ||
| 32 | fn file_type(filename: &str) -> FileType { | ||
| 33 | let Some((_, ext)) = filename.rsplit_once('.') else { | ||
| 34 | return FileType::Unknown; | ||
| 35 | }; | ||
| 36 | if ext.is_empty() || ext.contains('/') { | ||
| 37 | return FileType::Unknown; | ||
| 38 | } | ||
| 39 | match ext.to_ascii_lowercase().as_str() { | ||
| 40 | "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp" | ||
| 41 | | "ts" => FileType::Video, | ||
| 42 | "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio, | ||
| 43 | "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => { | ||
| 44 | FileType::Image | ||
| 45 | } | ||
| 46 | _ => FileType::Unknown, | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Lowercased extension without the leading dot, or `None` if there is none. | ||
| 51 | fn ext_of(filename: &str) -> Option<String> { | ||
| 52 | let (_, ext) = filename.rsplit_once('.')?; | ||
| 53 | if ext.is_empty() || ext.contains('/') { | ||
| 54 | None | ||
| 55 | } else { | ||
| 56 | Some(ext.to_ascii_lowercase()) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | /// Filename stem: everything before the final dot of the basename. | ||
| 61 | fn stem_of(filename: &str) -> &str { | ||
| 62 | let base = filename.rsplit('/').next().unwrap_or(filename); | ||
| 63 | match base.rsplit_once('.') { | ||
| 64 | Some((stem, _)) if !stem.is_empty() => stem, | ||
| 65 | _ => base, | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | fn temp_path_for(name: &str) -> String { | ||
| 70 | format!("{}/{}", crate::config::TEMP_PATH, name) | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Best-effort removal of temp files; ignores missing files. | ||
| 74 | async fn cleanup(paths: &[&str]) { | ||
| 75 | for p in paths { | ||
| 76 | let _ = tokio::fs::remove_file(p).await; | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Resolve the input source — either an autocomplete-picked copyparty file or | ||
| 81 | /// a Discord attachment — downloading the bytes and returning them alongside | ||
| 82 | /// the original filename. | ||
| 83 | /// | ||
| 84 | /// Replies with an appropriate user-facing message and returns `Ok(None)` when | ||
| 85 | /// no valid source was supplied, so callers can early-return without sending a | ||
| 86 | /// second message. | ||
| 87 | async fn resolve_input( | ||
| 88 | ctx: &Context<'_>, | ||
| 89 | plik: Option<&str>, | ||
| 90 | zalacznik: Option<&serenity::Attachment>, | ||
| 91 | ) -> Result<Option<(Vec<u8>, String)>> { | ||
| 92 | if let Some(name) = plik { | ||
| 93 | // Autocomplete-selected copyparty file: the choice value is the decoded | ||
| 94 | // filename, stashed into the per-user ac_cache by `any_file`. | ||
| 95 | let hit = match ctx.data().ac_lookup(ctx.author().id, name).await { | ||
| 96 | Some(h) => h, | ||
| 97 | None => { | ||
| 98 | ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?; | ||
| 99 | return Ok(None); | ||
| 100 | } | ||
| 101 | }; | ||
| 102 | let path = hit.url_path().unwrap_or(""); | ||
| 103 | let path = if path.starts_with('/') { | ||
| 104 | path.to_string() | ||
| 105 | } else { | ||
| 106 | format!("/{path}") | ||
| 107 | }; | ||
| 108 | let full_url = ctx.data().copyparty.build_url(&path); | ||
| 109 | let resp = ctx.data().http_client.get(&full_url).send().await?; | ||
| 110 | if !resp.status().is_success() { | ||
| 111 | ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; | ||
| 112 | return Ok(None); | ||
| 113 | } | ||
| 114 | let bytes = resp.bytes().await?.to_vec(); | ||
| 115 | return Ok(Some((bytes, name.to_string()))); | ||
| 116 | } | ||
| 117 | |||
| 118 | if let Some(att) = zalacznik { | ||
| 119 | let resp = ctx.data().http_client.get(&att.url).send().await?; | ||
| 120 | if !resp.status().is_success() { | ||
| 121 | ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; | ||
| 122 | return Ok(None); | ||
| 123 | } | ||
| 124 | let bytes = resp.bytes().await?.to_vec(); | ||
| 125 | return Ok(Some((bytes, att.filename.clone()))); | ||
| 126 | } | ||
| 127 | |||
| 128 | ctx.say(lang::L_UTIL_NEED_INPUT).await?; | ||
| 129 | Ok(None) | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Output extension the compressor produces for a given input. | ||
| 133 | /// | ||
| 134 | /// - Video keeps its container extension (ffmpeg re-muxes in place). | ||
| 135 | /// - Audio is always re-encoded to Opus in an `.ogg` container. | ||
| 136 | /// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. | ||
| 137 | fn compress_output_ext(ft: FileType, original: &str) -> String { | ||
| 138 | let orig = ext_of(original); | ||
| 139 | match ft { | ||
| 140 | FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), | ||
| 141 | FileType::Audio => "ogg".to_string(), | ||
| 142 | FileType::Image => match orig.as_deref() { | ||
| 143 | Some("jpg" | "jpeg" | "gif") => orig.unwrap(), | ||
| 144 | _ => "jpg".to_string(), | ||
| 145 | }, | ||
| 146 | FileType::Unknown => String::new(), | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Build the ffmpeg/magick command for extreme-low-quality compression. | ||
| 151 | /// Returns `None` for unsupported (`Unknown`) file types. | ||
| 152 | fn build_compress_command( | ||
| 153 | ft: FileType, | ||
| 154 | input: &str, | ||
| 155 | output: &str, | ||
| 156 | ) -> Option<tokio::process::Command> { | ||
| 157 | match ft { | ||
| 158 | FileType::Video => { | ||
| 159 | let mut c = tokio::process::Command::new("ffmpeg"); | ||
| 160 | c.arg("-y") | ||
| 161 | .arg("-i") | ||
| 162 | .arg(input) | ||
| 163 | .arg("-c:v") | ||
| 164 | .arg("libx264") | ||
| 165 | .arg("-crf") | ||
| 166 | .arg("51") | ||
| 167 | .arg("-b:v") | ||
| 168 | .arg("100") | ||
| 169 | .arg("-vf") | ||
| 170 | .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2") | ||
| 171 | .arg("-preset") | ||
| 172 | .arg("ultrafast") | ||
| 173 | .arg("-c:a") | ||
| 174 | .arg("libopus") | ||
| 175 | .arg("-b:a") | ||
| 176 | .arg("500") | ||
| 177 | .arg(output); | ||
| 178 | Some(c) | ||
| 179 | } | ||
| 180 | FileType::Audio => { | ||
| 181 | let mut c = tokio::process::Command::new("ffmpeg"); | ||
| 182 | c.arg("-y") | ||
| 183 | .arg("-i") | ||
| 184 | .arg(input) | ||
| 185 | .arg("-c:a") | ||
| 186 | .arg("libopus") | ||
| 187 | .arg("-b:a") | ||
| 188 | .arg("500") | ||
| 189 | .arg("-ar") | ||
| 190 | .arg("12000") | ||
| 191 | .arg("-ac") | ||
| 192 | .arg("1") | ||
| 193 | .arg(output); | ||
| 194 | Some(c) | ||
| 195 | } | ||
| 196 | FileType::Image => { | ||
| 197 | let mut c = tokio::process::Command::new("magick"); | ||
| 198 | c.arg(input); | ||
| 199 | match ext_of(input).as_deref() { | ||
| 200 | Some("gif") => { | ||
| 201 | c.arg("-coalesce") | ||
| 202 | .arg("-resize") | ||
| 203 | .arg("30%") | ||
| 204 | .arg("-colors") | ||
| 205 | .arg("32") | ||
| 206 | .arg("-strip") | ||
| 207 | .arg("-layers") | ||
| 208 | .arg("Optimize"); | ||
| 209 | } | ||
| 210 | // jpg/jpeg stay jpg; other images are rewritten to .jpg by | ||
| 211 | // `compress_output_ext` and magick infers the encoder from the | ||
| 212 | // output extension. | ||
| 213 | _ => { | ||
| 214 | c.arg("-strip") | ||
| 215 | .arg("-resize") | ||
| 216 | .arg("30%") | ||
| 217 | .arg("-quality") | ||
| 218 | .arg("5"); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | c.arg(output); | ||
| 222 | Some(c) | ||
| 223 | } | ||
| 224 | FileType::Unknown => None, | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | enum DeliverKind { | ||
| 229 | Compress, | ||
| 230 | Convert, | ||
| 231 | } | ||
| 232 | |||
| 233 | /// Compress a single in-memory blob (an attachment body) into a compressed | ||
| 234 | /// file, returning the encoded bytes and the output filename. | ||
| 235 | /// | ||
| 236 | /// Mirrors the per-attachment path of Python's `Compression` message command, | ||
| 237 | /// including sequential `TEMP_PATH` reuse so identical attachment stems don't | ||
| 238 | /// collide (each iteration writes, encodes, then `cleanup`s before the next). | ||
| 239 | async fn compress_attachment_bytes( | ||
| 240 | ft: FileType, | ||
| 241 | original: &str, | ||
| 242 | input_bytes: &[u8], | ||
| 243 | ) -> Option<(Vec<u8>, String)> { | ||
| 244 | let stem = stem_of(original).to_string(); | ||
| 245 | let out_ext = compress_output_ext(ft, original); | ||
| 246 | let out_filename = format!("compressed_{stem}.{out_ext}"); | ||
| 247 | let input_path = temp_path_for(original); | ||
| 248 | let output_path = temp_path_for(&out_filename); | ||
| 249 | let cleanup_paths = [input_path.as_str(), output_path.as_str()]; | ||
| 250 | |||
| 251 | if tokio::fs::write(&input_path, input_bytes).await.is_err() { | ||
| 252 | return None; | ||
| 253 | } | ||
| 254 | let mut cmd = match build_compress_command(ft, &input_path, &output_path) { | ||
| 255 | Some(c) => c, | ||
| 256 | None => { | ||
| 257 | cleanup(&cleanup_paths).await; | ||
| 258 | return None; | ||
| 259 | } | ||
| 260 | }; | ||
| 261 | let res = match cmd.output().await { | ||
| 262 | Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), | ||
| 263 | _ => None, | ||
| 264 | }; | ||
| 265 | cleanup(&cleanup_paths).await; | ||
| 266 | res.map(|b| (b, out_filename)) | ||
| 267 | } | ||
| 268 | |||
| 269 | /// Strip characters that Discord/coping filenames can't safely carry. Mirrors | ||
| 270 | /// Python's `Downloader._sanitize_filename`. | ||
| 271 | fn sanitize_filename(name: &str) -> String { | ||
| 272 | const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; | ||
| 273 | name.chars() | ||
| 274 | .filter(|c| !BAD.contains(c)) | ||
| 275 | .collect::<String>() | ||
| 276 | .trim() | ||
| 277 | .to_string() | ||
| 278 | } | ||
| 279 | |||
| 280 | /// Extract `https://` URLs containing a path (host + `/...`) from a message. | ||
| 281 | /// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the | ||
| 282 | /// message-context `download` command — only URLs with at least one `/` after | ||
| 283 | /// the host are matched, and `http://` links are intentionally ignored. | ||
| 284 | fn extract_urls(content: &str) -> Vec<String> { | ||
| 285 | let bytes = content.as_bytes(); | ||
| 286 | let mut urls = Vec::new(); | ||
| 287 | let mut i = 0; | ||
| 288 | while let Some(pos) = content[i..].find("https://") { | ||
| 289 | let start = i + pos; | ||
| 290 | let after = start + "https://".len(); | ||
| 291 | |||
| 292 | // Consume the host (until `/` or whitespace). | ||
| 293 | let mut j = after; | ||
| 294 | while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' { | ||
| 295 | j += 1; | ||
| 296 | } | ||
| 297 | |||
| 298 | if j < bytes.len() && bytes[j] == b'/' { | ||
| 299 | // Consume the rest of the URL (until whitespace). | ||
| 300 | let mut k = j; | ||
| 301 | while k < bytes.len() && !bytes[k].is_ascii_whitespace() { | ||
| 302 | k += 1; | ||
| 303 | } | ||
| 304 | urls.push(content[start..k].to_string()); | ||
| 305 | i = k; | ||
| 306 | } else { | ||
| 307 | // No path component — not matched by the regex; advance past it. | ||
| 308 | i = after; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | urls | ||
| 312 | } | ||
| 313 | |||
| 314 | /// Locate the single file yt-dlp produced in `dir`, picking the most recently | ||
| 315 | /// modified one (matches Python's `os.listdir`-first fallback behaviour while | ||
| 316 | /// being deterministic enough for the per-call unique subdir). | ||
| 317 | async fn find_downloaded_file(dir: &str) -> Option<(String, String)> { | ||
| 318 | let mut rd = tokio::fs::read_dir(dir).await.ok()?; | ||
| 319 | let mut newest: Option<(std::time::SystemTime, String)> = None; | ||
| 320 | while let Ok(Some(entry)) = rd.next_entry().await { | ||
| 321 | let meta = match entry.metadata().await { | ||
| 322 | Ok(m) => m, | ||
| 323 | Err(_) => continue, | ||
| 324 | }; | ||
| 325 | if !meta.is_file() { | ||
| 326 | continue; | ||
| 327 | } | ||
| 328 | let mtime = match meta.modified() { | ||
| 329 | Ok(t) => t, | ||
| 330 | Err(_) => continue, | ||
| 331 | }; | ||
| 332 | if let Some((t, _)) = &newest { | ||
| 333 | if *t >= mtime { | ||
| 334 | continue; | ||
| 335 | } | ||
| 336 | } | ||
| 337 | newest = Some((mtime, entry.path().to_string_lossy().into_owned())); | ||
| 338 | } | ||
| 339 | newest.map(|(_, p)| { | ||
| 340 | let fname = std::path::Path::new(&p) | ||
| 341 | .file_name() | ||
| 342 | .map(|s| s.to_string_lossy().into_owned()) | ||
| 343 | .unwrap_or_default(); | ||
| 344 | (p, fname) | ||
| 345 | }) | ||
| 346 | } | ||
| 347 | |||
| 348 | /// Hand the produced download back to the user: attach when below the size | ||
| 349 | /// threshold (and upload isn't forced), otherwise push to copyparty and reply | ||
| 350 | /// with the download URL. Mirrors Python's `Downloader` slash `/download`. | ||
| 351 | async fn deliver_download( | ||
| 352 | ctx: &Context<'_>, | ||
| 353 | bytes: Vec<u8>, | ||
| 354 | filename: String, | ||
| 355 | catbox: bool, | ||
| 356 | ) -> Result<()> { | ||
| 357 | if bytes.len() > LARGE_FILE_THRESHOLD || catbox { | ||
| 358 | match ctx.data().copyparty.upload(&filename, bytes).await { | ||
| 359 | Ok(url) => { | ||
| 360 | ctx.say(lang::fmt_download_uploaded(&url)).await?; | ||
| 361 | } | ||
| 362 | Err(e) => { | ||
| 363 | tracing::warn!("copyparty upload failed: {e}"); | ||
| 364 | ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; | ||
| 365 | } | ||
| 366 | } | ||
| 367 | } else { | ||
| 368 | ctx.send( | ||
| 369 | poise::CreateReply::default() | ||
| 370 | .attachment(serenity::CreateAttachment::bytes(bytes, filename)), | ||
| 371 | ) | ||
| 372 | .await?; | ||
| 373 | } | ||
| 374 | Ok(()) | ||
| 375 | } | ||
| 376 | |||
| 377 | /// Run yt-dlp on `url` and deliver the resulting file. The caller is expected | ||
| 378 | /// to have already called `ctx.defer()`. Each invocation uses a fresh unique | ||
| 379 | /// subdirectory under `TEMP_PATH` (matching Python's per-call | ||
| 380 | /// `tempfile.TemporaryDirectory`) so concurrent downloads never collide. | ||
| 381 | async fn run_download( | ||
| 382 | ctx: &Context<'_>, | ||
| 383 | url: &str, | ||
| 384 | catbox: bool, | ||
| 385 | fformat: Option<&str>, | ||
| 386 | filename_user: Option<&str>, | ||
| 387 | ) -> Result<()> { | ||
| 388 | let fmt = fformat.unwrap_or("mp4"); | ||
| 389 | let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); | ||
| 390 | let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4()); | ||
| 391 | tokio::fs::create_dir_all(&dl_dir).await.ok(); | ||
| 392 | |||
| 393 | let out_template = match filename_user { | ||
| 394 | Some(name) => { | ||
| 395 | // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext. | ||
| 396 | let clean: String = sanitize_filename(name).chars().take(100).collect(); | ||
| 397 | format!("{dl_dir}/{clean}.%(ext)s") | ||
| 398 | } | ||
| 399 | None => format!("{dl_dir}/%(title).100s.%(ext)s"), | ||
| 400 | }; | ||
| 401 | |||
| 402 | let mut cmd = tokio::process::Command::new("yt-dlp"); | ||
| 403 | cmd.arg("-f") | ||
| 404 | .arg("bestaudio/bestvideo+bestaudio") | ||
| 405 | .arg("--merge-output-format") | ||
| 406 | .arg("mp4") | ||
| 407 | .arg("-o") | ||
| 408 | .arg(&out_template); | ||
| 409 | if is_audio { | ||
| 410 | cmd.arg("--extract-audio").arg("--audio-format").arg(fmt); | ||
| 411 | } | ||
| 412 | cmd.arg(sanitize_yt_url(url)); | ||
| 413 | |||
| 414 | let output = cmd | ||
| 415 | .output() | ||
| 416 | .await | ||
| 417 | .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?; | ||
| 418 | if !output.status.success() { | ||
| 419 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 420 | // Keep the full yt-dlp trace in the logs; the user only sees the | ||
| 421 | // short `fmt_download_failed` (translated) message. | ||
| 422 | warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); | ||
| 423 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; | ||
| 424 | ctx.say(lang::fmt_download_failed(&stderr)).await?; | ||
| 425 | return Ok(()); | ||
| 426 | } | ||
| 427 | |||
| 428 | let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else { | ||
| 429 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; | ||
| 430 | ctx.say(lang::fmt_download_failed("no file produced")) | ||
| 431 | .await?; | ||
| 432 | return Ok(()); | ||
| 433 | }; | ||
| 434 | |||
| 435 | // Determine the display filename for the upload/attachment. | ||
| 436 | let final_name = match filename_user { | ||
| 437 | Some(name) => { | ||
| 438 | let clean: String = sanitize_filename(name).chars().take(100).collect(); | ||
| 439 | let ext = std::path::Path::new(&basename) | ||
| 440 | .extension() | ||
| 441 | .map(|e| format!(".{}", e.to_string_lossy())) | ||
| 442 | .unwrap_or_default(); | ||
| 443 | format!("{clean}{ext}") | ||
| 444 | } | ||
| 445 | None => basename, | ||
| 446 | }; | ||
| 447 | |||
| 448 | let bytes = match tokio::fs::read(&actual_path).await { | ||
| 449 | Ok(b) => b, | ||
| 450 | Err(e) => { | ||
| 451 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; | ||
| 452 | ctx.say(lang::fmt_download_failed(&e.to_string())).await?; | ||
| 453 | return Ok(()); | ||
| 454 | } | ||
| 455 | }; | ||
| 456 | |||
| 457 | let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await; | ||
| 458 | let _ = tokio::fs::remove_dir_all(&dl_dir).await; | ||
| 459 | deliver_result | ||
| 460 | } | ||
| 461 | |||
| 462 | /// Hand the produced bytes back to the user. | ||
| 463 | /// | ||
| 464 | /// Below the size threshold (and when upload isn't forced) the result is | ||
| 465 | /// attached directly to the reply. Otherwise it's pushed to copyparty and the | ||
| 466 | /// reply contains the download URL. Upload failures are reported gracefully | ||
| 467 | /// rather than propagated, matching the Python original. | ||
| 468 | async fn deliver( | ||
| 469 | ctx: &Context<'_>, | ||
| 470 | bytes: Vec<u8>, | ||
| 471 | filename: String, | ||
| 472 | force_upload: bool, | ||
| 473 | kind: DeliverKind, | ||
| 474 | ) -> Result<()> { | ||
| 475 | if bytes.len() > LARGE_FILE_THRESHOLD || force_upload { | ||
| 476 | match ctx.data().copyparty.upload(&filename, bytes).await { | ||
| 477 | Ok(url) => { | ||
| 478 | let msg = match kind { | ||
| 479 | DeliverKind::Compress => lang::fmt_util_compressed(&url), | ||
| 480 | DeliverKind::Convert => lang::fmt_util_converted(&url), | ||
| 481 | }; | ||
| 482 | ctx.say(msg).await?; | ||
| 483 | } | ||
| 484 | Err(e) => { | ||
| 485 | tracing::warn!("copyparty upload failed: {e}"); | ||
| 486 | ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; | ||
| 487 | } | ||
| 488 | } | ||
| 489 | } else { | ||
| 490 | ctx.send( | ||
| 491 | poise::CreateReply::default() | ||
| 492 | .attachment(serenity::CreateAttachment::bytes(bytes, filename)), | ||
| 493 | ) | ||
| 494 | .await?; | ||
| 495 | } | ||
| 496 | Ok(()) | ||
| 497 | } | ||
| 498 | |||
| 499 | /// Compress media to extreme low quality | ||
| 500 | #[poise::command(slash_command, aliases("kompresja"))] | ||
| 501 | pub async fn compress( | ||
| 502 | ctx: Context<'_>, | ||
| 503 | #[description = "File from Copyparty"] | ||
| 504 | #[autocomplete = "any_file"] | ||
| 505 | plik: Option<String>, | ||
| 506 | #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>, | ||
| 507 | #[description = "Force upload to Copyparty"] serwer: Option<bool>, | ||
| 508 | ) -> Result<()> { | ||
| 509 | ctx.defer().await?; | ||
| 510 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 511 | .await | ||
| 512 | .ok(); | ||
| 513 | |||
| 514 | let (input_bytes, original_filename) = | ||
| 515 | match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { | ||
| 516 | Some(v) => v, | ||
| 517 | None => return Ok(()), | ||
| 518 | }; | ||
| 519 | |||
| 520 | let ft = file_type(&original_filename); | ||
| 521 | let stem = stem_of(&original_filename).to_string(); | ||
| 522 | let out_ext = compress_output_ext(ft, &original_filename); | ||
| 523 | let out_filename = format!("compressed_{stem}.{out_ext}"); | ||
| 524 | let input_path = temp_path_for(&original_filename); | ||
| 525 | let output_path = temp_path_for(&out_filename); | ||
| 526 | let cleanup_paths = [input_path.as_str(), output_path.as_str()]; | ||
| 527 | |||
| 528 | if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { | ||
| 529 | tracing::warn!("compress: failed to write input: {e}"); | ||
| 530 | ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; | ||
| 531 | return Ok(()); | ||
| 532 | } | ||
| 533 | |||
| 534 | let result = match build_compress_command(ft, &input_path, &output_path) { | ||
| 535 | Some(mut cmd) => cmd.output().await, | ||
| 536 | None => { | ||
| 537 | cleanup(&cleanup_paths).await; | ||
| 538 | ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; | ||
| 539 | return Ok(()); | ||
| 540 | } | ||
| 541 | }; | ||
| 542 | |||
| 543 | match result { | ||
| 544 | Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { | ||
| 545 | Ok(out_bytes) => { | ||
| 546 | cleanup(&cleanup_paths).await; | ||
| 547 | deliver( | ||
| 548 | &ctx, | ||
| 549 | out_bytes, | ||
| 550 | out_filename, | ||
| 551 | serwer.unwrap_or(false), | ||
| 552 | DeliverKind::Compress, | ||
| 553 | ) | ||
| 554 | .await?; | ||
| 555 | } | ||
| 556 | Err(e) => { | ||
| 557 | tracing::warn!("compress: output missing after encode: {e}"); | ||
| 558 | cleanup(&cleanup_paths).await; | ||
| 559 | ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; | ||
| 560 | } | ||
| 561 | }, | ||
| 562 | _ => { | ||
| 563 | cleanup(&cleanup_paths).await; | ||
| 564 | ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; | ||
| 565 | } | ||
| 566 | } | ||
| 567 | Ok(()) | ||
| 568 | } | ||
| 569 | |||
| 570 | /// Convert media between formats | ||
| 571 | #[poise::command(slash_command, aliases("konwersja"))] | ||
| 572 | pub async fn convert( | ||
| 573 | ctx: Context<'_>, | ||
| 574 | #[description = "File from Copyparty"] | ||
| 575 | #[autocomplete = "any_file"] | ||
| 576 | plik: Option<String>, | ||
| 577 | #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>, | ||
| 578 | #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String, | ||
| 579 | #[description = "Force upload to Copyparty"] serwer: Option<bool>, | ||
| 580 | ) -> Result<()> { | ||
| 581 | ctx.defer().await?; | ||
| 582 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 583 | .await | ||
| 584 | .ok(); | ||
| 585 | |||
| 586 | let (input_bytes, original_filename) = | ||
| 587 | match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { | ||
| 588 | Some(v) => v, | ||
| 589 | None => return Ok(()), | ||
| 590 | }; | ||
| 591 | |||
| 592 | // Normalise the requested extension: lowercase, no leading dot, and reject | ||
| 593 | // anything containing path separators or extra dots — this keeps it a | ||
| 594 | // single clean extension component and rules out escaping TEMP_PATH. | ||
| 595 | let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase(); | ||
| 596 | if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') { | ||
| 597 | ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; | ||
| 598 | return Ok(()); | ||
| 599 | } | ||
| 600 | |||
| 601 | let ft = file_type(&original_filename); | ||
| 602 | let stem = stem_of(&original_filename).to_string(); | ||
| 603 | let out_filename = format!("{stem}.{fmt}"); | ||
| 604 | let input_path = temp_path_for(&original_filename); | ||
| 605 | let output_path = temp_path_for(&out_filename); | ||
| 606 | let cleanup_paths = [input_path.as_str(), output_path.as_str()]; | ||
| 607 | |||
| 608 | if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { | ||
| 609 | tracing::warn!("convert: failed to write input: {e}"); | ||
| 610 | ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; | ||
| 611 | return Ok(()); | ||
| 612 | } | ||
| 613 | |||
| 614 | let mut cmd = match ft { | ||
| 615 | FileType::Video | FileType::Audio => { | ||
| 616 | let mut c = tokio::process::Command::new("ffmpeg"); | ||
| 617 | c.arg("-y").arg("-i").arg(&input_path).arg(&output_path); | ||
| 618 | c | ||
| 619 | } | ||
| 620 | FileType::Image => { | ||
| 621 | let mut c = tokio::process::Command::new("magick"); | ||
| 622 | c.arg(&input_path).arg(&output_path); | ||
| 623 | c | ||
| 624 | } | ||
| 625 | FileType::Unknown => { | ||
| 626 | cleanup(&cleanup_paths).await; | ||
| 627 | ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; | ||
| 628 | return Ok(()); | ||
| 629 | } | ||
| 630 | }; | ||
| 631 | |||
| 632 | match cmd.output().await { | ||
| 633 | Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { | ||
| 634 | Ok(out_bytes) => { | ||
| 635 | cleanup(&cleanup_paths).await; | ||
| 636 | deliver( | ||
| 637 | &ctx, | ||
| 638 | out_bytes, | ||
| 639 | out_filename, | ||
| 640 | serwer.unwrap_or(false), | ||
| 641 | DeliverKind::Convert, | ||
| 642 | ) | ||
| 643 | .await?; | ||
| 644 | } | ||
| 645 | Err(e) => { | ||
| 646 | tracing::warn!("convert: output missing after encode: {e}"); | ||
| 647 | cleanup(&cleanup_paths).await; | ||
| 648 | ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; | ||
| 649 | } | ||
| 650 | }, | ||
| 651 | Ok(out) => { | ||
| 652 | let stderr = String::from_utf8_lossy(&out.stderr); | ||
| 653 | cleanup(&cleanup_paths).await; | ||
| 654 | ctx.say(lang::fmt_util_convert_err(&stderr)).await?; | ||
| 655 | } | ||
| 656 | Err(e) => { | ||
| 657 | cleanup(&cleanup_paths).await; | ||
| 658 | ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?; | ||
| 659 | } | ||
| 660 | } | ||
| 661 | Ok(()) | ||
| 662 | } | ||
| 663 | |||
| 664 | /// Upload files from Discord to Copyparty | ||
| 665 | #[poise::command(slash_command)] | ||
| 666 | pub async fn upload( | ||
| 667 | ctx: Context<'_>, | ||
| 668 | #[description = "File to upload"] zalacznik: serenity::Attachment, | ||
| 669 | ) -> Result<()> { | ||
| 670 | ctx.defer().await?; | ||
| 671 | |||
| 672 | let data = ctx | ||
| 673 | .data() | ||
| 674 | .http_client | ||
| 675 | .get(&zalacznik.url) | ||
| 676 | .send() | ||
| 677 | .await? | ||
| 678 | .bytes() | ||
| 679 | .await?; | ||
| 680 | let file_url = ctx | ||
| 681 | .data() | ||
| 682 | .copyparty | ||
| 683 | .upload(&zalacznik.filename, data.to_vec()) | ||
| 684 | .await?; | ||
| 685 | ctx.say(lang::fmt_uploaded(&file_url)).await?; | ||
| 686 | Ok(()) | ||
| 687 | } | ||
| 688 | |||
| 689 | /// Search Copyparty file server and return a direct URL | ||
| 690 | #[poise::command(slash_command)] | ||
| 691 | pub async fn search( | ||
| 692 | ctx: Context<'_>, | ||
| 693 | #[description = "Partial filename to search"] | ||
| 694 | #[autocomplete = "any_file"] | ||
| 695 | filename: String, | ||
| 696 | ) -> Result<()> { | ||
| 697 | // Autocomplete cache: per-user (UserId, filename) → SearchHit. | ||
| 698 | if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await { | ||
| 699 | let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename))); | ||
| 700 | let path = hit.url_path().unwrap_or(""); | ||
| 701 | let path = if path.starts_with('/') { | ||
| 702 | path.to_string() | ||
| 703 | } else { | ||
| 704 | format!("/{path}") | ||
| 705 | }; | ||
| 706 | let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); | ||
| 707 | ctx.say(lang::fmt_search_result(&name, &full_url)).await?; | ||
| 708 | return Ok(()); | ||
| 709 | } | ||
| 710 | |||
| 711 | // Direct copyparty path from manual input (starts with /) | ||
| 712 | if filename.starts_with('/') { | ||
| 713 | let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename); | ||
| 714 | ctx.say(lang::fmt_search_result(&filename, &full_url)) | ||
| 715 | .await?; | ||
| 716 | return Ok(()); | ||
| 717 | } | ||
| 718 | |||
| 719 | let results = ctx.data().copyparty.search(&filename, 10, &[]).await?; | ||
| 720 | |||
| 721 | if results.is_empty() { | ||
| 722 | ctx.say(lang::L_SEARCH_NO_RESULTS).await?; | ||
| 723 | return Ok(()); | ||
| 724 | } | ||
| 725 | |||
| 726 | let hit = &results[0]; | ||
| 727 | let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown"))); | ||
| 728 | let path = hit.url_path().unwrap_or(""); | ||
| 729 | let path = if path.starts_with('/') { | ||
| 730 | path.to_string() | ||
| 731 | } else { | ||
| 732 | format!("/{path}") | ||
| 733 | }; | ||
| 734 | let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); | ||
| 735 | |||
| 736 | ctx.say(lang::fmt_search_result(&name, &full_url)).await?; | ||
| 737 | Ok(()) | ||
| 738 | } | ||
| 739 | |||
| 740 | /// Download media from any yt-dlp-supported URL | ||
| 741 | #[poise::command(slash_command)] | ||
| 742 | pub async fn download( | ||
| 743 | ctx: Context<'_>, | ||
| 744 | #[description = "Media URL"] url: String, | ||
| 745 | #[description = "Force upload to Copyparty"] catbox: Option<bool>, | ||
| 746 | #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option<String>, | ||
| 747 | #[description = "Custom output filename"] filename_user: Option<String>, | ||
| 748 | ) -> Result<()> { | ||
| 749 | ctx.defer().await?; | ||
| 750 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 751 | .await | ||
| 752 | .ok(); | ||
| 753 | run_download( | ||
| 754 | &ctx, | ||
| 755 | &url, | ||
| 756 | catbox.unwrap_or(false), | ||
| 757 | fformat.as_deref(), | ||
| 758 | filename_user.as_deref(), | ||
| 759 | ) | ||
| 760 | .await | ||
| 761 | } | ||
| 762 | |||
| 763 | /// Right-click a message → upload its attachments to Copyparty | ||
| 764 | /// (mirrors Python's `@commands.message_command(name="upload")`). | ||
| 765 | /// | ||
| 766 | /// Display name is not `"upload"` because poise's dispatcher matches commands | ||
| 767 | /// by name alone without considering `CommandType`, so a context-menu named | ||
| 768 | /// `"upload"` would shadow — and be shadowed by — the slash `/upload` command | ||
| 769 | /// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"` | ||
| 770 | /// avoids the collision while keeping the Polish user-facing label. | ||
| 771 | #[poise::command(context_menu_command = "Wyślij załączniki")] | ||
| 772 | pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> { | ||
| 773 | if message.attachments.is_empty() { | ||
| 774 | ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; | ||
| 775 | return Ok(()); | ||
| 776 | } | ||
| 777 | ctx.defer().await?; | ||
| 778 | |||
| 779 | let mut urls: Vec<String> = Vec::new(); | ||
| 780 | let mut failed = 0usize; | ||
| 781 | for att in &message.attachments { | ||
| 782 | let resp = match ctx.data().http_client.get(&att.url).send().await { | ||
| 783 | Ok(r) if r.status().is_success() => r, | ||
| 784 | _ => { | ||
| 785 | failed += 1; | ||
| 786 | continue; | ||
| 787 | } | ||
| 788 | }; | ||
| 789 | let bytes = resp.bytes().await?.to_vec(); | ||
| 790 | match ctx.data().copyparty.upload(&att.filename, bytes).await { | ||
| 791 | Ok(url) => urls.push(url), | ||
| 792 | Err(e) => { | ||
| 793 | tracing::warn!("upload: copyparty failed for {}: {e}", att.filename); | ||
| 794 | failed += 1; | ||
| 795 | } | ||
| 796 | } | ||
| 797 | } | ||
| 798 | |||
| 799 | if urls.is_empty() { | ||
| 800 | ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; | ||
| 801 | return Ok(()); | ||
| 802 | } | ||
| 803 | |||
| 804 | let mut resp = lang::fmt_upload_summary(urls.len()); | ||
| 805 | for u in &urls { | ||
| 806 | resp.push_str(u); | ||
| 807 | resp.push('\n'); | ||
| 808 | } | ||
| 809 | if failed > 0 { | ||
| 810 | resp.push_str(&lang::fmt_upload_partial_failed(failed)); | ||
| 811 | } | ||
| 812 | ctx.say(resp).await?; | ||
| 813 | Ok(()) | ||
| 814 | } | ||
| 815 | |||
| 816 | /// Right-click a message → compress all of its attachments | ||
| 817 | /// (mirrors Python's `@commands.message_command(name="kompresja")`). | ||
| 818 | #[poise::command(context_menu_command = "kompresja")] | ||
| 819 | pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> { | ||
| 820 | if message.attachments.is_empty() { | ||
| 821 | ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; | ||
| 822 | return Ok(()); | ||
| 823 | } | ||
| 824 | ctx.defer().await?; | ||
| 825 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 826 | .await | ||
| 827 | .ok(); | ||
| 828 | |||
| 829 | let total = message.attachments.len(); | ||
| 830 | let mut attachments_out: Vec<serenity::CreateAttachment> = Vec::new(); | ||
| 831 | let mut made = 0usize; | ||
| 832 | |||
| 833 | for att in &message.attachments { | ||
| 834 | let resp = match ctx.data().http_client.get(&att.url).send().await { | ||
| 835 | Ok(r) if r.status().is_success() => r, | ||
| 836 | _ => continue, | ||
| 837 | }; | ||
| 838 | let bytes = match resp.bytes().await { | ||
| 839 | Ok(b) => b.to_vec(), | ||
| 840 | Err(_) => continue, | ||
| 841 | }; | ||
| 842 | let ft = file_type(&att.filename); | ||
| 843 | if let Some((out_bytes, out_name)) = | ||
| 844 | compress_attachment_bytes(ft, &att.filename, &bytes).await | ||
| 845 | { | ||
| 846 | attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name)); | ||
| 847 | made += 1; | ||
| 848 | } | ||
| 849 | } | ||
| 850 | |||
| 851 | if attachments_out.is_empty() { | ||
| 852 | ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; | ||
| 853 | return Ok(()); | ||
| 854 | } | ||
| 855 | |||
| 856 | let mut reply = | ||
| 857 | poise::CreateReply::default().content(lang::fmt_message_compressed(made, total)); | ||
| 858 | for a in attachments_out { | ||
| 859 | reply = reply.attachment(a); | ||
| 860 | } | ||
| 861 | ctx.send(reply).await?; | ||
| 862 | Ok(()) | ||
| 863 | } | ||
| 864 | |||
| 865 | /// Right-click a message → download every `https://` URL it contains | ||
| 866 | /// (mirrors Python's `@commands.message_command(name="download")`). | ||
| 867 | /// | ||
| 868 | /// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with | ||
| 869 | /// the slash `/download` command in poise's name-only dispatcher (see the note | ||
| 870 | /// on `message_upload`). | ||
| 871 | #[poise::command(context_menu_command = "Pobierz z linku")] | ||
| 872 | pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> { | ||
| 873 | let urls = extract_urls(&message.content); | ||
| 874 | if urls.is_empty() { | ||
| 875 | // Faithful to the Python original, which reuses the attachments-missing | ||
| 876 | // string for the no-URL case. | ||
| 877 | ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; | ||
| 878 | return Ok(()); | ||
| 879 | } | ||
| 880 | ctx.defer().await?; | ||
| 881 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | ||
| 882 | .await | ||
| 883 | .ok(); | ||
| 884 | |||
| 885 | for url in urls { | ||
| 886 | if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await { | ||
| 887 | tracing::warn!("download runner error for {url}: {e}"); | ||
| 888 | } | ||
| 889 | } | ||
| 890 | Ok(()) | ||
| 891 | } | ||
| 892 | |||
| 893 | /// Echo text to channel | ||
| 894 | #[poise::command(slash_command)] | ||
| 895 | pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> { | ||
| 896 | ctx.say(&text).await?; | ||
| 897 | ctx.send( | ||
| 898 | poise::CreateReply::default() | ||
| 899 | .content("\u{200b}") | ||
| 900 | .ephemeral(true), | ||
| 901 | ) | ||
| 902 | .await?; | ||
| 903 | Ok(()) | ||
| 904 | } | ||
| 905 | |||
| 906 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 907 | vec![ | ||
| 908 | compress(), | ||
| 909 | convert(), | ||
| 910 | upload(), | ||
| 911 | search(), | ||
| 912 | download(), | ||
| 913 | say(), | ||
| 914 | message_upload(), | ||
| 915 | message_compression(), | ||
| 916 | message_download(), | ||
| 917 | ] | ||
| 918 | } | ||
diff --git a/src/commands/web.rs b/src/commands/web.rs new file mode 100644 index 0000000..d68f663 --- /dev/null +++ b/src/commands/web.rs | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use scraper::{Html, Selector}; | ||
| 3 | |||
| 4 | use crate::Data; | ||
| 5 | use crate::error::{Error, Result}; | ||
| 6 | use crate::lang; | ||
| 7 | |||
| 8 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 9 | |||
| 10 | /// Search the web via DuckDuckGo | ||
| 11 | #[poise::command(slash_command)] | ||
| 12 | pub async fn web(ctx: Context<'_>, #[description = "Search query"] query: String) -> Result<()> { | ||
| 13 | ctx.defer().await?; | ||
| 14 | |||
| 15 | let url = format!( | ||
| 16 | "https://html.duckduckgo.com/html/?q={}", | ||
| 17 | urlencoding(&query) | ||
| 18 | ); | ||
| 19 | let html = ctx | ||
| 20 | .data() | ||
| 21 | .http_client | ||
| 22 | .get(&url) | ||
| 23 | .send() | ||
| 24 | .await? | ||
| 25 | .text() | ||
| 26 | .await?; | ||
| 27 | |||
| 28 | let results = { | ||
| 29 | let document = Html::parse_document(&html); | ||
| 30 | |||
| 31 | let result_selector = Selector::parse(".result").unwrap(); | ||
| 32 | let title_selector = Selector::parse(".result__title a").unwrap(); | ||
| 33 | let snippet_selector = Selector::parse(".result__snippet").unwrap(); | ||
| 34 | |||
| 35 | let mut results = Vec::new(); | ||
| 36 | for result in document.select(&result_selector).take(5) { | ||
| 37 | let title = result | ||
| 38 | .select(&title_selector) | ||
| 39 | .next() | ||
| 40 | .map(|a| a.text().collect::<String>()) | ||
| 41 | .unwrap_or_default(); | ||
| 42 | |||
| 43 | let url = result | ||
| 44 | .select(&title_selector) | ||
| 45 | .next() | ||
| 46 | .and_then(|a| a.attr("href")) | ||
| 47 | .unwrap_or("") | ||
| 48 | .to_string(); | ||
| 49 | |||
| 50 | let snippet = result | ||
| 51 | .select(&snippet_selector) | ||
| 52 | .next() | ||
| 53 | .map(|s| s.text().collect::<String>()) | ||
| 54 | .unwrap_or_default(); | ||
| 55 | |||
| 56 | let clean_url = if let Some(encoded) = url.split("uddg=").nth(1) { | ||
| 57 | let encoded = encoded.split('&').next().unwrap_or(encoded); | ||
| 58 | url_decode(encoded) | ||
| 59 | } else { | ||
| 60 | url.to_string() | ||
| 61 | }; | ||
| 62 | |||
| 63 | results.push((title, clean_url, snippet)); | ||
| 64 | } | ||
| 65 | results | ||
| 66 | }; | ||
| 67 | |||
| 68 | if results.is_empty() { | ||
| 69 | ctx.say(lang::L_WEB_NO_RESULTS).await?; | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 72 | |||
| 73 | let mut embed = serenity::CreateEmbed::default() | ||
| 74 | .title(lang::fmt_web_title(&query)) | ||
| 75 | .color(0x00aaff); | ||
| 76 | |||
| 77 | for (i, (title, url, snippet)) in results.iter().enumerate() { | ||
| 78 | embed = embed.field( | ||
| 79 | format!("{}. {title}", i + 1), | ||
| 80 | format!("{url}\n{snippet}"), | ||
| 81 | false, | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | |||
| 85 | ctx.send(poise::CreateReply::default().embed(embed)).await?; | ||
| 86 | Ok(()) | ||
| 87 | } | ||
| 88 | |||
| 89 | fn url_decode(s: &str) -> String { | ||
| 90 | let mut result = String::new(); | ||
| 91 | let mut chars = s.chars(); | ||
| 92 | while let Some(c) = chars.next() { | ||
| 93 | match c { | ||
| 94 | '+' => result.push(' '), | ||
| 95 | '%' => { | ||
| 96 | let hi = chars.next().and_then(|c| c.to_digit(16)); | ||
| 97 | let lo = chars.next().and_then(|c| c.to_digit(16)); | ||
| 98 | if let (Some(hi), Some(lo)) = (hi, lo) { | ||
| 99 | result.push(char::from_u32((hi << 4) | lo).unwrap_or('�')); | ||
| 100 | } else { | ||
| 101 | result.push('%'); | ||
| 102 | } | ||
| 103 | } | ||
| 104 | _ => result.push(c), | ||
| 105 | } | ||
| 106 | } | ||
| 107 | result | ||
| 108 | } | ||
| 109 | |||
| 110 | fn urlencoding(s: &str) -> String { | ||
| 111 | s.chars() | ||
| 112 | .map(|c| match c { | ||
| 113 | 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '~' => c.to_string(), | ||
| 114 | ' ' => "+".to_string(), | ||
| 115 | _ => format!("%{:02X}", c as u8), | ||
| 116 | }) | ||
| 117 | .collect() | ||
| 118 | } | ||
| 119 | |||
| 120 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 121 | vec![web()] | ||
| 122 | } | ||
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..96a6849 --- /dev/null +++ b/src/config.rs | |||
| @@ -0,0 +1,145 @@ | |||
| 1 | use std::env; | ||
| 2 | |||
| 3 | pub const TEMP_PATH: &str = "/tmp/senator_sperma"; | ||
| 4 | |||
| 5 | #[derive(Debug, Clone)] | ||
| 6 | pub struct Config { | ||
| 7 | pub token: String, | ||
| 8 | pub status_channel_id: Option<u64>, | ||
| 9 | pub assistant_keyword: String, | ||
| 10 | pub copyparty_url: String, | ||
| 11 | pub copyparty_url_upload: String, | ||
| 12 | pub copyparty_password: Option<String>, | ||
| 13 | pub openrouter_api_key: Option<String>, | ||
| 14 | pub openrouter_model: Option<String>, | ||
| 15 | pub database_path: String, | ||
| 16 | } | ||
| 17 | |||
| 18 | struct EnvVar { | ||
| 19 | name: &'static str, | ||
| 20 | required: bool, | ||
| 21 | default: Option<&'static str>, | ||
| 22 | description: &'static str, | ||
| 23 | } | ||
| 24 | |||
| 25 | const ENV_VARS: &[EnvVar] = &[ | ||
| 26 | EnvVar { | ||
| 27 | name: "TOKEN", | ||
| 28 | required: true, | ||
| 29 | default: None, | ||
| 30 | description: "Discord bot token", | ||
| 31 | }, | ||
| 32 | EnvVar { | ||
| 33 | name: "STATUS_CHANNEL_ID", | ||
| 34 | required: false, | ||
| 35 | default: None, | ||
| 36 | description: "status channel ID", | ||
| 37 | }, | ||
| 38 | EnvVar { | ||
| 39 | name: "ASSISTANT_KEYWORD", | ||
| 40 | required: false, | ||
| 41 | default: Some("jarvis"), | ||
| 42 | description: "keyword for AI assistant", | ||
| 43 | }, | ||
| 44 | EnvVar { | ||
| 45 | name: "COPYPARTY_URL", | ||
| 46 | required: false, | ||
| 47 | default: Some("http://localhost:3939"), | ||
| 48 | description: "Copyparty base URL", | ||
| 49 | }, | ||
| 50 | EnvVar { | ||
| 51 | name: "COPYPARTY_URL_UPLOAD", | ||
| 52 | required: false, | ||
| 53 | default: Some("http://localhost:3939"), | ||
| 54 | description: "Copyparty upload URL", | ||
| 55 | }, | ||
| 56 | EnvVar { | ||
| 57 | name: "COPYPARTY_PASSWORD", | ||
| 58 | required: false, | ||
| 59 | default: None, | ||
| 60 | description: "Copyparty auth password", | ||
| 61 | }, | ||
| 62 | EnvVar { | ||
| 63 | name: "OPENROUTER_API_KEY", | ||
| 64 | required: false, | ||
| 65 | default: None, | ||
| 66 | description: "OpenRouter API key (disables AI if missing)", | ||
| 67 | }, | ||
| 68 | EnvVar { | ||
| 69 | name: "OPENROUTER_MODEL", | ||
| 70 | required: false, | ||
| 71 | default: Some("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"), | ||
| 72 | description: "OpenRouter model", | ||
| 73 | }, | ||
| 74 | EnvVar { | ||
| 75 | name: "SENATOR_DB_PATH", | ||
| 76 | required: false, | ||
| 77 | default: Some("data/senator.db"), | ||
| 78 | description: "SQLite database path", | ||
| 79 | }, | ||
| 80 | EnvVar { | ||
| 81 | name: "RUST_LOG", | ||
| 82 | required: false, | ||
| 83 | default: Some("info"), | ||
| 84 | description: "tracing/logging level filter", | ||
| 85 | }, | ||
| 86 | ]; | ||
| 87 | |||
| 88 | pub fn check_env_vars() { | ||
| 89 | let mut missing_required = Vec::new(); | ||
| 90 | let mut missing_optional = Vec::new(); | ||
| 91 | |||
| 92 | for var in ENV_VARS { | ||
| 93 | match env::var(var.name) { | ||
| 94 | Ok(_) => continue, | ||
| 95 | Err(_) => { | ||
| 96 | if var.required { | ||
| 97 | tracing::error!("MISSING REQUIRED: {} ({})", var.name, var.description); | ||
| 98 | missing_required.push(var.name); | ||
| 99 | } else { | ||
| 100 | match var.default { | ||
| 101 | Some(default) => { | ||
| 102 | tracing::warn!("{} not set, using default: {}", var.name, default) | ||
| 103 | } | ||
| 104 | None => tracing::warn!("{} not set ({})", var.name, var.description), | ||
| 105 | } | ||
| 106 | missing_optional.push(var.name); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | if !missing_required.is_empty() { | ||
| 113 | tracing::error!( | ||
| 114 | "Missing required environment variables: {}. The application will not function correctly.", | ||
| 115 | missing_required.join(", ") | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | |||
| 119 | if !missing_optional.is_empty() { | ||
| 120 | tracing::warn!( | ||
| 121 | "Missing optional environment variables: {}. Some features will be disabled or use defaults.", | ||
| 122 | missing_optional.join(", ") | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | impl Config { | ||
| 128 | pub fn from_env() -> Self { | ||
| 129 | Self { | ||
| 130 | token: env::var("TOKEN").expect("Missing TOKEN"), | ||
| 131 | status_channel_id: env::var("STATUS_CHANNEL_ID") | ||
| 132 | .ok() | ||
| 133 | .and_then(|v| v.parse().ok()), | ||
| 134 | assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()), | ||
| 135 | copyparty_url: env::var("COPYPARTY_URL") | ||
| 136 | .unwrap_or_else(|_| "http://localhost:3939".into()), | ||
| 137 | copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD") | ||
| 138 | .unwrap_or_else(|_| "http://localhost:3939".into()), | ||
| 139 | copyparty_password: env::var("COPYPARTY_PASSWORD").ok(), | ||
| 140 | openrouter_api_key: env::var("OPENROUTER_API_KEY").ok(), | ||
| 141 | openrouter_model: env::var("OPENROUTER_MODEL").ok(), | ||
| 142 | database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), | ||
| 143 | } | ||
| 144 | } | ||
| 145 | } | ||
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs new file mode 100644 index 0000000..00bfd53 --- /dev/null +++ b/src/copyparty/mod.rs | |||
| @@ -0,0 +1,267 @@ | |||
| 1 | use reqwest::Client; | ||
| 2 | use serde::Deserialize; | ||
| 3 | use tracing::warn; | ||
| 4 | |||
| 5 | use crate::error::Result; | ||
| 6 | use crate::lang; | ||
| 7 | |||
| 8 | #[derive(Clone)] | ||
| 9 | pub struct CopypartyClient { | ||
| 10 | pub base_url: String, | ||
| 11 | pub upload_url: String, | ||
| 12 | pub password: Option<String>, | ||
| 13 | client: Client, | ||
| 14 | } | ||
| 15 | |||
| 16 | /// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`). | ||
| 17 | pub fn percent_decode(s: &str) -> String { | ||
| 18 | let mut buf: Vec<u8> = Vec::with_capacity(s.len()); | ||
| 19 | let bytes = s.as_bytes(); | ||
| 20 | let mut i = 0; | ||
| 21 | while i < bytes.len() { | ||
| 22 | let b = bytes[i]; | ||
| 23 | if b == b'%' && i + 2 < bytes.len() { | ||
| 24 | if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { | ||
| 25 | buf.push(hi << 4 | lo); | ||
| 26 | i += 3; | ||
| 27 | continue; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | if b == b'+' { | ||
| 31 | buf.push(b' '); | ||
| 32 | } else { | ||
| 33 | buf.push(b); | ||
| 34 | } | ||
| 35 | i += 1; | ||
| 36 | } | ||
| 37 | String::from_utf8_lossy(&buf).into_owned() | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Decode common HTML entities in a display string. | ||
| 41 | /// Handles named (`&`, `<`) and numeric (`{`, `{`) forms. | ||
| 42 | /// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8. | ||
| 43 | pub fn html_decode(s: &str) -> String { | ||
| 44 | let mut buf: Vec<u8> = Vec::with_capacity(s.len()); | ||
| 45 | let bytes = s.as_bytes(); | ||
| 46 | let mut i = 0; | ||
| 47 | while i < bytes.len() { | ||
| 48 | if bytes[i] == b'&' { | ||
| 49 | let rest = &s[i..]; | ||
| 50 | if let Some(end) = rest.find(';') { | ||
| 51 | let entity = &rest[..=end]; | ||
| 52 | match entity { | ||
| 53 | "&" => buf.extend_from_slice(b"&"), | ||
| 54 | "<" => buf.extend_from_slice(b"<"), | ||
| 55 | ">" => buf.extend_from_slice(b">"), | ||
| 56 | """ => buf.extend_from_slice(b"\""), | ||
| 57 | "'" => buf.extend_from_slice(b"'"), | ||
| 58 | " " => buf.extend_from_slice(b" "), | ||
| 59 | _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => { | ||
| 60 | let (num_str, radix) = | ||
| 61 | if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' { | ||
| 62 | (&entity[3..entity.len() - 1], 16u32) | ||
| 63 | } else { | ||
| 64 | (&entity[2..entity.len() - 1], 10u32) | ||
| 65 | }; | ||
| 66 | let decoded = u32::from_str_radix(num_str, radix) | ||
| 67 | .ok() | ||
| 68 | .and_then(std::char::from_u32); | ||
| 69 | if let Some(c) = decoded { | ||
| 70 | let mut tmp = [0u8; 4]; | ||
| 71 | buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes()); | ||
| 72 | i += entity.len(); | ||
| 73 | continue; | ||
| 74 | } | ||
| 75 | buf.extend_from_slice(entity.as_bytes()); | ||
| 76 | } | ||
| 77 | _ => buf.extend_from_slice(entity.as_bytes()), | ||
| 78 | } | ||
| 79 | i += entity.len(); | ||
| 80 | continue; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | buf.push(bytes[i]); | ||
| 84 | i += 1; | ||
| 85 | } | ||
| 86 | String::from_utf8_lossy(&buf).into_owned() | ||
| 87 | } | ||
| 88 | |||
| 89 | fn hex_nibble(b: u8) -> Option<u8> { | ||
| 90 | match b { | ||
| 91 | b'0'..=b'9' => Some(b - b'0'), | ||
| 92 | b'A'..=b'F' => Some(b - b'A' + 10), | ||
| 93 | b'a'..=b'f' => Some(b - b'a' + 10), | ||
| 94 | _ => None, | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | /// A search result from copyparty's `/?srch` API. | ||
| 99 | /// | ||
| 100 | /// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). | ||
| 101 | /// The `name` / `url` / `path` fields are typically only present in directory | ||
| 102 | /// listing (`?ls`) responses, not in search. Use the convenience accessors | ||
| 103 | /// below instead of reading fields directly. | ||
| 104 | #[derive(Debug, Clone, Deserialize)] | ||
| 105 | pub struct SearchHit { | ||
| 106 | /// Relative path including filename (e.g. `/music/foo.mp3`). | ||
| 107 | /// This is the primary field populated by `/?srch`. | ||
| 108 | #[serde(default)] | ||
| 109 | pub rp: Option<String>, | ||
| 110 | /// Path component (directory listing only) | ||
| 111 | #[serde(default)] | ||
| 112 | pub path: Option<String>, | ||
| 113 | /// URL fragment for building a download URL | ||
| 114 | #[serde(default)] | ||
| 115 | pub url: Option<String>, | ||
| 116 | /// Bare filename (directory listing only) | ||
| 117 | #[serde(default)] | ||
| 118 | pub name: Option<String>, | ||
| 119 | } | ||
| 120 | |||
| 121 | impl SearchHit { | ||
| 122 | /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail. | ||
| 123 | pub fn filename(&self) -> Option<&str> { | ||
| 124 | self.name | ||
| 125 | .as_deref() | ||
| 126 | .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next())) | ||
| 127 | .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next())) | ||
| 128 | } | ||
| 129 | |||
| 130 | /// Best-effort URL path for building a full download URL. | ||
| 131 | /// Tries `url`, then `rp`. | ||
| 132 | pub fn url_path(&self) -> Option<&str> { | ||
| 133 | self.url.as_deref().or_else(|| self.rp.as_deref()) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | #[derive(Debug, Deserialize)] | ||
| 138 | pub struct UploadResponse { | ||
| 139 | pub fileurl: Option<String>, | ||
| 140 | } | ||
| 141 | |||
| 142 | impl CopypartyClient { | ||
| 143 | pub fn new(base_url: String, upload_url: String, password: Option<String>) -> Self { | ||
| 144 | Self { | ||
| 145 | base_url, | ||
| 146 | upload_url, | ||
| 147 | password, | ||
| 148 | client: Client::new(), | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD` | ||
| 153 | /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) | ||
| 154 | /// or relative (`music/foo.mp3`). | ||
| 155 | pub fn build_url(&self, path: &str) -> String { | ||
| 156 | let prefixed = if path.starts_with('/') { | ||
| 157 | format!("{}{}", self.base_url, path) | ||
| 158 | } else { | ||
| 159 | format!("{}/{}", self.base_url, path) | ||
| 160 | }; | ||
| 161 | if let Some(pw) = &self.password { | ||
| 162 | format!("{prefixed}?pw={pw}") | ||
| 163 | } else { | ||
| 164 | prefixed | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | /// Search copyparty via the jPOST `/?srch` endpoint. | ||
| 169 | /// | ||
| 170 | /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python | ||
| 171 | /// `_extract_files_from_response` helper), because the exact JSON shape can vary | ||
| 172 | /// across copyparty versions. | ||
| 173 | pub async fn search( | ||
| 174 | &self, | ||
| 175 | query: &str, | ||
| 176 | max_results: u32, | ||
| 177 | ext_filter: &[&str], | ||
| 178 | ) -> Result<Vec<SearchHit>> { | ||
| 179 | let tokens: Vec<&str> = query.split_whitespace().collect(); | ||
| 180 | let conditions: Vec<String> = tokens | ||
| 181 | .iter() | ||
| 182 | .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")")) | ||
| 183 | .collect(); | ||
| 184 | |||
| 185 | let mut q = conditions.join(" and "); | ||
| 186 | if !ext_filter.is_empty() { | ||
| 187 | let ext_conditions: Vec<String> = ext_filter | ||
| 188 | .iter() | ||
| 189 | .map(|e| format!("name like \"*.{e}\"")) | ||
| 190 | .collect(); | ||
| 191 | let ext_q = ext_conditions.join(" or "); | ||
| 192 | // When query has no tokens (e.g. empty autocomplete partial), | ||
| 193 | // avoid wrapping an empty string in parens → "() and ..." is invalid. | ||
| 194 | if q.is_empty() { | ||
| 195 | q = ext_q; | ||
| 196 | } else { | ||
| 197 | q = format!("({q}) and ({ext_q})"); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | let payload = serde_json::json!({"n": max_results, "q": q}); | ||
| 202 | |||
| 203 | let mut req = self | ||
| 204 | .client | ||
| 205 | .post(format!("{}/?srch", self.base_url)) | ||
| 206 | .json(&payload); | ||
| 207 | if let Some(pw) = &self.password { | ||
| 208 | req = req.header("PW", pw); | ||
| 209 | } | ||
| 210 | |||
| 211 | let resp = req.send().await?; | ||
| 212 | let status = resp.status(); | ||
| 213 | let body_text = resp.text().await?; | ||
| 214 | |||
| 215 | if !status.is_success() { | ||
| 216 | let snippet: String = body_text.chars().take(200).collect(); | ||
| 217 | return Err(crate::error::Error::Custom(lang::fmt_cp_search_http( | ||
| 218 | &status, &snippet, | ||
| 219 | ))); | ||
| 220 | } | ||
| 221 | |||
| 222 | let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { | ||
| 223 | let snippet: String = body_text.chars().take(200).collect(); | ||
| 224 | crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet)) | ||
| 225 | })?; | ||
| 226 | |||
| 227 | // Extract hits array – copyparty wraps results in {"hits": [...]} | ||
| 228 | let hits_val = match &root { | ||
| 229 | serde_json::Value::Object(map) => map.get("hits"), | ||
| 230 | _ => None, | ||
| 231 | }; | ||
| 232 | |||
| 233 | let hits: Vec<SearchHit> = match hits_val { | ||
| 234 | Some(serde_json::Value::Array(arr)) => arr | ||
| 235 | .iter() | ||
| 236 | .filter_map(|v| SearchHit::deserialize(v).ok()) | ||
| 237 | .collect(), | ||
| 238 | _ => { | ||
| 239 | warn!( | ||
| 240 | "copyparty search response missing 'hits' array; body starts: {:?}", | ||
| 241 | body_text.chars().take(100).collect::<String>() | ||
| 242 | ); | ||
| 243 | Vec::new() | ||
| 244 | } | ||
| 245 | }; | ||
| 246 | |||
| 247 | Ok(hits) | ||
| 248 | } | ||
| 249 | |||
| 250 | pub async fn upload(&self, file_name: &str, data: Vec<u8>) -> Result<String> { | ||
| 251 | let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string()); | ||
| 252 | let form = reqwest::multipart::Form::new().part("f", part); | ||
| 253 | |||
| 254 | let mut req = self | ||
| 255 | .client | ||
| 256 | .post(format!("{}/?j=1&ck=b2", self.upload_url)) | ||
| 257 | .multipart(form); | ||
| 258 | |||
| 259 | if let Some(pw) = &self.password { | ||
| 260 | req = req.header("Cookie", format!("cppwd={pw}")); | ||
| 261 | } | ||
| 262 | |||
| 263 | let resp: UploadResponse = req.send().await?.json().await?; | ||
| 264 | resp.fileurl | ||
| 265 | .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into())) | ||
| 266 | } | ||
| 267 | } | ||
diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..53f4bf4 --- /dev/null +++ b/src/database/mod.rs | |||
| @@ -0,0 +1,344 @@ | |||
| 1 | use chrono::{DateTime, Utc}; | ||
| 2 | use sqlx::FromRow; | ||
| 3 | use sqlx::SqlitePool; | ||
| 4 | |||
| 5 | use crate::error::{Error, Result}; | ||
| 6 | |||
| 7 | pub type DbPool = SqlitePool; | ||
| 8 | |||
| 9 | pub async fn init_pool(path: &str) -> Result<DbPool> { | ||
| 10 | if let Some(parent) = std::path::Path::new(path).parent() { | ||
| 11 | tokio::fs::create_dir_all(parent).await.map_err(Error::Io)?; | ||
| 12 | } | ||
| 13 | let pool = SqlitePool::connect(&format!("sqlite:{path}?mode=rwc")).await?; | ||
| 14 | run_migrations(&pool).await?; | ||
| 15 | Ok(pool) | ||
| 16 | } | ||
| 17 | |||
| 18 | async fn run_migrations(pool: &DbPool) -> Result<()> { | ||
| 19 | sqlx::query( | ||
| 20 | "CREATE TABLE IF NOT EXISTS settings ( | ||
| 21 | key TEXT PRIMARY KEY, | ||
| 22 | value TEXT NOT NULL | ||
| 23 | )", | ||
| 24 | ) | ||
| 25 | .execute(pool) | ||
| 26 | .await?; | ||
| 27 | |||
| 28 | sqlx::query( | ||
| 29 | "CREATE TABLE IF NOT EXISTS voice_activity ( | ||
| 30 | user_id TEXT PRIMARY KEY, | ||
| 31 | join_time REAL NOT NULL DEFAULT 0, | ||
| 32 | total_active REAL NOT NULL DEFAULT 0, | ||
| 33 | total_afk REAL NOT NULL DEFAULT 0, | ||
| 34 | is_afk INTEGER NOT NULL DEFAULT 0, | ||
| 35 | afk_start REAL NOT NULL DEFAULT 0, | ||
| 36 | active_start REAL NOT NULL DEFAULT 0, | ||
| 37 | channel_name TEXT NOT NULL DEFAULT '', | ||
| 38 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 39 | )", | ||
| 40 | ) | ||
| 41 | .execute(pool) | ||
| 42 | .await?; | ||
| 43 | |||
| 44 | sqlx::query( | ||
| 45 | "CREATE TABLE IF NOT EXISTS cron_jobs ( | ||
| 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 47 | cron_expression TEXT NOT NULL, | ||
| 48 | type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')), | ||
| 49 | channel_id INTEGER NOT NULL, | ||
| 50 | active INTEGER NOT NULL DEFAULT 1, | ||
| 51 | audio_filename TEXT, | ||
| 52 | message TEXT, | ||
| 53 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 54 | )", | ||
| 55 | ) | ||
| 56 | .execute(pool) | ||
| 57 | .await?; | ||
| 58 | |||
| 59 | sqlx::query( | ||
| 60 | "CREATE TABLE IF NOT EXISTS triggers ( | ||
| 61 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 62 | name TEXT UNIQUE NOT NULL, | ||
| 63 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 64 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 65 | )", | ||
| 66 | ) | ||
| 67 | .execute(pool) | ||
| 68 | .await?; | ||
| 69 | |||
| 70 | sqlx::query( | ||
| 71 | "CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 72 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 73 | trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 74 | keyword TEXT NOT NULL, | ||
| 75 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 76 | UNIQUE(trigger_id, keyword) | ||
| 77 | )", | ||
| 78 | ) | ||
| 79 | .execute(pool) | ||
| 80 | .await?; | ||
| 81 | |||
| 82 | sqlx::query( | ||
| 83 | "CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 84 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 85 | trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 86 | response_text TEXT NOT NULL, | ||
| 87 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 88 | )", | ||
| 89 | ) | ||
| 90 | .execute(pool) | ||
| 91 | .await?; | ||
| 92 | |||
| 93 | Ok(()) | ||
| 94 | } | ||
| 95 | |||
| 96 | // -- Voice Activity -- | ||
| 97 | #[derive(Debug, Clone, FromRow)] | ||
| 98 | pub struct VoiceActivity { | ||
| 99 | pub user_id: String, | ||
| 100 | pub join_time: f64, | ||
| 101 | pub total_active: f64, | ||
| 102 | pub total_afk: f64, | ||
| 103 | pub is_afk: i32, | ||
| 104 | pub afk_start: f64, | ||
| 105 | pub active_start: f64, | ||
| 106 | pub channel_name: String, | ||
| 107 | #[allow(dead_code)] | ||
| 108 | pub last_updated: Option<DateTime<Utc>>, | ||
| 109 | } | ||
| 110 | |||
| 111 | #[allow(dead_code)] | ||
| 112 | pub async fn get_voice_activity(pool: &DbPool, user_id: &str) -> Result<Option<VoiceActivity>> { | ||
| 113 | Ok( | ||
| 114 | sqlx::query_as("SELECT * FROM voice_activity WHERE user_id = ?") | ||
| 115 | .bind(user_id) | ||
| 116 | .fetch_optional(pool) | ||
| 117 | .await?, | ||
| 118 | ) | ||
| 119 | } | ||
| 120 | |||
| 121 | pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result<()> { | ||
| 122 | sqlx::query( | ||
| 123 | "INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) | ||
| 124 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| 125 | ON CONFLICT(user_id) DO UPDATE SET | ||
| 126 | join_time = excluded.join_time, | ||
| 127 | total_active = excluded.total_active, | ||
| 128 | total_afk = excluded.total_afk, | ||
| 129 | is_afk = excluded.is_afk, | ||
| 130 | afk_start = excluded.afk_start, | ||
| 131 | active_start = excluded.active_start, | ||
| 132 | channel_name = excluded.channel_name, | ||
| 133 | last_updated = CURRENT_TIMESTAMP", | ||
| 134 | ) | ||
| 135 | .bind(&va.user_id) | ||
| 136 | .bind(va.join_time) | ||
| 137 | .bind(va.total_active) | ||
| 138 | .bind(va.total_afk) | ||
| 139 | .bind(va.is_afk) | ||
| 140 | .bind(va.afk_start) | ||
| 141 | .bind(va.active_start) | ||
| 142 | .bind(&va.channel_name) | ||
| 143 | .execute(pool) | ||
| 144 | .await?; | ||
| 145 | Ok(()) | ||
| 146 | } | ||
| 147 | |||
| 148 | // -- Cron Jobs -- | ||
| 149 | #[derive(Debug, Clone, FromRow)] | ||
| 150 | pub struct CronJob { | ||
| 151 | pub id: i64, | ||
| 152 | pub cron_expression: String, | ||
| 153 | pub r#type: String, | ||
| 154 | pub channel_id: i64, | ||
| 155 | pub active: i32, | ||
| 156 | pub audio_filename: Option<String>, | ||
| 157 | pub message: Option<String>, | ||
| 158 | #[allow(dead_code)] | ||
| 159 | pub created_at: Option<DateTime<Utc>>, | ||
| 160 | } | ||
| 161 | |||
| 162 | #[allow(dead_code)] | ||
| 163 | pub async fn list_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> { | ||
| 164 | Ok(sqlx::query_as("SELECT * FROM cron_jobs ORDER BY id") | ||
| 165 | .fetch_all(pool) | ||
| 166 | .await?) | ||
| 167 | } | ||
| 168 | |||
| 169 | #[allow(dead_code)] | ||
| 170 | pub async fn list_active_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> { | ||
| 171 | Ok(sqlx::query_as("SELECT * FROM cron_jobs WHERE active = 1") | ||
| 172 | .fetch_all(pool) | ||
| 173 | .await?) | ||
| 174 | } | ||
| 175 | |||
| 176 | pub async fn add_cron_job( | ||
| 177 | pool: &DbPool, | ||
| 178 | cron_expression: &str, | ||
| 179 | r#type: &str, | ||
| 180 | channel_id: i64, | ||
| 181 | audio_filename: Option<&str>, | ||
| 182 | message: Option<&str>, | ||
| 183 | ) -> Result<i64> { | ||
| 184 | let result = sqlx::query( | ||
| 185 | "INSERT INTO cron_jobs (cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?)", | ||
| 186 | ) | ||
| 187 | .bind(cron_expression) | ||
| 188 | .bind(r#type) | ||
| 189 | .bind(channel_id) | ||
| 190 | .bind(audio_filename) | ||
| 191 | .bind(message) | ||
| 192 | .execute(pool) | ||
| 193 | .await?; | ||
| 194 | Ok(result.last_insert_rowid()) | ||
| 195 | } | ||
| 196 | |||
| 197 | pub async fn delete_cron_job(pool: &DbPool, id: i64) -> Result<bool> { | ||
| 198 | let result = sqlx::query("DELETE FROM cron_jobs WHERE id = ?") | ||
| 199 | .bind(id) | ||
| 200 | .execute(pool) | ||
| 201 | .await?; | ||
| 202 | Ok(result.rows_affected() > 0) | ||
| 203 | } | ||
| 204 | |||
| 205 | pub async fn set_cron_job_active(pool: &DbPool, id: i64, active: bool) -> Result<bool> { | ||
| 206 | let result = sqlx::query("UPDATE cron_jobs SET active = ? WHERE id = ?") | ||
| 207 | .bind(if active { 1 } else { 0 }) | ||
| 208 | .bind(id) | ||
| 209 | .execute(pool) | ||
| 210 | .await?; | ||
| 211 | Ok(result.rows_affected() > 0) | ||
| 212 | } | ||
| 213 | |||
| 214 | // -- Triggers -- | ||
| 215 | #[derive(Debug, Clone, FromRow)] | ||
| 216 | pub struct Trigger { | ||
| 217 | pub id: i64, | ||
| 218 | pub name: String, | ||
| 219 | pub created_at: Option<DateTime<Utc>>, | ||
| 220 | pub last_updated: Option<DateTime<Utc>>, | ||
| 221 | } | ||
| 222 | |||
| 223 | #[allow(dead_code)] | ||
| 224 | #[derive(Debug, Clone, FromRow)] | ||
| 225 | pub struct TriggerKeyword { | ||
| 226 | pub id: i64, | ||
| 227 | pub trigger_id: i64, | ||
| 228 | pub keyword: String, | ||
| 229 | } | ||
| 230 | |||
| 231 | #[allow(dead_code)] | ||
| 232 | #[derive(Debug, Clone, FromRow)] | ||
| 233 | pub struct TriggerResponse { | ||
| 234 | pub id: i64, | ||
| 235 | pub trigger_id: i64, | ||
| 236 | pub response_text: String, | ||
| 237 | } | ||
| 238 | |||
| 239 | pub async fn create_trigger(pool: &DbPool, name: &str) -> Result<i64> { | ||
| 240 | let result = sqlx::query("INSERT INTO triggers (name) VALUES (?)") | ||
| 241 | .bind(name) | ||
| 242 | .execute(pool) | ||
| 243 | .await?; | ||
| 244 | Ok(result.last_insert_rowid()) | ||
| 245 | } | ||
| 246 | |||
| 247 | pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result<bool> { | ||
| 248 | let result = sqlx::query("DELETE FROM triggers WHERE id = ?") | ||
| 249 | .bind(id) | ||
| 250 | .execute(pool) | ||
| 251 | .await?; | ||
| 252 | Ok(result.rows_affected() > 0) | ||
| 253 | } | ||
| 254 | |||
| 255 | pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result<Option<Trigger>> { | ||
| 256 | Ok(sqlx::query_as("SELECT * FROM triggers WHERE name = ?") | ||
| 257 | .bind(name) | ||
| 258 | .fetch_optional(pool) | ||
| 259 | .await?) | ||
| 260 | } | ||
| 261 | |||
| 262 | pub async fn list_triggers(pool: &DbPool) -> Result<Vec<Trigger>> { | ||
| 263 | Ok(sqlx::query_as("SELECT * FROM triggers ORDER BY name") | ||
| 264 | .fetch_all(pool) | ||
| 265 | .await?) | ||
| 266 | } | ||
| 267 | |||
| 268 | pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> { | ||
| 269 | sqlx::query("INSERT OR IGNORE INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)") | ||
| 270 | .bind(trigger_id) | ||
| 271 | .bind(keyword) | ||
| 272 | .execute(pool) | ||
| 273 | .await?; | ||
| 274 | update_trigger_timestamp(pool, trigger_id).await | ||
| 275 | } | ||
| 276 | |||
| 277 | pub async fn remove_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<bool> { | ||
| 278 | let result = sqlx::query("DELETE FROM trigger_keywords WHERE trigger_id = ? AND keyword = ?") | ||
| 279 | .bind(trigger_id) | ||
| 280 | .bind(keyword) | ||
| 281 | .execute(pool) | ||
| 282 | .await?; | ||
| 283 | update_trigger_timestamp(pool, trigger_id).await?; | ||
| 284 | Ok(result.rows_affected() > 0) | ||
| 285 | } | ||
| 286 | |||
| 287 | pub async fn add_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<()> { | ||
| 288 | sqlx::query("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)") | ||
| 289 | .bind(trigger_id) | ||
| 290 | .bind(response) | ||
| 291 | .execute(pool) | ||
| 292 | .await?; | ||
| 293 | update_trigger_timestamp(pool, trigger_id).await | ||
| 294 | } | ||
| 295 | |||
| 296 | pub async fn remove_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<bool> { | ||
| 297 | let result = | ||
| 298 | sqlx::query("DELETE FROM trigger_responses WHERE trigger_id = ? AND response_text = ?") | ||
| 299 | .bind(trigger_id) | ||
| 300 | .bind(response) | ||
| 301 | .execute(pool) | ||
| 302 | .await?; | ||
| 303 | update_trigger_timestamp(pool, trigger_id).await?; | ||
| 304 | Ok(result.rows_affected() > 0) | ||
| 305 | } | ||
| 306 | |||
| 307 | pub async fn get_keywords(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> { | ||
| 308 | let rows: Vec<(String,)> = | ||
| 309 | sqlx::query_as("SELECT keyword FROM trigger_keywords WHERE trigger_id = ?") | ||
| 310 | .bind(trigger_id) | ||
| 311 | .fetch_all(pool) | ||
| 312 | .await?; | ||
| 313 | Ok(rows.into_iter().map(|r| r.0).collect()) | ||
| 314 | } | ||
| 315 | |||
| 316 | pub async fn get_responses(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> { | ||
| 317 | let rows: Vec<(String,)> = | ||
| 318 | sqlx::query_as("SELECT response_text FROM trigger_responses WHERE trigger_id = ?") | ||
| 319 | .bind(trigger_id) | ||
| 320 | .fetch_all(pool) | ||
| 321 | .await?; | ||
| 322 | Ok(rows.into_iter().map(|r| r.0).collect()) | ||
| 323 | } | ||
| 324 | |||
| 325 | pub async fn load_all_triggers_with_keywords_and_responses( | ||
| 326 | pool: &DbPool, | ||
| 327 | ) -> Result<Vec<(Trigger, Vec<String>, Vec<String>)>> { | ||
| 328 | let triggers = list_triggers(pool).await?; | ||
| 329 | let mut result = Vec::new(); | ||
| 330 | for t in triggers { | ||
| 331 | let keywords = get_keywords(pool, t.id).await?; | ||
| 332 | let responses = get_responses(pool, t.id).await?; | ||
| 333 | result.push((t, keywords, responses)); | ||
| 334 | } | ||
| 335 | Ok(result) | ||
| 336 | } | ||
| 337 | |||
| 338 | async fn update_trigger_timestamp(pool: &DbPool, id: i64) -> Result<()> { | ||
| 339 | sqlx::query("UPDATE triggers SET last_updated = CURRENT_TIMESTAMP WHERE id = ?") | ||
| 340 | .bind(id) | ||
| 341 | .execute(pool) | ||
| 342 | .await?; | ||
| 343 | Ok(()) | ||
| 344 | } | ||
diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d90d2e9 --- /dev/null +++ b/src/error.rs | |||
| @@ -0,0 +1,108 @@ | |||
| 1 | use std::fmt; | ||
| 2 | |||
| 3 | #[derive(Debug)] | ||
| 4 | pub enum Error { | ||
| 5 | Sqlx(sqlx::Error), | ||
| 6 | Reqwest(reqwest::Error), | ||
| 7 | Serenity(serenity::Error), | ||
| 8 | SongbirdJoin(songbird::error::JoinError), | ||
| 9 | SongbirdControl(songbird::tracks::ControlError), | ||
| 10 | Io(std::io::Error), | ||
| 11 | Cron(cron::error::Error), | ||
| 12 | Image(image::ImageError), | ||
| 13 | Qr(qrcode::types::QrError), | ||
| 14 | Scraper(String), | ||
| 15 | Config(String), | ||
| 16 | TaskJoin(String), | ||
| 17 | Custom(String), | ||
| 18 | } | ||
| 19 | |||
| 20 | impl fmt::Display for Error { | ||
| 21 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 22 | use crate::lang; | ||
| 23 | match self { | ||
| 24 | Self::Sqlx(e) => write!(f, "{}: {e}", lang::L_ERR_DATABASE), | ||
| 25 | Self::Reqwest(e) => write!(f, "{}: {e}", lang::L_ERR_HTTP), | ||
| 26 | Self::Serenity(e) => write!(f, "{}: {e}", lang::L_ERR_DISCORD), | ||
| 27 | Self::SongbirdJoin(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_JOIN), | ||
| 28 | Self::SongbirdControl(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_CONTROL), | ||
| 29 | Self::Io(e) => write!(f, "{}: {e}", lang::L_ERR_IO), | ||
| 30 | Self::Cron(e) => write!(f, "{}: {e}", lang::L_ERR_CRON), | ||
| 31 | Self::Image(e) => write!(f, "{}: {e}", lang::L_ERR_IMAGE), | ||
| 32 | Self::Qr(e) => write!(f, "{}: {e}", lang::L_ERR_QR), | ||
| 33 | Self::Scraper(e) => write!(f, "{}: {e}", lang::L_ERR_SCRAPER), | ||
| 34 | Self::Config(e) => write!(f, "{}: {e}", lang::L_ERR_CONFIG), | ||
| 35 | Self::TaskJoin(e) => write!(f, "{}: {e}", lang::L_ERR_TASK_JOIN), | ||
| 36 | Self::Custom(e) => write!(f, "{e}"), | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | impl std::error::Error for Error { | ||
| 42 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { | ||
| 43 | match self { | ||
| 44 | Self::Sqlx(e) => Some(e), | ||
| 45 | Self::Reqwest(e) => Some(e), | ||
| 46 | Self::Serenity(e) => Some(e), | ||
| 47 | Self::SongbirdJoin(e) => Some(e), | ||
| 48 | Self::SongbirdControl(e) => Some(e), | ||
| 49 | Self::Io(e) => Some(e), | ||
| 50 | Self::Image(e) => Some(e), | ||
| 51 | Self::Qr(e) => Some(e), | ||
| 52 | _ => None, | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | impl From<sqlx::Error> for Error { | ||
| 58 | fn from(e: sqlx::Error) -> Self { | ||
| 59 | Self::Sqlx(e) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | impl From<reqwest::Error> for Error { | ||
| 63 | fn from(e: reqwest::Error) -> Self { | ||
| 64 | Self::Reqwest(e) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | impl From<serenity::Error> for Error { | ||
| 68 | fn from(e: serenity::Error) -> Self { | ||
| 69 | Self::Serenity(e) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | impl From<songbird::error::JoinError> for Error { | ||
| 73 | fn from(e: songbird::error::JoinError) -> Self { | ||
| 74 | Self::SongbirdJoin(e) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | impl From<songbird::tracks::ControlError> for Error { | ||
| 78 | fn from(e: songbird::tracks::ControlError) -> Self { | ||
| 79 | Self::SongbirdControl(e) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | impl From<std::io::Error> for Error { | ||
| 83 | fn from(e: std::io::Error) -> Self { | ||
| 84 | Self::Io(e) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | impl From<cron::error::Error> for Error { | ||
| 88 | fn from(e: cron::error::Error) -> Self { | ||
| 89 | Self::Cron(e) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | impl From<image::ImageError> for Error { | ||
| 93 | fn from(e: image::ImageError) -> Self { | ||
| 94 | Self::Image(e) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | impl From<qrcode::types::QrError> for Error { | ||
| 98 | fn from(e: qrcode::types::QrError) -> Self { | ||
| 99 | Self::Qr(e) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | impl From<tokio::task::JoinError> for Error { | ||
| 103 | fn from(e: tokio::task::JoinError) -> Self { | ||
| 104 | Self::TaskJoin(e.to_string()) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | pub type Result<T> = std::result::Result<T, Error>; | ||
diff --git a/src/events/mod.rs b/src/events/mod.rs new file mode 100644 index 0000000..457ebcd --- /dev/null +++ b/src/events/mod.rs | |||
| @@ -0,0 +1,368 @@ | |||
| 1 | use chrono::{Local, Utc}; | ||
| 2 | use poise::serenity_prelude as serenity; | ||
| 3 | use rand::seq::IndexedRandom; | ||
| 4 | use tracing::{info, warn}; | ||
| 5 | |||
| 6 | use crate::Data; | ||
| 7 | use crate::database::{self, VoiceActivity}; | ||
| 8 | use crate::error::Result; | ||
| 9 | use crate::lang; | ||
| 10 | |||
| 11 | pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> { | ||
| 12 | info!("{}", lang::L_EV_READY); | ||
| 13 | Ok(()) | ||
| 14 | } | ||
| 15 | |||
| 16 | fn format_duration(seconds: f64) -> String { | ||
| 17 | let total = seconds as u64; | ||
| 18 | let hours = total / 3600; | ||
| 19 | let minutes = (total % 3600) / 60; | ||
| 20 | let secs = total % 60; | ||
| 21 | if hours > 0 { | ||
| 22 | format!("{hours}h {minutes}m {secs}s") | ||
| 23 | } else if minutes > 0 { | ||
| 24 | format!("{minutes}m {secs}s") | ||
| 25 | } else { | ||
| 26 | format!("{secs}s") | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | async fn play_tts_in_channel( | ||
| 31 | _ctx: &serenity::Context, | ||
| 32 | data: &Data, | ||
| 33 | guild_id: serenity::GuildId, | ||
| 34 | channel_id: serenity::ChannelId, | ||
| 35 | text: &str, | ||
| 36 | ) { | ||
| 37 | let tts_url = format!( | ||
| 38 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | ||
| 39 | crate::commands::tts::urlencoding(text), | ||
| 40 | ); | ||
| 41 | |||
| 42 | let audio_data = match data.http_client.get(&tts_url).send().await { | ||
| 43 | Ok(resp) => match resp.bytes().await { | ||
| 44 | Ok(b) => b, | ||
| 45 | Err(e) => { | ||
| 46 | warn!("Failed to read TTS audio bytes: {e}"); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | }, | ||
| 50 | Err(e) => { | ||
| 51 | warn!("Failed to fetch TTS audio: {e}"); | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | let call = match data.voice_manager.connect(guild_id, channel_id).await { | ||
| 57 | Ok(c) => c, | ||
| 58 | Err(e) => { | ||
| 59 | warn!("Failed to connect to voice for TTS: {e}"); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | |||
| 64 | let input = match crate::voice::decode_audio(&audio_data) { | ||
| 65 | Ok(i) => i, | ||
| 66 | Err(e) => { | ||
| 67 | warn!("Failed to decode TTS audio: {e}"); | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | }; | ||
| 71 | |||
| 72 | let mut call_lock = call.lock().await; | ||
| 73 | if call_lock.current_channel().is_some() { | ||
| 74 | call_lock.play_input(input); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | pub async fn handle_voice_state_update( | ||
| 79 | ctx: &serenity::Context, | ||
| 80 | data: &Data, | ||
| 81 | old: Option<serenity::VoiceState>, | ||
| 82 | new: &serenity::VoiceState, | ||
| 83 | ) -> Result<()> { | ||
| 84 | let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new); | ||
| 85 | let current_ts = Utc::now().timestamp(); | ||
| 86 | let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); | ||
| 87 | |||
| 88 | let user_id = new.user_id; | ||
| 89 | let guild_id = new.guild_id; | ||
| 90 | |||
| 91 | // skip bots (Python behavior) | ||
| 92 | let member = guild_id | ||
| 93 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 94 | .and_then(|g| g.members.get(&user_id).cloned()); | ||
| 95 | if let Some(ref m) = member { | ||
| 96 | if m.user.bot { | ||
| 97 | return Ok(()); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | let member_name = member | ||
| 102 | .as_ref() | ||
| 103 | .map(|m| m.user.name.clone()) | ||
| 104 | .unwrap_or_default(); | ||
| 105 | let display_name = member | ||
| 106 | .as_ref() | ||
| 107 | .map(|m| m.display_name().to_string()) | ||
| 108 | .unwrap_or_default(); | ||
| 109 | |||
| 110 | let now = current_ts as f64; | ||
| 111 | |||
| 112 | let user_id_str = user_id.to_string(); | ||
| 113 | let mut va = database::get_voice_activity(&data.db_pool, &user_id_str) | ||
| 114 | .await? | ||
| 115 | .unwrap_or(VoiceActivity { | ||
| 116 | user_id: user_id_str.clone(), | ||
| 117 | join_time: 0.0, | ||
| 118 | total_active: 0.0, | ||
| 119 | total_afk: 0.0, | ||
| 120 | is_afk: 0, | ||
| 121 | afk_start: 0.0, | ||
| 122 | active_start: 0.0, | ||
| 123 | channel_name: String::new(), | ||
| 124 | last_updated: None, | ||
| 125 | }); | ||
| 126 | |||
| 127 | let old_channel = old.as_ref().and_then(|v| v.channel_id); | ||
| 128 | let new_channel = new.channel_id; | ||
| 129 | |||
| 130 | let mut tts_text: Option<String> = None; | ||
| 131 | let mut tts_channel: Option<serenity::ChannelId> = None; | ||
| 132 | |||
| 133 | // ── JOIN ── | ||
| 134 | if old_channel.is_none() && new_channel.is_some() { | ||
| 135 | let cid = new_channel.unwrap(); | ||
| 136 | let is_afk = new.self_mute || new.self_deaf; | ||
| 137 | |||
| 138 | va.join_time = now; | ||
| 139 | va.total_active = 0.0; | ||
| 140 | va.total_afk = 0.0; | ||
| 141 | va.is_afk = if is_afk { 1 } else { 0 }; | ||
| 142 | va.afk_start = if is_afk { now } else { 0.0 }; | ||
| 143 | va.active_start = if !is_afk { now } else { 0.0 }; | ||
| 144 | va.channel_name = guild_id | ||
| 145 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 146 | .map(|g| { | ||
| 147 | g.channels | ||
| 148 | .get(&cid) | ||
| 149 | .map(|c| c.name.clone()) | ||
| 150 | .unwrap_or_default() | ||
| 151 | }) | ||
| 152 | .unwrap_or_default(); | ||
| 153 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 154 | info!("User {user_id} joined voice"); | ||
| 155 | |||
| 156 | if let Some(lc) = log_channel { | ||
| 157 | let msg = format!( | ||
| 158 | "[{current_time}] {member_name} dołączył na {} <t:{current_ts}:R>", | ||
| 159 | va.channel_name | ||
| 160 | ); | ||
| 161 | lc.say(ctx, &msg).await.ok(); | ||
| 162 | } | ||
| 163 | tts_text = Some(lang::fmt_ev_joined(&display_name)); | ||
| 164 | tts_channel = Some(cid); | ||
| 165 | } | ||
| 166 | // ── LEAVE ── | ||
| 167 | else if old_channel.is_some() && new_channel.is_none() { | ||
| 168 | let ocid = old_channel.unwrap(); | ||
| 169 | let channel_name = guild_id | ||
| 170 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 171 | .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone())) | ||
| 172 | .unwrap_or_default(); | ||
| 173 | |||
| 174 | if va.join_time > 0.0 { | ||
| 175 | if va.is_afk == 1 { | ||
| 176 | va.total_afk += now - va.afk_start; | ||
| 177 | } else { | ||
| 178 | va.total_active += (now - va.active_start).max(0.0); | ||
| 179 | } | ||
| 180 | |||
| 181 | let active_str = format_duration(va.total_active); | ||
| 182 | let afk_str = format_duration(va.total_afk); | ||
| 183 | |||
| 184 | if let Some(lc) = log_channel { | ||
| 185 | let msg = format!( | ||
| 186 | "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\ | ||
| 187 | Aktywny: {active_str} | AFK: {afk_str}", | ||
| 188 | channel_name, | ||
| 189 | ); | ||
| 190 | lc.say(ctx, &msg).await.ok(); | ||
| 191 | } | ||
| 192 | info!("User {user_id} left voice"); | ||
| 193 | |||
| 194 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 195 | } else { | ||
| 196 | if let Some(lc) = log_channel { | ||
| 197 | let msg = format!( | ||
| 198 | "[{current_time}] {member_name} rozłączył się z {channel_name} <t:{current_ts}:R>" | ||
| 199 | ); | ||
| 200 | lc.say(ctx, &msg).await.ok(); | ||
| 201 | } | ||
| 202 | info!("User {user_id} left voice (no data)"); | ||
| 203 | } | ||
| 204 | |||
| 205 | tts_text = Some(lang::fmt_ev_left(&display_name)); | ||
| 206 | tts_channel = Some(ocid); | ||
| 207 | } | ||
| 208 | // ── AFK STATE CHANGE (only for tracked users already in channel) ── | ||
| 209 | else if new_channel.is_some() && va.join_time > 0.0 { | ||
| 210 | let is_afk = new.self_mute || new.self_deaf; | ||
| 211 | let was_afk = va.is_afk == 1; | ||
| 212 | |||
| 213 | if was_afk && !is_afk { | ||
| 214 | // Return from AFK | ||
| 215 | let afk_duration = now - va.afk_start; | ||
| 216 | va.total_afk += afk_duration; | ||
| 217 | va.is_afk = 0; | ||
| 218 | va.afk_start = 0.0; | ||
| 219 | va.active_start = now; | ||
| 220 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 221 | |||
| 222 | let afk_str = format_duration(afk_duration); | ||
| 223 | // Python behavior: after.self_mute is always false here so always "odgłuchnął" | ||
| 224 | let activity = if new.self_mute { | ||
| 225 | "odciszył" | ||
| 226 | } else { | ||
| 227 | "odgłuchnął" | ||
| 228 | }; | ||
| 229 | |||
| 230 | if let Some(lc) = log_channel { | ||
| 231 | let msg = format!( | ||
| 232 | "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\ | ||
| 233 | AFK: {afk_str}", | ||
| 234 | va.channel_name, | ||
| 235 | ); | ||
| 236 | lc.say(ctx, &msg).await.ok(); | ||
| 237 | } | ||
| 238 | info!("User {user_id} returned from AFK"); | ||
| 239 | } else if !was_afk && is_afk { | ||
| 240 | // Went AFK | ||
| 241 | let active_duration = if va.active_start > 0.0 { | ||
| 242 | (now - va.active_start).max(0.0) | ||
| 243 | } else { | ||
| 244 | 0.0 | ||
| 245 | }; | ||
| 246 | va.total_active += active_duration; | ||
| 247 | va.is_afk = 1; | ||
| 248 | va.afk_start = now; | ||
| 249 | va.active_start = 0.0; | ||
| 250 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 251 | |||
| 252 | let active_str = format_duration(active_duration); | ||
| 253 | let activity = if new.self_mute { | ||
| 254 | "wyciszył" | ||
| 255 | } else { | ||
| 256 | "ogłuchnął" | ||
| 257 | }; | ||
| 258 | |||
| 259 | if let Some(lc) = log_channel { | ||
| 260 | let msg = format!( | ||
| 261 | "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\ | ||
| 262 | Aktywny: {active_str}", | ||
| 263 | va.channel_name, | ||
| 264 | ); | ||
| 265 | lc.say(ctx, &msg).await.ok(); | ||
| 266 | } | ||
| 267 | info!("User {user_id} went AFK"); | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | // ── TTS for mute/deafen (deaf takes priority with early return) ── | ||
| 272 | if old.as_ref().map_or(false, |v| v.self_deaf) != new.self_deaf { | ||
| 273 | let phrase = if new.self_deaf { | ||
| 274 | lang::fmt_ev_deafened(&display_name) | ||
| 275 | } else { | ||
| 276 | lang::fmt_ev_undeafened(&display_name) | ||
| 277 | }; | ||
| 278 | if let Some(gid) = guild_id { | ||
| 279 | if let Some(cid) = tts_channel.or(new_channel) { | ||
| 280 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | ||
| 281 | } | ||
| 282 | } | ||
| 283 | return Ok(()); | ||
| 284 | } | ||
| 285 | |||
| 286 | if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute { | ||
| 287 | let phrase = if new.self_mute { | ||
| 288 | lang::fmt_ev_muted(&display_name) | ||
| 289 | } else { | ||
| 290 | lang::fmt_ev_unmuted(&display_name) | ||
| 291 | }; | ||
| 292 | if let Some(gid) = guild_id { | ||
| 293 | if let Some(cid) = tts_channel.or(new_channel) { | ||
| 294 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | ||
| 295 | } | ||
| 296 | } | ||
| 297 | return Ok(()); | ||
| 298 | } | ||
| 299 | |||
| 300 | // ── Play join/leave TTS if no mute/deafen change ── | ||
| 301 | if let (Some(text), Some(cid)) = (tts_text, tts_channel) { | ||
| 302 | if let Some(gid) = guild_id { | ||
| 303 | play_tts_in_channel(ctx, data, gid, cid, &text).await; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | Ok(()) | ||
| 308 | } | ||
| 309 | |||
| 310 | pub async fn handle_message( | ||
| 311 | ctx: &serenity::Context, | ||
| 312 | data: &Data, | ||
| 313 | new_message: &serenity::Message, | ||
| 314 | ) -> Result<()> { | ||
| 315 | if new_message.author.id == ctx.cache.current_user().id { | ||
| 316 | return Ok(()); | ||
| 317 | } | ||
| 318 | |||
| 319 | let content_lower = new_message.content.to_lowercase(); | ||
| 320 | let response_to_send = { | ||
| 321 | let triggers = data.triggers.lock().await; | ||
| 322 | let mut chosen = None; | ||
| 323 | for (_trigger, keywords, responses) in triggers.iter() { | ||
| 324 | if keywords.iter().any(|k| content_lower.contains(k)) { | ||
| 325 | chosen = responses.choose(&mut rand::rng()).cloned(); | ||
| 326 | break; | ||
| 327 | } | ||
| 328 | } | ||
| 329 | chosen | ||
| 330 | }; | ||
| 331 | if let Some(response) = response_to_send { | ||
| 332 | new_message.channel_id.say(ctx, response).await.ok(); | ||
| 333 | } | ||
| 334 | |||
| 335 | let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id); | ||
| 336 | let has_keyword = content_lower.contains(&data.config.assistant_keyword); | ||
| 337 | |||
| 338 | if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() { | ||
| 339 | let api_key = data.config.openrouter_api_key.clone().unwrap(); | ||
| 340 | let model = data.config.openrouter_model.clone().unwrap_or_else(|| { | ||
| 341 | "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into() | ||
| 342 | }); | ||
| 343 | |||
| 344 | let client = crate::openrouter::OpenRouterClient::new(api_key, model); | ||
| 345 | let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT; | ||
| 346 | |||
| 347 | let mut messages = Vec::new(); | ||
| 348 | let mut current_msg = Some(new_message.clone()); | ||
| 349 | for _ in 0..5 { | ||
| 350 | if let Some(msg) = current_msg { | ||
| 351 | messages.push(("user".into(), msg.content.clone())); | ||
| 352 | current_msg = match msg.message_reference.and_then(|r| r.message_id) { | ||
| 353 | Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(), | ||
| 354 | None => None, | ||
| 355 | }; | ||
| 356 | } else { | ||
| 357 | break; | ||
| 358 | } | ||
| 359 | } | ||
| 360 | messages.reverse(); | ||
| 361 | |||
| 362 | if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await { | ||
| 363 | new_message.reply(ctx, response).await.ok(); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | Ok(()) | ||
| 368 | } | ||
diff --git a/src/lang.rs b/src/lang.rs new file mode 100644 index 0000000..626f387 --- /dev/null +++ b/src/lang.rs | |||
| @@ -0,0 +1,387 @@ | |||
| 1 | // ┌──────────────────────────────────────────────────────────────────┐ | ||
| 2 | // │ lang.rs — All user-facing messages live here. │ | ||
| 3 | // │ Translate the bot by editing this single file. │ | ||
| 4 | // │ Naming: L_<CAT>_<NAME> for simple &str; │ | ||
| 5 | // │ fn fmt_<cat>_<name>(...) -> String for parameterized. │ | ||
| 6 | // └──────────────────────────────────────────────────────────────────┘ | ||
| 7 | |||
| 8 | // ── Error display prefixes (used in error.rs Display impl) ──────────── | ||
| 9 | |||
| 10 | pub const L_ERR_DATABASE: &str = "Database error"; | ||
| 11 | pub const L_ERR_HTTP: &str = "HTTP error"; | ||
| 12 | pub const L_ERR_DISCORD: &str = "Discord error"; | ||
| 13 | pub const L_ERR_VOICE_JOIN: &str = "Voice join error"; | ||
| 14 | pub const L_ERR_VOICE_CONTROL: &str = "Voice control error"; | ||
| 15 | pub const L_ERR_IO: &str = "IO error"; | ||
| 16 | pub const L_ERR_CRON: &str = "Cron error"; | ||
| 17 | pub const L_ERR_IMAGE: &str = "Image error"; | ||
| 18 | pub const L_ERR_QR: &str = "QR error"; | ||
| 19 | pub const L_ERR_SCRAPER: &str = "Scraper error"; | ||
| 20 | pub const L_ERR_CONFIG: &str = "Config error"; | ||
| 21 | pub const L_ERR_TASK_JOIN: &str = "Task join error"; | ||
| 22 | |||
| 23 | // ── Common / shared error messages ──────────────────────────────────── | ||
| 24 | |||
| 25 | /// "Nie jestem w gildii" — bot must be invoked inside a guild | ||
| 26 | pub const L_NOT_IN_GUILD: &str = "Nie jestem w gildii"; | ||
| 27 | /// "Nie jesteś na kanale głosowym" — user must be in a voice channel | ||
| 28 | pub const L_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym"; | ||
| 29 | /// "Zły typ kanału" — wrong channel variant | ||
| 30 | pub const L_BAD_CHANNEL_TYPE: &str = "Zły typ kanału"; | ||
| 31 | /// "Grupa triggerów nie znaleziona" — trigger group not found | ||
| 32 | pub const L_TRIGGER_NOT_FOUND: &str = "Grupa triggerów nie znaleziona"; | ||
| 33 | |||
| 34 | // ── AI / senator command ────────────────────────────────────────────── | ||
| 35 | |||
| 36 | pub const SENATOR_SYSTEM_PROMPT: &str = "Jesteś Senatorem Steavenem Sperma Armstrongiem. Jesteś rasistą, seksistą i homofobem. Odpowiadasz w języku polskim. Odpowiedź ma maksymalnie 100 słów. Nie używaj formatowania markdown."; | ||
| 37 | |||
| 38 | pub const L_OPENROUTER_NO_KEY: &str = "Klucz API OpenRouter nie jest skonfigurowany w .env"; | ||
| 39 | |||
| 40 | // ── Cron command ────────────────────────────────────────────────────── | ||
| 41 | |||
| 42 | pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana"; | ||
| 43 | pub const L_CRON_NONE: &str = "Brak zakolejkowanych prac cron"; | ||
| 44 | pub const L_CRON_TITLE: &str = "Prace cron"; | ||
| 45 | pub const L_CRON_ACTIVE: &str = "✅ Aktywna"; | ||
| 46 | pub const L_CRON_PAUSED: &str = "⏸️ Zapauzowana"; | ||
| 47 | |||
| 48 | pub fn fmt_cron_audio_queued(cron: &str) -> String { | ||
| 49 | format!("Praca audio zakolejkowana z cron'em `{cron}`") | ||
| 50 | } | ||
| 51 | pub fn fmt_cron_label_audio(name: &str) -> String { | ||
| 52 | format!("Audio: {name}") | ||
| 53 | } | ||
| 54 | pub fn fmt_cron_label_msg(msg: &str) -> String { | ||
| 55 | format!("Wiadomość: {msg}") | ||
| 56 | } | ||
| 57 | pub fn fmt_cron_job_status(id: i64, status: &str) -> String { | ||
| 58 | format!("ID {id} — {status}") | ||
| 59 | } | ||
| 60 | pub fn fmt_cron_field(cron: &str, chan: i64, desc: &str) -> String { | ||
| 61 | format!("`{cron}` — <#{chan}>\n{desc}") | ||
| 62 | } | ||
| 63 | pub fn fmt_cron_deleted(id: i64) -> String { | ||
| 64 | format!("Praca cron z ID {id} została usunięta") | ||
| 65 | } | ||
| 66 | pub fn fmt_cron_not_found(id: i64) -> String { | ||
| 67 | format!("Praca z ID {id} nie została znaleziona") | ||
| 68 | } | ||
| 69 | pub fn fmt_cron_paused(id: i64) -> String { | ||
| 70 | format!("Praca cron z ID {id} została zapauzowana") | ||
| 71 | } | ||
| 72 | pub fn fmt_cron_resumed(id: i64) -> String { | ||
| 73 | format!("Praca cron z ID {id} została wznowiona") | ||
| 74 | } | ||
| 75 | |||
| 76 | // ── Math command ────────────────────────────────────────────────────── | ||
| 77 | |||
| 78 | pub const L_MATH_TOO_LONG: &str = "Ekspresja za długa (max 1000 znaków"; | ||
| 79 | pub const L_MATH_NOT_ASCII: &str = "Tylko znaki ASCII dozwolone"; | ||
| 80 | pub const L_MATH_UNBALANCED: &str = "Nawiasy są niezbalansowane"; | ||
| 81 | pub const L_MATH_DANGEROUS: &str = "Ekspresja zawiera groźne słowa"; | ||
| 82 | pub const L_MATH_NO_RESULT: &str = "Brak rezultatu"; | ||
| 83 | |||
| 84 | pub fn fmt_math_qalc_err(e: &dyn std::fmt::Display) -> String { | ||
| 85 | format!("qalc nie jest dostępny: {e}") | ||
| 86 | } | ||
| 87 | pub fn fmt_math_result(r: &str) -> String { | ||
| 88 | format!("```\n{r}\n```") | ||
| 89 | } | ||
| 90 | |||
| 91 | // ── Music command ───────────────────────────────────────────────────── | ||
| 92 | |||
| 93 | pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik"; | ||
| 94 | pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty"; | ||
| 95 | pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL"; | ||
| 96 | pub const L_MUSIC_CANT_COMPRESS_LIVE: &str = | ||
| 97 | "Nie można skompresować transmisji na żywo, odtwarzam normalnie"; | ||
| 98 | pub const L_MUSIC_STOPPED: &str = "wyszed"; | ||
| 99 | pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; | ||
| 100 | |||
| 101 | pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String { | ||
| 102 | format!("Szukanie na copyparty zawiodło: {e}") | ||
| 103 | } | ||
| 104 | pub fn fmt_music_playing(name: &str) -> String { | ||
| 105 | format!("Grajcowanie: {name}") | ||
| 106 | } | ||
| 107 | pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String { | ||
| 108 | format!("Dołączon <#{cid}>") | ||
| 109 | } | ||
| 110 | pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String { | ||
| 111 | format!("Nie udało się połączyć z VC: {e}") | ||
| 112 | } | ||
| 113 | |||
| 114 | // ── TTS command ─────────────────────────────────────────────────────── | ||
| 115 | |||
| 116 | pub const L_TTS_SAVED: &str = "TTS zapisany"; | ||
| 117 | pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; | ||
| 118 | |||
| 119 | // ── Trigger command ─────────────────────────────────────────────────── | ||
| 120 | |||
| 121 | pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; | ||
| 122 | pub const L_TRIGGER_KW_NONE: &str = "Brak"; | ||
| 123 | |||
| 124 | pub fn fmt_trigger_created(name: &str) -> String { | ||
| 125 | format!("Grupa trigger '{name}' utworzona") | ||
| 126 | } | ||
| 127 | pub fn fmt_trigger_failed(e: &dyn std::fmt::Display) -> String { | ||
| 128 | format!("Nie udało się: {e}") | ||
| 129 | } | ||
| 130 | pub fn fmt_trigger_deleted(name: &str) -> String { | ||
| 131 | format!("Grupa trigger '{name}' usunięta") | ||
| 132 | } | ||
| 133 | pub fn fmt_trigger_title(name: &str) -> String { | ||
| 134 | format!("Trigger: {name}") | ||
| 135 | } | ||
| 136 | |||
| 137 | // ── Utility commands ────────────────────────────────────────────────── | ||
| 138 | |||
| 139 | pub const L_UTIL_NEED_INPUT: &str = "Podaj plik z copyparty lub załącznik"; | ||
| 140 | pub const L_UTIL_PICK_FROM_AUTOCOMPLETE: &str = "Wybierz plik z podpowiedzi"; | ||
| 141 | pub const L_UTIL_DOWNLOAD_FAILED: &str = "Nie udało się pobrać pliku"; | ||
| 142 | pub const L_UTIL_UNSUPPORTED_TYPE: &str = "Nieobsługiwany typ pliku"; | ||
| 143 | pub const L_UTIL_COMPRESS_FAILED: &str = "Kompresja nie powiodła się"; | ||
| 144 | pub const L_UTIL_CONVERT_FAILED: &str = "Konwersja nie powiodła się"; | ||
| 145 | pub const L_UTIL_UPLOAD_FAILED: &str = "Nie udało się wysłać na serwer"; | ||
| 146 | pub const L_UTIL_NO_ATTACHMENTS: &str = "Brak załączników w wiadomości"; | ||
| 147 | pub const L_SEARCH_NO_RESULTS: &str = "No results found"; | ||
| 148 | |||
| 149 | pub fn fmt_util_compressed(url: &str) -> String { | ||
| 150 | format!("Skompresowano\n{url}") | ||
| 151 | } | ||
| 152 | pub fn fmt_util_converted(url: &str) -> String { | ||
| 153 | format!("Skonwertowano\n{url}") | ||
| 154 | } | ||
| 155 | pub fn fmt_util_convert_err(stderr: &str) -> String { | ||
| 156 | format!("Błąd konwersji: {stderr}") | ||
| 157 | } | ||
| 158 | |||
| 159 | pub fn fmt_uploaded(url: &str) -> String { | ||
| 160 | format!("Wysłon: {url}") | ||
| 161 | } | ||
| 162 | pub fn fmt_download_uploaded(url: &str) -> String { | ||
| 163 | format!("pobioron i wysyłon: {url}") | ||
| 164 | } | ||
| 165 | pub fn fmt_upload_summary(count: usize) -> String { | ||
| 166 | format!("Wysłano {count} plików:\n") | ||
| 167 | } | ||
| 168 | pub fn fmt_upload_partial_failed(count: usize) -> String { | ||
| 169 | format!("\n\nNie wysłano {count} plików") | ||
| 170 | } | ||
| 171 | pub fn fmt_message_compressed(made: usize, total: usize) -> String { | ||
| 172 | format!("Skompresowano {made} z {total} plików:") | ||
| 173 | } | ||
| 174 | pub fn fmt_search_result(name: &str, url: &str) -> String { | ||
| 175 | format!("**{name}**\n{url}") | ||
| 176 | } | ||
| 177 | pub fn fmt_download_failed(stderr: &str) -> String { | ||
| 178 | format!( | ||
| 179 | "Pobieranie się nie powiodło: {}", | ||
| 180 | translate_ytdlp_stderr(stderr) | ||
| 181 | ) | ||
| 182 | } | ||
| 183 | pub fn fmt_ytdlp_err(e: &dyn std::fmt::Display) -> String { | ||
| 184 | format!("yt-dlp nie mógło się uruchomić: {e}") | ||
| 185 | } | ||
| 186 | pub fn fmt_ytdlp_stderr(stderr: &str) -> String { | ||
| 187 | translate_ytdlp_stderr(stderr) | ||
| 188 | } | ||
| 189 | |||
| 190 | /// Translate yt-dlp's verbose stderr into a short, user-friendly Polish | ||
| 191 | /// message. yt-dlp emits lots of `[youtube] Extracting URL…`, `WARNING:` | ||
| 192 | /// and `ERROR:` noise that is meaningless to a normal Discord user; this | ||
| 193 | /// recognises the handful of failure modes users actually hit and falls | ||
| 194 | /// back to the first `ERROR:`/`WARNING:` line (stripped of the | ||
| 195 | /// `ERROR: [extractor] id: ` prefix) when nothing matches, so we never | ||
| 196 | /// show the full multi-line trace. | ||
| 197 | pub fn translate_ytdlp_stderr(stderr: &str) -> String { | ||
| 198 | let lower = stderr.to_ascii_lowercase(); | ||
| 199 | |||
| 200 | // YouTube's anti-bot sign-in wall. yt-dlp can't bypass this without | ||
| 201 | // browser cookies configured on the host — surface that to the user | ||
| 202 | // rather than the cryptic "Sign in to confirm you're not a bot" line. | ||
| 203 | if lower.contains("sign in to confirm you") | ||
| 204 | || lower.contains("confirm you're not a bot") | ||
| 205 | || lower.contains("confirm you are not a bot") | ||
| 206 | { | ||
| 207 | return "YouTube uznaje nas za bota i blokuje pobieranie. Spróbuj ponownie później albo podaj inny link.".into(); | ||
| 208 | } | ||
| 209 | |||
| 210 | // Generic extractor fallback means the URL didn't match any known | ||
| 211 | // site — combined with "Requested format is not available" this is | ||
| 212 | // almost always "the user pasted a non-video URL into /download". | ||
| 213 | if lower.contains("requested format is not available") | ||
| 214 | || lower.contains("url could be a direct video link") | ||
| 215 | || lower.contains("falling back on generic information extractor") | ||
| 216 | { | ||
| 217 | return "Ten link nie wygląda na wideo obsługiwane przez yt-dlp. Upewnij się, że to adres do filmu (YouTube / SoundCloud / Bandcamp / Twitch).".into(); | ||
| 218 | } | ||
| 219 | |||
| 220 | if lower.contains("private video") { | ||
| 221 | return "Ten film jest prywatny i nie można go pobrać.".into(); | ||
| 222 | } | ||
| 223 | if lower.contains("video unavailable") | ||
| 224 | || lower.contains("video has been removed") | ||
| 225 | || lower.contains("this video is unavailable") | ||
| 226 | { | ||
| 227 | return "Ten film jest niedostępny lub został usunięty.".into(); | ||
| 228 | } | ||
| 229 | if lower.contains("members-only") | ||
| 230 | || lower.contains("premium-only") | ||
| 231 | || lower.contains("channel's members") | ||
| 232 | { | ||
| 233 | return "Ten film jest dostępny tylko dla subskrybujących / członków kanału.".into(); | ||
| 234 | } | ||
| 235 | if lower.contains("not available in your country") | ||
| 236 | || lower.contains("geo-restricted") | ||
| 237 | || lower.contains("geo restricted") | ||
| 238 | { | ||
| 239 | return "Ten film jest zablokowany regionalnie i nie jest dostępny w naszym regionie." | ||
| 240 | .into(); | ||
| 241 | } | ||
| 242 | if lower.contains("age-restricted") | ||
| 243 | || lower.contains("age restricted") | ||
| 244 | || lower.contains("inappropriate for some users") | ||
| 245 | { | ||
| 246 | return "Ten film jest ograniczony wiekowo i wymaga uwierzytelnienia.".into(); | ||
| 247 | } | ||
| 248 | if lower.contains("http error 404") || lower.contains(" http error 404 ") { | ||
| 249 | return "Nie znaleziono takiego adresu (404).".into(); | ||
| 250 | } | ||
| 251 | if lower.contains("unable to extract") && lower.contains("video data") { | ||
| 252 | return "Nie udało się wyciągnąć danych filmu — strona mogła się zmienić albo link jest niepełny.".into(); | ||
| 253 | } | ||
| 254 | |||
| 255 | // Fallback: keep only the first ERROR: line and strip the | ||
| 256 | // `ERROR: [extractor] id: ` prefix so the message is short and | ||
| 257 | // free of yt-dlp internals. | ||
| 258 | for line in stderr.lines() { | ||
| 259 | if let Some(rest) = line.strip_prefix("ERROR:") { | ||
| 260 | let rest = rest.trim(); | ||
| 261 | // Drop leading `[extractor] video_id: ` noise if present. | ||
| 262 | let msg = rest | ||
| 263 | .strip_prefix(|c: char| c == '[') | ||
| 264 | .and_then(|r| r.find(']').map(|i| &r[i + 1..])) | ||
| 265 | .map(|s| { | ||
| 266 | s.trim_start_matches(|c: char| { | ||
| 267 | c.is_alphanumeric() || c == '_' || c == '-' || c == ':' | ||
| 268 | }) | ||
| 269 | .trim() | ||
| 270 | }) | ||
| 271 | .filter(|s| !s.is_empty()) | ||
| 272 | .unwrap_or(rest); | ||
| 273 | return format!("Pobieranie się nie powiodło: {msg}"); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | for line in stderr.lines() { | ||
| 277 | if let Some(rest) = line.strip_prefix("WARNING:") { | ||
| 278 | return format!("Pobieranie się nie powiodło: {}", rest.trim()); | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | "Pobieranie się nie powiodło.".into() | ||
| 283 | } | ||
| 284 | |||
| 285 | // ── Web command ─────────────────────────────────────────────────────── | ||
| 286 | |||
| 287 | pub const L_WEB_NO_RESULTS: &str = "Brak rezultatów"; | ||
| 288 | |||
| 289 | pub fn fmt_web_title(query: &str) -> String { | ||
| 290 | format!("Wyniki wyszukiwania dla: {query}") | ||
| 291 | } | ||
| 292 | |||
| 293 | // ── QR command ──────────────────────────────────────────────────────── | ||
| 294 | |||
| 295 | pub const L_QR_GENERATED: &str = "Kod QR wygenerowany:"; | ||
| 296 | pub const L_QR_DECODE_FAILED: &str = "Nie udało się zdekodować kodu QR."; | ||
| 297 | |||
| 298 | pub fn fmt_qr_decoded(text: &str) -> String { | ||
| 299 | format!("```\n{text}\n```") | ||
| 300 | } | ||
| 301 | |||
| 302 | // ── Copyparty client ────────────────────────────────────────────────── | ||
| 303 | |||
| 304 | pub const L_CP_UPLOAD_NO_URL: &str = "Wysłanie zawiodło, brak URL w odpowiedzi"; | ||
| 305 | |||
| 306 | pub fn fmt_cp_search_http(status: &dyn std::fmt::Display, snippet: &str) -> String { | ||
| 307 | format!("copyparty zwróciło status HTTP {status}: {snippet}") | ||
| 308 | } | ||
| 309 | pub fn fmt_cp_invalid_json(e: &dyn std::fmt::Display, snippet: &str) -> String { | ||
| 310 | format!("copyparty zwróciło nielegalny JSON. ({e}): {snippet}") | ||
| 311 | } | ||
| 312 | |||
| 313 | // ── OpenRouter ──────────────────────────────────────────────────────── | ||
| 314 | |||
| 315 | pub fn fmt_openrouter_err(status: &dyn std::fmt::Display, text: &str) -> String { | ||
| 316 | format!("Błąd API OpenRouter. {status}: {text}") | ||
| 317 | } | ||
| 318 | |||
| 319 | // ── Voice / audio decoding ──────────────────────────────────────────── | ||
| 320 | |||
| 321 | pub const L_VOICE_NO_TRACK: &str = "Brak kawałka"; | ||
| 322 | pub const L_VOICE_NO_CODEC: &str = "Brak parametrów codec'a audio"; | ||
| 323 | |||
| 324 | pub fn fmt_voice_remove_failed(e: &dyn std::fmt::Display) -> String { | ||
| 325 | format!("Nie udało się usunąć klienta głosowego: {e}") | ||
| 326 | } | ||
| 327 | pub fn fmt_voice_probe_failed(e: &dyn std::fmt::Display) -> String { | ||
| 328 | format!("Próbkowanie symphonia zawiodło: {e}") | ||
| 329 | } | ||
| 330 | pub fn fmt_voice_decoder_failed(e: &dyn std::fmt::Display) -> String { | ||
| 331 | format!("Nie udało się utworzyć dekodera audio: {e}") | ||
| 332 | } | ||
| 333 | pub fn fmt_voice_ytdlp_launch(e: &dyn std::fmt::Display) -> String { | ||
| 334 | format!("yt-dlp nie mogło się uruchomić: {e}") | ||
| 335 | } | ||
| 336 | pub fn fmt_voice_ffmpeg_launch(e: &dyn std::fmt::Display) -> String { | ||
| 337 | format!("ffmpeg nie mogło się uruchomić: {e}") | ||
| 338 | } | ||
| 339 | |||
| 340 | // ── Events ──────────────────────────────────────────────────────────── | ||
| 341 | |||
| 342 | pub const L_EV_READY: &str = "ŁUKASZ SPERMA AKTYWACJA"; | ||
| 343 | pub const L_EV_VOICE_STATE_ERROR: &str = "Błąd stanu głosu"; | ||
| 344 | pub const L_EV_MESSAGE_HANDLER_ERROR: &str = "Błąd ustanawiacza głosu"; | ||
| 345 | |||
| 346 | // ── Event TTS phrases ───────────────────────────────────────────────── | ||
| 347 | |||
| 348 | pub fn fmt_ev_joined(name: &str) -> String { | ||
| 349 | format!("{name} sie zespawnił") | ||
| 350 | } | ||
| 351 | pub fn fmt_ev_left(name: &str) -> String { | ||
| 352 | format!("{name} jest chujem") | ||
| 353 | } | ||
| 354 | pub fn fmt_ev_deafened(name: &str) -> String { | ||
| 355 | format!("{name} jest bity przez ojca") | ||
| 356 | } | ||
| 357 | pub fn fmt_ev_undeafened(name: &str) -> String { | ||
| 358 | format!("{name} uciekł od ojca") | ||
| 359 | } | ||
| 360 | pub fn fmt_ev_muted(name: &str) -> String { | ||
| 361 | format!("{name} idzie robić jajecznice") | ||
| 362 | } | ||
| 363 | pub fn fmt_ev_unmuted(name: &str) -> String { | ||
| 364 | format!("{name} zrobił jajecznice") | ||
| 365 | } | ||
| 366 | |||
| 367 | // ── AI assistant (events/message handler) ───────────────────────────── | ||
| 368 | |||
| 369 | pub const ASSISTANT_SYSTEM_PROMPT: &str = | ||
| 370 | "Jesteś asystentem. Odpowiadasz po polsku, jesteś pomocny, nie używasz emoji."; | ||
| 371 | |||
| 372 | // ── Convenience helpers for repeated Error::Custom patterns ──────────── | ||
| 373 | |||
| 374 | use crate::error::Error; | ||
| 375 | |||
| 376 | pub fn err_not_in_guild() -> Error { | ||
| 377 | Error::Custom(L_NOT_IN_GUILD.into()) | ||
| 378 | } | ||
| 379 | pub fn err_not_in_voice() -> Error { | ||
| 380 | Error::Custom(L_NOT_IN_VOICE.into()) | ||
| 381 | } | ||
| 382 | pub fn err_trigger_not_found() -> Error { | ||
| 383 | Error::Custom(L_TRIGGER_NOT_FOUND.into()) | ||
| 384 | } | ||
| 385 | pub fn err_bad_channel_type() -> Error { | ||
| 386 | Error::Custom(L_BAD_CHANNEL_TYPE.into()) | ||
| 387 | } | ||
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4a89315 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,190 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 2 | |||
| 3 | use poise::serenity_prelude as serenity; | ||
| 4 | use songbird::SerenityInit; | ||
| 5 | use tracing_subscriber::filter::EnvFilter; | ||
| 6 | |||
| 7 | mod commands; | ||
| 8 | mod config; | ||
| 9 | mod copyparty; | ||
| 10 | mod database; | ||
| 11 | mod error; | ||
| 12 | mod events; | ||
| 13 | mod lang; | ||
| 14 | mod openrouter; | ||
| 15 | mod tasks; | ||
| 16 | mod voice; | ||
| 17 | |||
| 18 | use database::DbPool; | ||
| 19 | |||
| 20 | pub struct Data { | ||
| 21 | pub db_pool: DbPool, | ||
| 22 | pub config: config::Config, | ||
| 23 | pub voice_manager: voice::VoiceManager, | ||
| 24 | pub copyparty: copyparty::CopypartyClient, | ||
| 25 | pub http_client: reqwest::Client, | ||
| 26 | pub volume: std::sync::atomic::AtomicU64, | ||
| 27 | pub triggers: tokio::sync::Mutex<Vec<(database::Trigger, Vec<String>, Vec<String>)>>, | ||
| 28 | /// Autocomplete lookup, keyed per-user to avoid cross-command collisions. | ||
| 29 | /// | ||
| 30 | /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users | ||
| 31 | /// search the same filename simultaneously, each invocation must resolve its | ||
| 32 | /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`. | ||
| 33 | pub ac_cache: tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>, | ||
| 34 | /// Active music track per guild (only one music source at a time). | ||
| 35 | pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>, | ||
| 36 | } | ||
| 37 | |||
| 38 | impl Data { | ||
| 39 | pub async fn reload_triggers(&self) { | ||
| 40 | let triggers = database::load_all_triggers_with_keywords_and_responses(&self.db_pool) | ||
| 41 | .await | ||
| 42 | .unwrap_or_default(); | ||
| 43 | *self.triggers.lock().await = triggers; | ||
| 44 | } | ||
| 45 | |||
| 46 | pub async fn ac_stash( | ||
| 47 | &self, | ||
| 48 | user_id: serenity::UserId, | ||
| 49 | key: String, | ||
| 50 | hit: copyparty::SearchHit, | ||
| 51 | ) { | ||
| 52 | self.ac_cache.lock().await.insert((user_id, key), hit); | ||
| 53 | } | ||
| 54 | |||
| 55 | pub async fn ac_lookup( | ||
| 56 | &self, | ||
| 57 | user_id: serenity::UserId, | ||
| 58 | key: &str, | ||
| 59 | ) -> Option<copyparty::SearchHit> { | ||
| 60 | self.ac_cache | ||
| 61 | .lock() | ||
| 62 | .await | ||
| 63 | .get(&(user_id, key.to_string())) | ||
| 64 | .cloned() | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | #[allow(dead_code)] | ||
| 69 | type Error = error::Error; | ||
| 70 | |||
| 71 | #[tokio::main] | ||
| 72 | async fn main() { | ||
| 73 | let _ = rustls::crypto::ring::default_provider().install_default(); | ||
| 74 | |||
| 75 | tracing_subscriber::fmt() | ||
| 76 | .with_env_filter( | ||
| 77 | EnvFilter::builder() | ||
| 78 | .parse(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into())) | ||
| 79 | .unwrap(), | ||
| 80 | ) | ||
| 81 | .init(); | ||
| 82 | |||
| 83 | dotenvy::dotenv().ok(); | ||
| 84 | config::check_env_vars(); | ||
| 85 | let config = config::Config::from_env(); | ||
| 86 | |||
| 87 | let db_pool = database::init_pool(&config.database_path) | ||
| 88 | .await | ||
| 89 | .expect("Failed to initialize database"); | ||
| 90 | |||
| 91 | let http_client = reqwest::Client::new(); | ||
| 92 | |||
| 93 | let copyparty = copyparty::CopypartyClient::new( | ||
| 94 | config.copyparty_url.clone(), | ||
| 95 | config.copyparty_url_upload.clone(), | ||
| 96 | config.copyparty_password.clone(), | ||
| 97 | ); | ||
| 98 | |||
| 99 | let triggers = database::load_all_triggers_with_keywords_and_responses(&db_pool) | ||
| 100 | .await | ||
| 101 | .unwrap_or_default(); | ||
| 102 | |||
| 103 | let commands_list = commands::all_commands(); | ||
| 104 | let config_for_client = config.clone(); | ||
| 105 | |||
| 106 | let framework = poise::Framework::builder() | ||
| 107 | .options(poise::FrameworkOptions { | ||
| 108 | commands: commands_list, | ||
| 109 | event_handler: |ctx, event, _framework, user_data| { | ||
| 110 | Box::pin(async move { | ||
| 111 | handle_event(ctx, event, user_data).await; | ||
| 112 | Ok(()) | ||
| 113 | }) | ||
| 114 | }, | ||
| 115 | ..Default::default() | ||
| 116 | }) | ||
| 117 | .setup(move |ctx, _ready, framework| { | ||
| 118 | let config = config.clone(); | ||
| 119 | let db_pool = db_pool.clone(); | ||
| 120 | let http_client = http_client.clone(); | ||
| 121 | let copyparty = copyparty.clone(); | ||
| 122 | let triggers = triggers.clone(); | ||
| 123 | Box::pin(async move { | ||
| 124 | let vm = voice::VoiceManager::new(ctx).await; | ||
| 125 | |||
| 126 | let data = Data { | ||
| 127 | config, | ||
| 128 | db_pool, | ||
| 129 | voice_manager: vm, | ||
| 130 | copyparty, | ||
| 131 | http_client, | ||
| 132 | volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()), | ||
| 133 | triggers: tokio::sync::Mutex::new(triggers), | ||
| 134 | ac_cache: tokio::sync::Mutex::new(HashMap::new()), | ||
| 135 | music_tracks: tokio::sync::Mutex::new(HashMap::new()), | ||
| 136 | }; | ||
| 137 | |||
| 138 | // Spawn cron scheduler loop. | ||
| 139 | { | ||
| 140 | let cron_ctx = crate::tasks::CronContext { | ||
| 141 | pool: data.db_pool.clone(), | ||
| 142 | http: ctx.http.clone(), | ||
| 143 | voice_manager: data.voice_manager.clone(), | ||
| 144 | copyparty: data.copyparty.clone(), | ||
| 145 | }; | ||
| 146 | tokio::spawn(async move { | ||
| 147 | crate::tasks::cron_scheduler_loop(cron_ctx).await; | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | poise::builtins::register_globally(ctx, &framework.options().commands).await?; | ||
| 152 | |||
| 153 | events::handle_ready(ctx).await.map_err(|e| { | ||
| 154 | tracing::error!("on_ready handler: {e}"); | ||
| 155 | e | ||
| 156 | })?; | ||
| 157 | |||
| 158 | Ok(data) | ||
| 159 | }) | ||
| 160 | }) | ||
| 161 | .build(); | ||
| 162 | |||
| 163 | let mut client = | ||
| 164 | serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all()) | ||
| 165 | .framework(framework) | ||
| 166 | .register_songbird() | ||
| 167 | .await | ||
| 168 | .expect("Error creating client"); | ||
| 169 | |||
| 170 | client.start().await.expect("Error starting client"); | ||
| 171 | } | ||
| 172 | |||
| 173 | async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) { | ||
| 174 | match event { | ||
| 175 | serenity::FullEvent::Ready { | ||
| 176 | data_about_bot: _, .. | ||
| 177 | } => {} | ||
| 178 | serenity::FullEvent::VoiceStateUpdate { old, new } => { | ||
| 179 | events::handle_voice_state_update(ctx, user_data, old.clone(), new) | ||
| 180 | .await | ||
| 181 | .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_VOICE_STATE_ERROR)); | ||
| 182 | } | ||
| 183 | serenity::FullEvent::Message { new_message } => { | ||
| 184 | events::handle_message(ctx, user_data, new_message) | ||
| 185 | .await | ||
| 186 | .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_MESSAGE_HANDLER_ERROR)); | ||
| 187 | } | ||
| 188 | _ => {} | ||
| 189 | } | ||
| 190 | } | ||
diff --git a/src/openrouter/mod.rs b/src/openrouter/mod.rs new file mode 100644 index 0000000..31cee85 --- /dev/null +++ b/src/openrouter/mod.rs | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | use reqwest::Client; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | |||
| 4 | use crate::error::Result; | ||
| 5 | use crate::lang; | ||
| 6 | |||
| 7 | pub struct OpenRouterClient { | ||
| 8 | api_key: String, | ||
| 9 | model: String, | ||
| 10 | client: Client, | ||
| 11 | } | ||
| 12 | |||
| 13 | #[derive(Serialize)] | ||
| 14 | struct ChatRequest { | ||
| 15 | model: String, | ||
| 16 | messages: Vec<Message>, | ||
| 17 | max_tokens: u32, | ||
| 18 | } | ||
| 19 | |||
| 20 | #[derive(Serialize)] | ||
| 21 | struct Message { | ||
| 22 | role: String, | ||
| 23 | content: String, | ||
| 24 | } | ||
| 25 | |||
| 26 | #[derive(Deserialize)] | ||
| 27 | struct ChatResponse { | ||
| 28 | choices: Vec<Choice>, | ||
| 29 | } | ||
| 30 | |||
| 31 | #[derive(Deserialize)] | ||
| 32 | struct Choice { | ||
| 33 | message: MessageContent, | ||
| 34 | } | ||
| 35 | |||
| 36 | #[derive(Deserialize)] | ||
| 37 | struct MessageContent { | ||
| 38 | content: String, | ||
| 39 | } | ||
| 40 | |||
| 41 | impl OpenRouterClient { | ||
| 42 | pub fn new(api_key: String, model: String) -> Self { | ||
| 43 | Self { | ||
| 44 | api_key, | ||
| 45 | model, | ||
| 46 | client: Client::new(), | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result<String> { | ||
| 51 | let body = ChatRequest { | ||
| 52 | model: self.model.clone(), | ||
| 53 | messages: vec![ | ||
| 54 | Message { | ||
| 55 | role: "system".into(), | ||
| 56 | content: system_prompt.to_string(), | ||
| 57 | }, | ||
| 58 | Message { | ||
| 59 | role: "user".into(), | ||
| 60 | content: user_message.to_string(), | ||
| 61 | }, | ||
| 62 | ], | ||
| 63 | max_tokens: 500, | ||
| 64 | }; | ||
| 65 | |||
| 66 | let resp = self | ||
| 67 | .client | ||
| 68 | .post("https://openrouter.ai/api/v1/chat/completions") | ||
| 69 | .header("Authorization", format!("Bearer {}", self.api_key)) | ||
| 70 | .json(&body) | ||
| 71 | .send() | ||
| 72 | .await?; | ||
| 73 | |||
| 74 | if !resp.status().is_success() { | ||
| 75 | let status = resp.status(); | ||
| 76 | let text = resp.text().await.unwrap_or_default(); | ||
| 77 | return Err(crate::error::Error::Custom(lang::fmt_openrouter_err( | ||
| 78 | &status, &text, | ||
| 79 | ))); | ||
| 80 | } | ||
| 81 | |||
| 82 | let chat_resp: ChatResponse = resp.json().await?; | ||
| 83 | Ok(chat_resp | ||
| 84 | .choices | ||
| 85 | .into_iter() | ||
| 86 | .next() | ||
| 87 | .map(|c| c.message.content) | ||
| 88 | .unwrap_or_default()) | ||
| 89 | } | ||
| 90 | |||
| 91 | pub async fn chat_with_history( | ||
| 92 | &self, | ||
| 93 | system_prompt: &str, | ||
| 94 | messages: Vec<(String, String)>, // (role, content) pairs | ||
| 95 | ) -> Result<String> { | ||
| 96 | let mut msg_vec = vec![Message { | ||
| 97 | role: "system".into(), | ||
| 98 | content: system_prompt.to_string(), | ||
| 99 | }]; | ||
| 100 | for (role, content) in messages { | ||
| 101 | msg_vec.push(Message { role, content }); | ||
| 102 | } | ||
| 103 | |||
| 104 | let body = ChatRequest { | ||
| 105 | model: self.model.clone(), | ||
| 106 | messages: msg_vec, | ||
| 107 | max_tokens: 500, | ||
| 108 | }; | ||
| 109 | |||
| 110 | let resp = self | ||
| 111 | .client | ||
| 112 | .post("https://openrouter.ai/api/v1/chat/completions") | ||
| 113 | .header("Authorization", format!("Bearer {}", self.api_key)) | ||
| 114 | .json(&body) | ||
| 115 | .send() | ||
| 116 | .await?; | ||
| 117 | |||
| 118 | if !resp.status().is_success() { | ||
| 119 | let status = resp.status(); | ||
| 120 | let text = resp.text().await.unwrap_or_default(); | ||
| 121 | return Err(crate::error::Error::Custom(lang::fmt_openrouter_err( | ||
| 122 | &status, &text, | ||
| 123 | ))); | ||
| 124 | } | ||
| 125 | |||
| 126 | let chat_resp: ChatResponse = resp.json().await?; | ||
| 127 | Ok(chat_resp | ||
| 128 | .choices | ||
| 129 | .into_iter() | ||
| 130 | .next() | ||
| 131 | .map(|c| c.message.content) | ||
| 132 | .unwrap_or_default()) | ||
| 133 | } | ||
| 134 | } | ||
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs new file mode 100644 index 0000000..f3f8811 --- /dev/null +++ b/src/tasks/mod.rs | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use chrono::{Duration, Timelike, Utc}; | ||
| 4 | use cron::Schedule; | ||
| 5 | use poise::serenity_prelude as serenity; | ||
| 6 | use std::str::FromStr; | ||
| 7 | use tokio::time::sleep; | ||
| 8 | use tracing::{error, info}; | ||
| 9 | |||
| 10 | use crate::copyparty::CopypartyClient; | ||
| 11 | use crate::database::{self, DbPool}; | ||
| 12 | use crate::voice::VoiceManager; | ||
| 13 | |||
| 14 | /// Shared resources the cron scheduler needs. | ||
| 15 | #[derive(Clone)] | ||
| 16 | pub struct CronContext { | ||
| 17 | pub pool: DbPool, | ||
| 18 | pub http: Arc<serenity::Http>, | ||
| 19 | pub voice_manager: VoiceManager, | ||
| 20 | pub copyparty: CopypartyClient, | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. | ||
| 24 | /// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow). | ||
| 25 | fn parse_cron(raw: &str) -> Result<Schedule, cron::error::Error> { | ||
| 26 | let fields: Vec<&str> = raw.split_whitespace().collect(); | ||
| 27 | let normalized = match fields.len() { | ||
| 28 | 7 => raw.to_string(), | ||
| 29 | 6 => format!("{} *", raw), // sec min hour dom month dow → +year | ||
| 30 | 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year | ||
| 31 | _ => raw.to_string(), // let the crate reject it | ||
| 32 | }; | ||
| 33 | Schedule::from_str(&normalized) | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Check window: look for cron fires in the last WINDOW_SECONDS. | ||
| 37 | /// 61s gives 1s overlap with 60s tick to avoid missing exact-boundary fires. | ||
| 38 | const WINDOW_SECONDS: i64 = 61; | ||
| 39 | /// Tick interval for the scheduler loop. | ||
| 40 | const TICK_SECONDS: u64 = 60; | ||
| 41 | |||
| 42 | pub async fn check_and_execute_jobs(ctx: &CronContext) { | ||
| 43 | let jobs = match database::list_active_cron_jobs(&ctx.pool).await { | ||
| 44 | Ok(jobs) => jobs, | ||
| 45 | Err(e) => { | ||
| 46 | error!("Failed to load cron jobs: {e}"); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | }; | ||
| 50 | |||
| 51 | let now = Utc::now(); | ||
| 52 | let window_start = now - Duration::seconds(WINDOW_SECONDS); | ||
| 53 | |||
| 54 | for job in jobs { | ||
| 55 | let schedule = match parse_cron(&job.cron_expression) { | ||
| 56 | Ok(s) => s, | ||
| 57 | Err(e) => { | ||
| 58 | error!("Invalid cron expression '{}': {e}", job.cron_expression); | ||
| 59 | continue; | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | |||
| 63 | // Check if any fire time fell within the last WINDOW_SECONDS. | ||
| 64 | let mut has_fired = false; | ||
| 65 | for next_fire in schedule.after(&window_start) { | ||
| 66 | if next_fire > now { | ||
| 67 | break; | ||
| 68 | } | ||
| 69 | has_fired = true; | ||
| 70 | break; // one fire per window is enough | ||
| 71 | } | ||
| 72 | |||
| 73 | if !has_fired { | ||
| 74 | continue; | ||
| 75 | } | ||
| 76 | |||
| 77 | info!("Executing cron job {}", job.id); | ||
| 78 | match job.r#type.as_str() { | ||
| 79 | "audio" => execute_audio_job(ctx, &job).await, | ||
| 80 | "wiadomość" => execute_message_job(ctx, &job).await, | ||
| 81 | _ => {} | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) { | ||
| 87 | let Some(ref msg_text) = job.message else { | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); | ||
| 91 | if let Err(e) = channel_id.say(&ctx.http, msg_text).await { | ||
| 92 | error!("Cron job {} failed to send message: {e}", job.id); | ||
| 93 | } else { | ||
| 94 | info!("Cron job {} sent message to {}", job.id, job.channel_id); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { | ||
| 99 | let Some(ref audio_path) = job.audio_filename else { | ||
| 100 | error!("Cron audio job {} has no audio_filename", job.id); | ||
| 101 | return; | ||
| 102 | }; | ||
| 103 | |||
| 104 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); | ||
| 105 | |||
| 106 | // Resolve guild + build URL concurrently. | ||
| 107 | let url = ctx.copyparty.build_url(audio_path); | ||
| 108 | info!("Cron audio job {} streaming via ffmpeg: {url}", job.id); | ||
| 109 | let guild_channel_fut = channel_id.to_channel(&ctx.http); | ||
| 110 | let input_fut = crate::voice::ffmpeg_stream_input(&url); | ||
| 111 | |||
| 112 | let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut); | ||
| 113 | |||
| 114 | let guild_id = match channel_result { | ||
| 115 | Ok(serenity::Channel::Guild(ch)) => ch.guild_id, | ||
| 116 | other => { | ||
| 117 | error!( | ||
| 118 | "Cron audio job {}: channel {} is not a guild voice channel ({:?})", | ||
| 119 | job.id, | ||
| 120 | job.channel_id, | ||
| 121 | other.map(|_| ()) | ||
| 122 | ); | ||
| 123 | return; | ||
| 124 | } | ||
| 125 | }; | ||
| 126 | |||
| 127 | let input = match input_result { | ||
| 128 | Ok(i) => i, | ||
| 129 | Err(e) => { | ||
| 130 | error!( | ||
| 131 | "Cron audio job {} ffmpeg stream failed ({url}): {e}", | ||
| 132 | job.id | ||
| 133 | ); | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | }; | ||
| 137 | |||
| 138 | // Connect to voice and play. | ||
| 139 | let call = match ctx.voice_manager.connect(guild_id, channel_id).await { | ||
| 140 | Ok(c) => c, | ||
| 141 | Err(e) => { | ||
| 142 | error!("Cron audio job {} voice connect failed: {e}", job.id); | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | }; | ||
| 146 | |||
| 147 | { | ||
| 148 | let mut call_lock = call.lock().await; | ||
| 149 | call_lock.play_input(input); | ||
| 150 | } | ||
| 151 | |||
| 152 | info!( | ||
| 153 | "Cron audio job {} playing '{}' (url: {url}) in guild {}", | ||
| 154 | job.id, audio_path, guild_id | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | pub async fn cron_scheduler_loop(ctx: CronContext) { | ||
| 159 | // Align first tick to the start of the next minute, then run every TICK_SECONDS. | ||
| 160 | let now = Utc::now(); | ||
| 161 | let next_minute = (now + Duration::minutes(1)) | ||
| 162 | .with_second(0) | ||
| 163 | .and_then(|t| t.with_nanosecond(0)) | ||
| 164 | .unwrap_or(now); | ||
| 165 | let initial_delay = (next_minute - now).to_std().unwrap_or_default(); | ||
| 166 | sleep(initial_delay).await; | ||
| 167 | |||
| 168 | loop { | ||
| 169 | check_and_execute_jobs(&ctx).await; | ||
| 170 | sleep(std::time::Duration::from_secs(TICK_SECONDS)).await; | ||
| 171 | } | ||
| 172 | } | ||
diff --git a/src/voice/mod.rs b/src/voice/mod.rs new file mode 100644 index 0000000..0d5b89e --- /dev/null +++ b/src/voice/mod.rs | |||
| @@ -0,0 +1,404 @@ | |||
| 1 | use std::io::Cursor; | ||
| 2 | use std::pin::Pin; | ||
| 3 | use std::sync::Arc; | ||
| 4 | use std::task::{Context, Poll}; | ||
| 5 | use std::time::Duration; | ||
| 6 | |||
| 7 | use serenity::all::{ChannelId, GuildId}; | ||
| 8 | use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter}; | ||
| 9 | use songbird::{Call, Songbird}; | ||
| 10 | use symphonia::core::audio::sample::Sample; | ||
| 11 | use symphonia::core::codecs::audio::AudioDecoderOptions; | ||
| 12 | use symphonia::core::formats::probe::Hint; | ||
| 13 | use symphonia::core::formats::{FormatOptions, TrackType}; | ||
| 14 | use symphonia::core::io::MediaSourceStream; | ||
| 15 | use symphonia::core::meta::MetadataOptions; | ||
| 16 | use tokio::sync::Mutex; | ||
| 17 | use tracing::{info, warn}; | ||
| 18 | |||
| 19 | use crate::error::{Error, Result}; | ||
| 20 | use crate::lang; | ||
| 21 | |||
| 22 | #[derive(Clone)] | ||
| 23 | pub struct VoiceManager { | ||
| 24 | pub songbird: Arc<Songbird>, | ||
| 25 | } | ||
| 26 | |||
| 27 | impl VoiceManager { | ||
| 28 | pub async fn new(ctx: &serenity::client::Context) -> Self { | ||
| 29 | Self { | ||
| 30 | songbird: songbird::get(ctx) | ||
| 31 | .await | ||
| 32 | .expect("Songbird not registered via register_songbird()"), | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | pub async fn connect( | ||
| 37 | &self, | ||
| 38 | guild_id: GuildId, | ||
| 39 | channel_id: ChannelId, | ||
| 40 | ) -> Result<Arc<Mutex<Call>>> { | ||
| 41 | let mut last_err = None; | ||
| 42 | for attempt in 1..=3 { | ||
| 43 | match self.songbird.join(guild_id, channel_id).await { | ||
| 44 | Ok(call) => { | ||
| 45 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 46 | return Ok(call); | ||
| 47 | } | ||
| 48 | Err(e) => { | ||
| 49 | warn!( | ||
| 50 | "Voice join attempt {attempt}/3 failed for guild {}: {e}", | ||
| 51 | guild_id | ||
| 52 | ); | ||
| 53 | if let songbird::error::JoinError::Driver(ref driver_err) = e { | ||
| 54 | warn!("Driver error details: {driver_err:?}"); | ||
| 55 | } | ||
| 56 | last_err = Some(e); | ||
| 57 | tokio::time::sleep(Duration::from_secs(1)).await; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | Err(Error::SongbirdJoin(last_err.unwrap())) | ||
| 62 | } | ||
| 63 | |||
| 64 | pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { | ||
| 65 | self.songbird | ||
| 66 | .remove(guild_id) | ||
| 67 | .await | ||
| 68 | .map_err(|e| Error::Custom(lang::fmt_voice_remove_failed(&e)))?; | ||
| 69 | Ok(()) | ||
| 70 | } | ||
| 71 | |||
| 72 | pub fn get_call(&self, guild_id: GuildId) -> Option<Arc<Mutex<Call>>> { | ||
| 73 | self.songbird.get(guild_id) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | // ── Audio decoding ───────────────────────────────────────────────────── | ||
| 78 | |||
| 79 | /// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird. | ||
| 80 | /// Supports mp3, m4a/aac, flac, ogg, wav, opus via symphonia auto-detection. | ||
| 81 | pub fn decode_audio(data: &[u8]) -> Result<songbird::input::Input> { | ||
| 82 | let mss = MediaSourceStream::new(Box::new(Cursor::new(data.to_vec())), Default::default()); | ||
| 83 | decode_stream(mss) | ||
| 84 | } | ||
| 85 | |||
| 86 | fn decode_stream(mss: MediaSourceStream) -> Result<songbird::input::Input> { | ||
| 87 | let hint = Hint::new(); | ||
| 88 | let fmt_opts = FormatOptions::default(); | ||
| 89 | let meta_opts = MetadataOptions::default(); | ||
| 90 | |||
| 91 | let mut format = symphonia::default::get_probe() | ||
| 92 | .probe(&hint, mss, fmt_opts, meta_opts) | ||
| 93 | .map_err(|e| Error::Custom(lang::fmt_voice_probe_failed(&e)))?; | ||
| 94 | |||
| 95 | let track = format | ||
| 96 | .default_track(TrackType::Audio) | ||
| 97 | .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_TRACK.into()))?; | ||
| 98 | let track_id = track.id; | ||
| 99 | |||
| 100 | let codec_params = track | ||
| 101 | .codec_params | ||
| 102 | .as_ref() | ||
| 103 | .and_then(|p| p.audio()) | ||
| 104 | .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_CODEC.into()))?; | ||
| 105 | |||
| 106 | let sample_rate = codec_params.sample_rate.unwrap_or(48000); | ||
| 107 | let channels = codec_params | ||
| 108 | .channels | ||
| 109 | .clone() | ||
| 110 | .map(|c| c.count() as u32) | ||
| 111 | .unwrap_or(2); | ||
| 112 | |||
| 113 | let dec_opts = AudioDecoderOptions::default(); | ||
| 114 | let mut decoder = symphonia::default::get_codecs() | ||
| 115 | .make_audio_decoder(codec_params, &dec_opts) | ||
| 116 | .map_err(|e| Error::Custom(lang::fmt_voice_decoder_failed(&e)))?; | ||
| 117 | |||
| 118 | let mut pcm_f32: Vec<f32> = Vec::new(); | ||
| 119 | |||
| 120 | loop { | ||
| 121 | match format.next_packet() { | ||
| 122 | Ok(Some(packet)) => { | ||
| 123 | if packet.track_id != track_id { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | if let Ok(buf) = decoder.decode(&packet) { | ||
| 127 | let n = buf.samples_interleaved(); | ||
| 128 | let start = pcm_f32.len(); | ||
| 129 | pcm_f32.resize(start + n, f32::MID); | ||
| 130 | buf.copy_to_slice_interleaved(&mut pcm_f32[start..]); | ||
| 131 | } | ||
| 132 | } | ||
| 133 | Ok(None) => break, | ||
| 134 | Err(_) => break, | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | let pcm_bytes: Vec<u8> = pcm_f32.iter().flat_map(|&s| s.to_le_bytes()).collect(); | ||
| 139 | let cursor = Cursor::new(pcm_bytes); | ||
| 140 | let adapter = RawAdapter::new(cursor, sample_rate, channels); | ||
| 141 | |||
| 142 | Ok(adapter.into()) | ||
| 143 | } | ||
| 144 | |||
| 145 | // ── yt-dlp helper ────────────────────────────────────────────────────── | ||
| 146 | |||
| 147 | /// Download audio via yt-dlp, collect stdout into memory, return the raw bytes. | ||
| 148 | /// Works for both finite media and live streams (yt-dlp handles the difference). | ||
| 149 | pub async fn ytdlp_download(url: &str) -> Result<Vec<u8>> { | ||
| 150 | let output = tokio::process::Command::new("yt-dlp") | ||
| 151 | .args(["-f", "bestaudio", "-o", "-", "--no-playlist", url]) | ||
| 152 | .stdout(std::process::Stdio::piped()) | ||
| 153 | .stderr(std::process::Stdio::piped()) | ||
| 154 | .output() | ||
| 155 | .await | ||
| 156 | .map_err(|e| Error::Custom(lang::fmt_voice_ytdlp_launch(&e)))?; | ||
| 157 | |||
| 158 | if !output.status.success() { | ||
| 159 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 160 | // Keep the full yt-dlp trace in the logs for debugging — the user | ||
| 161 | // only ever sees the short, translated `fmt_ytdlp_stderr` message. | ||
| 162 | warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); | ||
| 163 | return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr))); | ||
| 164 | } | ||
| 165 | |||
| 166 | Ok(output.stdout) | ||
| 167 | } | ||
| 168 | |||
| 169 | // ── Compression helpers ─────────────────────────────────────────────── | ||
| 170 | |||
| 171 | /// Download a URL into memory via reqwest. | ||
| 172 | pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> { | ||
| 173 | let resp = client | ||
| 174 | .get(url) | ||
| 175 | .send() | ||
| 176 | .await | ||
| 177 | .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?; | ||
| 178 | let bytes = resp | ||
| 179 | .bytes() | ||
| 180 | .await | ||
| 181 | .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?; | ||
| 182 | Ok(bytes.to_vec()) | ||
| 183 | } | ||
| 184 | |||
| 185 | /// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM. | ||
| 186 | /// If this plays, the streaming pipeline works; bug is in Opus roundtrip. | ||
| 187 | pub async fn degrade_audio(input: &[u8]) -> Result<songbird::input::Input> { | ||
| 188 | use std::process::Stdio; | ||
| 189 | |||
| 190 | let mut child = tokio::process::Command::new("ffmpeg") | ||
| 191 | .args([ | ||
| 192 | "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500", | ||
| 193 | "pipe:1", | ||
| 194 | ]) | ||
| 195 | .stdin(Stdio::piped()) | ||
| 196 | .stdout(Stdio::piped()) | ||
| 197 | .stderr(Stdio::null()) | ||
| 198 | .kill_on_drop(true) | ||
| 199 | .spawn() | ||
| 200 | .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?; | ||
| 201 | |||
| 202 | let mut stdin = child.stdin.take().unwrap(); | ||
| 203 | let stdout = child | ||
| 204 | .stdout | ||
| 205 | .take() | ||
| 206 | .ok_or_else(|| Error::Custom("stdout not captured".into()))?; | ||
| 207 | |||
| 208 | let owned_input = input.to_vec(); | ||
| 209 | tokio::spawn(async move { | ||
| 210 | use tokio::io::AsyncWriteExt; | ||
| 211 | let _ = stdin.write_all(&owned_input).await; | ||
| 212 | }); | ||
| 213 | |||
| 214 | let ffmpeg_child = FfmpegChild { | ||
| 215 | _child: child, | ||
| 216 | stdout, | ||
| 217 | }; | ||
| 218 | |||
| 219 | let async_source = AsyncReadOnlySource::new(ffmpeg_child); | ||
| 220 | let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); | ||
| 221 | let raw = RawAdapter::new(adapter, 48000, 2); | ||
| 222 | Ok(raw.into()) | ||
| 223 | } | ||
| 224 | |||
| 225 | // ── HTTP streaming via songbird's built-in HttpRequest (legacy) ───── | ||
| 226 | |||
| 227 | /// Create a songbird `Input` that streams audio directly from an HTTP(S) URL. | ||
| 228 | /// Works for both finite files and infinite Icecast/Shoutcast live streams. | ||
| 229 | /// | ||
| 230 | /// Superseded by `ffmpeg_stream_input` for the `/play` command (which needs | ||
| 231 | /// HLS/playlist/Icecast-metadata handling that symphonia can't provide), but | ||
| 232 | /// retained for other callers that want lightweight symphonia-based streaming. | ||
| 233 | #[allow(dead_code)] | ||
| 234 | pub fn http_stream_input(client: reqwest::Client, url: &str) -> songbird::input::Input { | ||
| 235 | info!(%url, "streaming via HttpRequest"); | ||
| 236 | songbird::input::HttpRequest::new(client, url.to_string()).into() | ||
| 237 | } | ||
| 238 | |||
| 239 | // ── Live streaming via ffmpeg subprocess ───────────────────────────── | ||
| 240 | |||
| 241 | /// Adapter holding an ffmpeg child process and its stdout pipe. | ||
| 242 | /// | ||
| 243 | /// Implements `AsyncRead` by delegating to `ChildStdout`. The `Child` is kept | ||
| 244 | /// alive for the lifetime of the stream and killed on drop via | ||
| 245 | /// `kill_on_drop(true)`, so stopping playback terminates ffmpeg. | ||
| 246 | /// | ||
| 247 | /// `Sync`: the `Child` handle is only touched on `Drop` (a single, one-shot | ||
| 248 | /// operation); `ChildStdout` is only read by the single `AsyncAdapterSink` | ||
| 249 | /// task spawned inside `AsyncAdapterStream`. No field is accessed concurrently. | ||
| 250 | struct FfmpegChild { | ||
| 251 | _child: tokio::process::Child, | ||
| 252 | stdout: tokio::process::ChildStdout, | ||
| 253 | } | ||
| 254 | |||
| 255 | impl tokio::io::AsyncRead for FfmpegChild { | ||
| 256 | fn poll_read( | ||
| 257 | mut self: Pin<&mut Self>, | ||
| 258 | cx: &mut Context<'_>, | ||
| 259 | buf: &mut tokio::io::ReadBuf<'_>, | ||
| 260 | ) -> Poll<std::io::Result<()>> { | ||
| 261 | Pin::new(&mut self.stdout).poll_read(cx, buf) | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | /// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird | ||
| 266 | /// `Input` of raw f32 PCM at 48 kHz stereo — NO full download. | ||
| 267 | /// | ||
| 268 | /// ffmpeg handles every streaming scenario the Python bot relied on it for: | ||
| 269 | /// Icecast/Shoutcast radio (incl. inline metadata), HLS `.m3u8` (segment | ||
| 270 | /// fetching), `.m3u`/`.pls` playlists, and direct media files. The | ||
| 271 | /// `-reconnect` flags let ffmpeg recover from dropped connections. | ||
| 272 | /// | ||
| 273 | /// Byte path: `ffmpeg stdout` → `AsyncReadOnlySource` (unseekable async | ||
| 274 | /// media source) → `AsyncAdapterStream` (async→sync ring buffer) → | ||
| 275 | /// `RawAdapter` (prepends `SbirdRaw` f32 header) → songbird `Input`. | ||
| 276 | pub async fn ffmpeg_stream_input(url: &str) -> Result<songbird::input::Input> { | ||
| 277 | use std::process::Stdio; | ||
| 278 | |||
| 279 | let mut child = tokio::process::Command::new("ffmpeg") | ||
| 280 | .args([ | ||
| 281 | "-reconnect", | ||
| 282 | "1", | ||
| 283 | "-reconnect_streamed", | ||
| 284 | "1", | ||
| 285 | "-reconnect_delay_max", | ||
| 286 | "5", | ||
| 287 | "-i", | ||
| 288 | url, | ||
| 289 | "-vn", | ||
| 290 | "-f", | ||
| 291 | "f32le", | ||
| 292 | "-ar", | ||
| 293 | "48000", | ||
| 294 | "-ac", | ||
| 295 | "2", | ||
| 296 | "pipe:1", | ||
| 297 | ]) | ||
| 298 | .stdout(Stdio::piped()) | ||
| 299 | .stderr(Stdio::piped()) | ||
| 300 | .kill_on_drop(true) | ||
| 301 | .spawn() | ||
| 302 | .map_err(|e| Error::Custom(lang::fmt_voice_ffmpeg_launch(&e)))?; | ||
| 303 | |||
| 304 | let stdout = child | ||
| 305 | .stdout | ||
| 306 | .take() | ||
| 307 | .ok_or_else(|| Error::Custom("ffmpeg stdout was not captured".into()))?; | ||
| 308 | |||
| 309 | // Drain stderr in background so ffmpeg doesn't block; log first 4 KiB on drop. | ||
| 310 | if let Some(stderr) = child.stderr.take() { | ||
| 311 | let url_owned = url.to_string(); | ||
| 312 | tokio::spawn(async move { | ||
| 313 | use tokio::io::AsyncReadExt; | ||
| 314 | let mut buf = Vec::with_capacity(4096); | ||
| 315 | let mut reader = tokio::io::BufReader::new(stderr); | ||
| 316 | let _ = reader.read_to_end(&mut buf).await; | ||
| 317 | let text = String::from_utf8_lossy(&buf); | ||
| 318 | if !text.is_empty() { | ||
| 319 | warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end()); | ||
| 320 | } | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | let ffmpeg_child = FfmpegChild { | ||
| 325 | _child: child, | ||
| 326 | stdout, | ||
| 327 | }; | ||
| 328 | |||
| 329 | let async_source = AsyncReadOnlySource::new(ffmpeg_child); | ||
| 330 | let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); | ||
| 331 | let raw = RawAdapter::new(adapter, 48000, 2); | ||
| 332 | Ok(raw.into()) | ||
| 333 | } | ||
| 334 | |||
| 335 | /// Smoke-test an HTTP stream URL: creates a raw reqwest GET, reads the first | ||
| 336 | /// chunk directly from the async bytes stream (bypasses AsyncAdapterStream). | ||
| 337 | /// Logs whether data arrives — isolates the hang to songbird's sync adapter. | ||
| 338 | #[allow(dead_code)] | ||
| 339 | pub async fn smoke_test_stream(client: reqwest::Client, url: String) { | ||
| 340 | use futures_util::StreamExt; | ||
| 341 | |||
| 342 | info!(%url, "smoke_test: sending GET"); | ||
| 343 | let resp = match client.get(url.as_str()).send().await { | ||
| 344 | Ok(r) => r, | ||
| 345 | Err(e) => { | ||
| 346 | warn!(%url, error = %e, "smoke_test: GET FAILED"); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | }; | ||
| 350 | info!(%url, status = %resp.status(), "smoke_test: response received"); | ||
| 351 | |||
| 352 | let mut stream = resp.bytes_stream(); | ||
| 353 | info!(%url, "smoke_test: awaiting first chunk"); | ||
| 354 | match stream.next().await { | ||
| 355 | Some(Ok(chunk)) => { | ||
| 356 | info!(%url, len = chunk.len(), "smoke_test: first chunk arrived"); | ||
| 357 | } | ||
| 358 | Some(Err(e)) => { | ||
| 359 | warn!(%url, error = %e, "smoke_test: chunk error"); | ||
| 360 | } | ||
| 361 | None => { | ||
| 362 | info!(%url, "smoke_test: stream ended immediately (0 bytes)"); | ||
| 363 | } | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | /// Diagnostic: fetch headers and full body for a URL, logging key metrics. | ||
| 368 | /// Helps debug symphonia probe EOF issues (Content-Length vs actual bytes). | ||
| 369 | /// | ||
| 370 | /// **Warning**: hangs forever on infinite streams (Icecast). Finite URLs only. | ||
| 371 | #[allow(dead_code)] | ||
| 372 | pub async fn debug_fetch(client: &reqwest::Client, url: &str) { | ||
| 373 | match client.get(url).send().await { | ||
| 374 | Ok(resp) => { | ||
| 375 | let status = resp.status(); | ||
| 376 | let headers = resp.headers().clone(); | ||
| 377 | let content_length = headers | ||
| 378 | .get("content-length") | ||
| 379 | .and_then(|v| v.to_str().ok()) | ||
| 380 | .and_then(|v| v.parse::<u64>().ok()); | ||
| 381 | let content_type = headers.get("content-type").and_then(|v| v.to_str().ok()); | ||
| 382 | |||
| 383 | info!( | ||
| 384 | %url, %status, | ||
| 385 | ?content_length, ?content_type, | ||
| 386 | "HTTP response headers" | ||
| 387 | ); | ||
| 388 | |||
| 389 | match resp.bytes().await { | ||
| 390 | Ok(body) => { | ||
| 391 | let actual_len = body.len() as u64; | ||
| 392 | let reported_len = content_length.unwrap_or(0); | ||
| 393 | info!( | ||
| 394 | %url, reported = reported_len, actual = actual_len, | ||
| 395 | delta = actual_len as i64 - reported_len as i64, | ||
| 396 | "buffered .bytes(): reported vs actual" | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | Err(e) => warn!(%url, error = %e, ".bytes() failed"), | ||
| 400 | } | ||
| 401 | } | ||
| 402 | Err(e) => warn!(%url, error = %e, "HTTP GET failed"), | ||
| 403 | } | ||
| 404 | } | ||
diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py deleted file mode 100644 index aab9861..0000000 --- a/tests/cogs/test_ai.py +++ /dev/null | |||
| @@ -1,43 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def ai_cog(bot): | ||
| 8 | from cogs.ai import AI | ||
| 9 | cog = AI(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value="Test response from AI") as mock_llm: | ||
| 18 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 19 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 20 | mock_llm.assert_awaited_once() | ||
| 21 | mock_followup.assert_called_once_with("Test response from AI") | ||
| 22 | |||
| 23 | |||
| 24 | @pytest.mark.asyncio | ||
| 25 | async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot): | ||
| 26 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 27 | |||
| 28 | with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value=None) as mock_llm: | ||
| 29 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 30 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 31 | mock_llm.assert_awaited_once() | ||
| 32 | mock_followup.assert_called_once_with("bot siem zesroł") | ||
| 33 | |||
| 34 | |||
| 35 | @pytest.mark.asyncio | ||
| 36 | async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot): | ||
| 37 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 38 | |||
| 39 | with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value=None) as mock_llm: | ||
| 40 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 41 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 42 | mock_llm.assert_awaited_once() | ||
| 43 | mock_followup.assert_called_once_with("bot siem zesroł") | ||
diff --git a/tests/cogs/test_assistant.py b/tests/cogs/test_assistant.py deleted file mode 100644 index 056a868..0000000 --- a/tests/cogs/test_assistant.py +++ /dev/null | |||
| @@ -1,66 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def assistant_cog(): | ||
| 8 | with patch('os.getenv', return_value="test_keyword"): | ||
| 9 | from cogs.assistant import Jarvis | ||
| 10 | |||
| 11 | mock_bot = MagicMock() | ||
| 12 | mock_bot.user = MagicMock() | ||
| 13 | mock_bot.user.id = 111 | ||
| 14 | |||
| 15 | cog = Jarvis(mock_bot) | ||
| 16 | return cog | ||
| 17 | |||
| 18 | |||
| 19 | def test_assistant_keyword_default(assistant_cog): | ||
| 20 | assert assistant_cog.keyword == "test_keyword" | ||
| 21 | |||
| 22 | |||
| 23 | @pytest.mark.asyncio | ||
| 24 | async def test_assistant_on_message_ignored_bot_message(assistant_cog): | ||
| 25 | mock_author = MagicMock() | ||
| 26 | mock_author.id = 456 | ||
| 27 | mock_author.bot = True | ||
| 28 | |||
| 29 | mock_message = MagicMock() | ||
| 30 | mock_message.author = mock_author | ||
| 31 | |||
| 32 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 33 | await assistant_cog.on_message(mock_message) | ||
| 34 | mock_assistant.assert_not_called() | ||
| 35 | |||
| 36 | |||
| 37 | @pytest.mark.asyncio | ||
| 38 | async def test_assistant_on_message_mentions_bot(assistant_cog): | ||
| 39 | mock_message = MagicMock() | ||
| 40 | mock_message.author = MagicMock() | ||
| 41 | mock_message.author.id = 123 | ||
| 42 | |||
| 43 | mock_mention = MagicMock() | ||
| 44 | mock_mention.id = 111 | ||
| 45 | mock_message.mentions = [mock_mention] | ||
| 46 | mock_message.content = "test" | ||
| 47 | |||
| 48 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 49 | await assistant_cog.on_message(mock_message) | ||
| 50 | mock_assistant.assert_called_once_with(mock_message) | ||
| 51 | |||
| 52 | |||
| 53 | @pytest.mark.asyncio | ||
| 54 | async def test_assistant_on_message_keyword_match(assistant_cog): | ||
| 55 | assistant_cog.keyword = "test" | ||
| 56 | |||
| 57 | mock_message = MagicMock() | ||
| 58 | mock_message.author = MagicMock() | ||
| 59 | mock_message.author.id = 123 | ||
| 60 | |||
| 61 | mock_message.mentions = [] | ||
| 62 | mock_message.content = "test message with test keyword" | ||
| 63 | |||
| 64 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 65 | await assistant_cog.on_message(mock_message) | ||
| 66 | mock_assistant.assert_called_once_with(mock_message) | ||
diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py deleted file mode 100644 index 892482b..0000000 --- a/tests/cogs/test_compression_conversion.py +++ /dev/null | |||
| @@ -1,139 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | import discord | ||
| 5 | from conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | def test_compression_filetype_video(): | ||
| 9 | from cogs.compression import filetype | ||
| 10 | assert filetype("test.mp4") == "Video" | ||
| 11 | assert filetype("test.avi") == "Video" | ||
| 12 | |||
| 13 | |||
| 14 | def test_compression_filetype_audio(): | ||
| 15 | from cogs.compression import filetype | ||
| 16 | assert filetype("test.mp3") == "Audio" | ||
| 17 | assert filetype("test.wav") == "Audio" | ||
| 18 | |||
| 19 | |||
| 20 | def test_compression_filetype_image(): | ||
| 21 | from cogs.compression import filetype | ||
| 22 | assert filetype("test.jpg") == "Image" | ||
| 23 | assert filetype("test.png") == "Image" | ||
| 24 | |||
| 25 | |||
| 26 | def test_conversion_filetype(): | ||
| 27 | from cogs.conversion import filetype | ||
| 28 | assert filetype("test.mp4") == "Video" | ||
| 29 | assert filetype("test.mp3") == "Audio" | ||
| 30 | assert filetype("test.jpg") == "Image" | ||
| 31 | assert filetype("test.xyz") == "Unknown" | ||
| 32 | |||
| 33 | |||
| 34 | @pytest.mark.asyncio | ||
| 35 | async def test_build_conversion_command_video(): | ||
| 36 | with patch('os.getenv', return_value="https://example.com"): | ||
| 37 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 38 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 39 | import importlib | ||
| 40 | import cogs.conversion | ||
| 41 | importlib.reload(cogs.conversion) | ||
| 42 | from cogs.conversion import Conversion | ||
| 43 | |||
| 44 | cog = Conversion.__new__(Conversion) | ||
| 45 | cog._baseurl = "https://example.com" | ||
| 46 | cog._uploadurl = "https://example.com" | ||
| 47 | |||
| 48 | cmd = cog._build_conversion_command("Video", "/tmp/test.mp4", "/tmp/output.mp4", "") | ||
| 49 | assert cmd is not None | ||
| 50 | assert "ffmpeg" in cmd | ||
| 51 | |||
| 52 | |||
| 53 | @pytest.mark.asyncio | ||
| 54 | async def test_build_conversion_command_image(): | ||
| 55 | with patch('os.getenv', return_value="https://example.com"): | ||
| 56 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 57 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 58 | import importlib | ||
| 59 | import cogs.conversion | ||
| 60 | importlib.reload(cogs.conversion) | ||
| 61 | from cogs.conversion import Conversion | ||
| 62 | |||
| 63 | cog = Conversion.__new__(Conversion) | ||
| 64 | cog._baseurl = "https://example.com" | ||
| 65 | cog._uploadurl = "https://example.com" | ||
| 66 | |||
| 67 | cmd = cog._build_conversion_command("Image", "/tmp/test.jpg", "/tmp/output.png", "") | ||
| 68 | assert cmd is not None | ||
| 69 | assert "magick" in cmd | ||
| 70 | |||
| 71 | |||
| 72 | @pytest.mark.asyncio | ||
| 73 | async def test_build_conversion_command_unknown(): | ||
| 74 | with patch('os.getenv', return_value="https://example.com"): | ||
| 75 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 76 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 77 | import importlib | ||
| 78 | import cogs.conversion | ||
| 79 | importlib.reload(cogs.conversion) | ||
| 80 | from cogs.conversion import Conversion | ||
| 81 | |||
| 82 | cog = Conversion.__new__(Conversion) | ||
| 83 | cog._baseurl = "https://example.com" | ||
| 84 | cog._uploadurl = "https://example.com" | ||
| 85 | |||
| 86 | cmd = cog._build_conversion_command("Unknown", "/tmp/test.xyz", "/tmp/output.abc", "") | ||
| 87 | assert cmd is None | ||
| 88 | |||
| 89 | |||
| 90 | @pytest.mark.asyncio | ||
| 91 | async def test_compression_get_command_video(): | ||
| 92 | with patch('os.getenv', return_value="https://example.com"): | ||
| 93 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 94 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 95 | import importlib | ||
| 96 | import cogs.compression | ||
| 97 | importlib.reload(cogs.compression) | ||
| 98 | from cogs.compression import Compression | ||
| 99 | |||
| 100 | cog = Compression.__new__(Compression) | ||
| 101 | cog.temp_dir = "/tmp" | ||
| 102 | |||
| 103 | cmd = cog._get_compression_command("Video", "/tmp/test.mp4", "/tmp/output.mp4") | ||
| 104 | assert cmd is not None | ||
| 105 | assert "ffmpeg" in cmd | ||
| 106 | |||
| 107 | |||
| 108 | @pytest.mark.asyncio | ||
| 109 | async def test_compression_get_command_image(): | ||
| 110 | with patch('os.getenv', return_value="https://example.com"): | ||
| 111 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 112 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 113 | import importlib | ||
| 114 | import cogs.compression | ||
| 115 | importlib.reload(cogs.compression) | ||
| 116 | from cogs.compression import Compression | ||
| 117 | |||
| 118 | cog = Compression.__new__(Compression) | ||
| 119 | cog.temp_dir = "/tmp" | ||
| 120 | |||
| 121 | cmd = cog._get_compression_command("Image", "/tmp/test.jpg", "/tmp/output.jpg") | ||
| 122 | assert cmd is not None | ||
| 123 | |||
| 124 | |||
| 125 | @pytest.mark.asyncio | ||
| 126 | async def test_compression_get_command_unknown(): | ||
| 127 | with patch('os.getenv', return_value="https://example.com"): | ||
| 128 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 129 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 130 | import importlib | ||
| 131 | import cogs.compression | ||
| 132 | importlib.reload(cogs.compression) | ||
| 133 | from cogs.compression import Compression | ||
| 134 | |||
| 135 | cog = Compression.__new__(Compression) | ||
| 136 | cog.temp_dir = "/tmp" | ||
| 137 | |||
| 138 | cmd = cog._get_compression_command("Unknown", "/tmp/test.xyz", "/tmp/output.xyz") | ||
| 139 | assert cmd is None | ||
diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py deleted file mode 100644 index 28ccfe1..0000000 --- a/tests/cogs/test_cron.py +++ /dev/null | |||
| @@ -1,147 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from datetime import datetime | ||
| 3 | from unittest.mock import MagicMock, patch | ||
| 4 | |||
| 5 | from cogs.cron import Cron | ||
| 6 | from cogs.utils.database import _ConnectionContextManager | ||
| 7 | |||
| 8 | |||
| 9 | @pytest.fixture | ||
| 10 | def mock_bot(): | ||
| 11 | bot = MagicMock() | ||
| 12 | bot.loop = MagicMock() | ||
| 13 | bot.is_closed = MagicMock(return_value=False) | ||
| 14 | bot.wait_until_ready = MagicMock() | ||
| 15 | bot.loop.create_task = MagicMock(return_value=MagicMock()) | ||
| 16 | return bot | ||
| 17 | |||
| 18 | |||
| 19 | def create_cog(mock_bot): | ||
| 20 | with patch.object(Cron, "__init__", lambda self, bot: None): | ||
| 21 | with patch("cogs.cron.init_database"): | ||
| 22 | cog = Cron.__new__(Cron) | ||
| 23 | cog.bot = mock_bot | ||
| 24 | cog.scheduled_tasks = [] | ||
| 25 | cog.task = MagicMock() | ||
| 26 | return cog | ||
| 27 | |||
| 28 | |||
| 29 | class TestLoadTasks: | ||
| 30 | def test_load_tasks_with_data(self, mock_bot): | ||
| 31 | mock_conn = MagicMock() | ||
| 32 | mock_cursor = MagicMock() | ||
| 33 | mock_cursor.fetchall.return_value = [ | ||
| 34 | (1, "* * * * *", "audio", 123, 1, None, "test message") | ||
| 35 | ] | ||
| 36 | mock_conn.cursor.return_value = mock_cursor | ||
| 37 | |||
| 38 | # Create a context manager mock | ||
| 39 | mock_ctx = MagicMock() | ||
| 40 | mock_ctx.__enter__ = MagicMock(return_value=mock_conn) | ||
| 41 | mock_ctx.__exit__ = MagicMock(return_value=False) | ||
| 42 | |||
| 43 | with patch("cogs.cron.get_connection", return_value=mock_ctx): | ||
| 44 | cog = Cron.__new__(Cron) | ||
| 45 | cog.bot = mock_bot | ||
| 46 | cog.scheduled_tasks = [] | ||
| 47 | cog.task = MagicMock() | ||
| 48 | cog.load_tasks() | ||
| 49 | assert len(cog.scheduled_tasks) == 1 | ||
| 50 | assert cog.scheduled_tasks[0]["id"] == 1 | ||
| 51 | |||
| 52 | class TestSaveTasks: | ||
| 53 | def test_save_tasks(self, mock_bot): | ||
| 54 | cog = create_cog(mock_bot) | ||
| 55 | cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] | ||
| 56 | |||
| 57 | mock_conn = MagicMock() | ||
| 58 | mock_cursor = MagicMock() | ||
| 59 | mock_conn.cursor.return_value = mock_cursor | ||
| 60 | |||
| 61 | # Create a context manager mock | ||
| 62 | mock_ctx = MagicMock() | ||
| 63 | mock_ctx.__enter__ = MagicMock(return_value=mock_conn) | ||
| 64 | mock_ctx.__exit__ = MagicMock(return_value=False) | ||
| 65 | |||
| 66 | with patch("cogs.cron.get_connection", return_value=mock_ctx): | ||
| 67 | cog.save_tasks() | ||
| 68 | assert len(cog.scheduled_tasks) == 1 | ||
| 69 | |||
| 70 | class TestShouldRunTask: | ||
| 71 | def test_should_run_task_invalid_cron(self, mock_bot): | ||
| 72 | cog = create_cog(mock_bot) | ||
| 73 | task = {"cron_expression": "invalid"} | ||
| 74 | now = datetime.now() | ||
| 75 | result = cog.should_run_task(task, now) | ||
| 76 | assert result is False | ||
| 77 | |||
| 78 | def test_should_run_task_missing_cron_expression(self, mock_bot): | ||
| 79 | cog = create_cog(mock_bot) | ||
| 80 | task = {} | ||
| 81 | now = datetime.now() | ||
| 82 | result = cog.should_run_task(task, now) | ||
| 83 | assert result is False | ||
| 84 | |||
| 85 | class TestCogUnload: | ||
| 86 | def test_cog_unload_cancels_task(self, mock_bot): | ||
| 87 | with patch.object(Cron, "__init__", lambda self, bot: None): | ||
| 88 | cog = Cron.__new__(Cron) | ||
| 89 | cog.bot = mock_bot | ||
| 90 | cog.task = MagicMock() | ||
| 91 | |||
| 92 | cog.cog_unload() | ||
| 93 | cog.task.cancel.assert_called_once() | ||
| 94 | |||
| 95 | class TestTaskManagement: | ||
| 96 | def test_add_task_increments_id(self, mock_bot): | ||
| 97 | cog = create_cog(mock_bot) | ||
| 98 | cog.scheduled_tasks = [ | ||
| 99 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 100 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 101 | ] | ||
| 102 | |||
| 103 | new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 | ||
| 104 | assert new_id == 3 | ||
| 105 | |||
| 106 | def test_find_task_by_id(self, mock_bot): | ||
| 107 | cog = create_cog(mock_bot) | ||
| 108 | cog.scheduled_tasks = [ | ||
| 109 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 110 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 111 | ] | ||
| 112 | |||
| 113 | found_task = next((t for t in cog.scheduled_tasks if t["id"] == 2), None) | ||
| 114 | assert found_task is not None | ||
| 115 | assert found_task["cron_expression"] == "0 * * * *" | ||
| 116 | |||
| 117 | def test_remove_task_by_index(self, mock_bot): | ||
| 118 | cog = create_cog(mock_bot) | ||
| 119 | cog.scheduled_tasks = [ | ||
| 120 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 121 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 122 | ] | ||
| 123 | |||
| 124 | cog.scheduled_tasks.pop(0) | ||
| 125 | assert len(cog.scheduled_tasks) == 1 | ||
| 126 | assert cog.scheduled_tasks[0]["id"] == 2 | ||
| 127 | |||
| 128 | def test_toggle_task_active(self, mock_bot): | ||
| 129 | cog = create_cog(mock_bot) | ||
| 130 | cog.scheduled_tasks = [{"id": 1, "active": True}] | ||
| 131 | |||
| 132 | cog.scheduled_tasks[0]["active"] = False | ||
| 133 | assert cog.scheduled_tasks[0]["active"] is False | ||
| 134 | |||
| 135 | cog.scheduled_tasks[0]["active"] = True | ||
| 136 | assert cog.scheduled_tasks[0]["active"] is True | ||
| 137 | |||
| 138 | class TestTaskTypes: | ||
| 139 | def test_task_type_audio_aliases(self, mock_bot): | ||
| 140 | audio_types = ("zagraj_audio", "audio") | ||
| 141 | for task_type in audio_types: | ||
| 142 | assert task_type in ("zagraj_audio", "audio") | ||
| 143 | |||
| 144 | def test_task_type_message_aliases(self, mock_bot): | ||
| 145 | message_types = ("wyślij_wiadomość", "send_message") | ||
| 146 | for task_type in message_types: | ||
| 147 | assert task_type in ("wyślij_wiadomość", "send_message") | ||
diff --git a/tests/cogs/test_cron_integration.py b/tests/cogs/test_cron_integration.py deleted file mode 100644 index f56fc46..0000000 --- a/tests/cogs/test_cron_integration.py +++ /dev/null | |||
| @@ -1,39 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | import time | ||
| 3 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def cron_cog(bot): | ||
| 8 | # Patch create_task to prevent background task creation | ||
| 9 | with patch.object(bot.loop, 'create_task', return_value=MagicMock()): | ||
| 10 | from cogs.cron import Cron | ||
| 11 | cog = Cron(bot) | ||
| 12 | yield cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_cron_job_execution(cron_cog): | ||
| 18 | """Test cron job executes when due.""" | ||
| 19 | cog = cron_cog | ||
| 20 | |||
| 21 | job = { | ||
| 22 | "id": "test_job", | ||
| 23 | "cron_expression": "* * * * *", | ||
| 24 | "type": "message", | ||
| 25 | "channel_id": 456, | ||
| 26 | "active": True, | ||
| 27 | "message": "Test", | ||
| 28 | } | ||
| 29 | cog.scheduled_tasks.append(job) | ||
| 30 | |||
| 31 | # Mock the execute_task method | ||
| 32 | with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec: | ||
| 33 | # Mock should_run_task to return True | ||
| 34 | with patch.object(cog, 'should_run_task', return_value=True): | ||
| 35 | # Test single iteration of schedule_checker logic | ||
| 36 | for task in cog.scheduled_tasks: | ||
| 37 | if task.get("active", True) and cog.should_run_task(task, None): | ||
| 38 | await cog.execute_task(task) | ||
| 39 | mock_exec.assert_called_once_with(job) \ No newline at end of file | ||
diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py deleted file mode 100644 index 125a103..0000000 --- a/tests/cogs/test_downloader.py +++ /dev/null | |||
| @@ -1,93 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from conftest import MockApplicationContext, MockMessage | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def downloader_cog(bot): | ||
| 9 | from cogs.downloader import Downloader | ||
| 10 | cog = Downloader(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_download_command_success_file(downloader_cog, mock_channel, mock_guild, bot): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | |||
| 18 | mock_file_path = "/tmp/test_video.mp4" | ||
| 19 | |||
| 20 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 21 | "status": "success", | ||
| 22 | "type": "file", | ||
| 23 | "content": mock_file_path, | ||
| 24 | "filename": "test_video.mp4" | ||
| 25 | }): | ||
| 26 | with patch('discord.File', return_value=MagicMock()) as mock_file: | ||
| 27 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 28 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=False, fformat="mp4", filename_user=None) | ||
| 29 | mock_respond.assert_called_once() | ||
| 30 | call_args = mock_respond.call_args | ||
| 31 | assert 'file' in str(call_args) or mock_file.called | ||
| 32 | |||
| 33 | |||
| 34 | @pytest.mark.asyncio | ||
| 35 | async def test_download_command_success_url(downloader_cog, mock_channel, mock_guild, bot): | ||
| 36 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 37 | |||
| 38 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 39 | "status": "success", | ||
| 40 | "type": "url", | ||
| 41 | "content": "https://catbox.2137697.xyz/sperma/test.mp4" | ||
| 42 | }): | ||
| 43 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 44 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=True, fformat="mp4", filename_user=None) | ||
| 45 | mock_respond.assert_called_once() | ||
| 46 | call_args = mock_respond.call_args | ||
| 47 | assert "pobioron" in str(call_args) | ||
| 48 | |||
| 49 | |||
| 50 | @pytest.mark.asyncio | ||
| 51 | async def test_download_command_error(downloader_cog, mock_channel, mock_guild, bot): | ||
| 52 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 53 | |||
| 54 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 55 | "status": "error", | ||
| 56 | "type": "error", | ||
| 57 | "content": "Download failed", | ||
| 58 | "details": "Connection timeout" | ||
| 59 | }): | ||
| 60 | with patch('cogs.downloader.logger') as mock_logger: | ||
| 61 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://badurl.com", catbox=False, fformat="mp4", filename_user=None) | ||
| 62 | assert mock_logger.error.called | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.asyncio | ||
| 66 | async def test_message_download_with_url(downloader_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 67 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 68 | |||
| 69 | mock_message = MagicMock() | ||
| 70 | mock_message.content = "Check this out https://example.com/video" | ||
| 71 | |||
| 72 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 73 | "status": "success", | ||
| 74 | "type": "file", | ||
| 75 | "content": "/tmp/test.mp4" | ||
| 76 | }): | ||
| 77 | with patch.object(downloader_cog, 'download', new_callable=AsyncMock) as mock_download: | ||
| 78 | await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) | ||
| 79 | mock_download.assert_called_once() | ||
| 80 | |||
| 81 | |||
| 82 | @pytest.mark.asyncio | ||
| 83 | async def test_message_download_no_url(downloader_cog, mock_channel, mock_guild, bot): | ||
| 84 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 85 | |||
| 86 | mock_message = MagicMock() | ||
| 87 | mock_message.content = "No URL here" | ||
| 88 | |||
| 89 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 90 | await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) | ||
| 91 | mock_respond.assert_called_once() | ||
| 92 | call_args = mock_respond.call_args | ||
| 93 | assert "Brak" in str(call_args) | ||
diff --git a/tests/cogs/test_downloader_integration.py b/tests/cogs/test_downloader_integration.py deleted file mode 100644 index c7c9790..0000000 --- a/tests/cogs/test_downloader_integration.py +++ /dev/null | |||
| @@ -1,37 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 3 | |||
| 4 | |||
| 5 | @pytest.fixture | ||
| 6 | def downloader_cog(bot): | ||
| 7 | from cogs.downloader import Downloader | ||
| 8 | cog = Downloader(bot) | ||
| 9 | return cog | ||
| 10 | |||
| 11 | |||
| 12 | @pytest.mark.slow | ||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_download_command_flow(downloader_cog, bot): | ||
| 15 | """Test download command with URL processing.""" | ||
| 16 | cog = downloader_cog | ||
| 17 | |||
| 18 | ctx = MagicMock() | ||
| 19 | ctx.author = MagicMock() | ||
| 20 | ctx.guild = MagicMock() | ||
| 21 | ctx.channel = MagicMock() | ||
| 22 | ctx.respond = AsyncMock() | ||
| 23 | ctx.defer = AsyncMock() | ||
| 24 | ctx.followup = AsyncMock() | ||
| 25 | ctx.voice_client = None | ||
| 26 | |||
| 27 | # Mock the download_standalone static method | ||
| 28 | mock_response = { | ||
| 29 | "status": "success", | ||
| 30 | "type": "url", | ||
| 31 | "content": "http://example.com/video.mp4" | ||
| 32 | } | ||
| 33 | |||
| 34 | with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response): | ||
| 35 | await cog.download.callback(cog, ctx, url="http://youtube.com/test") | ||
| 36 | |||
| 37 | ctx.respond.assert_called_once() | ||
diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py deleted file mode 100644 index 21e9a2e..0000000 --- a/tests/cogs/test_math_commands.py +++ /dev/null | |||
| @@ -1,55 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def math_cog(bot): | ||
| 8 | from cogs.math_commands import Math | ||
| 9 | cog = Math(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_math_command_valid_expression(math_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | mock_process = MagicMock() | ||
| 18 | mock_process.returncode = 0 | ||
| 19 | mock_process.communicate = AsyncMock(return_value=(b"4", b"")) | ||
| 20 | |||
| 21 | with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process) as mock_subprocess: | ||
| 22 | with patch('cogs.math_commands.re.search', return_value=None): | ||
| 23 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 24 | await math_cog.math.callback(math_cog, ctx, equation="2+2") | ||
| 25 | mock_subprocess.assert_called_once() | ||
| 26 | mock_respond.assert_called_once() | ||
| 27 | |||
| 28 | |||
| 29 | @pytest.mark.asyncio | ||
| 30 | async def test_math_command_invalid_expression(math_cog, mock_channel, mock_guild, bot): | ||
| 31 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 32 | |||
| 33 | with patch('cogs.math_commands.re.search', return_value=MagicMock()): | ||
| 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 35 | await math_cog.math.callback(math_cog, ctx, equation="eval(1)") | ||
| 36 | mock_respond.assert_called_once() | ||
| 37 | args = mock_respond.call_args | ||
| 38 | assert "Invalid" in str(args) | ||
| 39 | |||
| 40 | |||
| 41 | @pytest.mark.asyncio | ||
| 42 | async def test_math_command_process_error(math_cog, mock_channel, mock_guild, bot): | ||
| 43 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 44 | |||
| 45 | mock_process = MagicMock() | ||
| 46 | mock_process.returncode = 1 | ||
| 47 | mock_process.communicate = AsyncMock(return_value=(b"", b"Error: invalid expression")) | ||
| 48 | |||
| 49 | with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process): | ||
| 50 | with patch('cogs.math_commands.re.search', return_value=None): | ||
| 51 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 52 | await math_cog.math.callback(math_cog, ctx, equation="2++") | ||
| 53 | mock_respond.assert_called_once() | ||
| 54 | args = mock_respond.call_args | ||
| 55 | assert "error" in str(args).lower() or "Error" in str(args) | ||
diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py deleted file mode 100644 index 98c6f6b..0000000 --- a/tests/cogs/test_music_audio.py +++ /dev/null | |||
| @@ -1,93 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.mark.asyncio | ||
| 8 | async def test_validate_voice_channel_not_in_voice(): | ||
| 9 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 10 | |||
| 11 | mock_ctx = MagicMock() | ||
| 12 | mock_ctx.author.voice = None | ||
| 13 | mock_ctx.respond = AsyncMock() | ||
| 14 | |||
| 15 | result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) | ||
| 16 | |||
| 17 | assert result is None | ||
| 18 | mock_ctx.respond.assert_called_once() | ||
| 19 | |||
| 20 | |||
| 21 | @pytest.mark.asyncio | ||
| 22 | async def test_validate_voice_channel_in_voice(): | ||
| 23 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 24 | |||
| 25 | mock_channel = MagicMock() | ||
| 26 | mock_ctx = MagicMock() | ||
| 27 | mock_ctx.author.voice = MagicMock() | ||
| 28 | mock_ctx.author.voice.channel = mock_channel | ||
| 29 | mock_ctx.respond = AsyncMock() | ||
| 30 | |||
| 31 | result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) | ||
| 32 | |||
| 33 | assert result == mock_channel | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_prepare_file_source_file_not_found(): | ||
| 38 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 39 | |||
| 40 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") | ||
| 41 | |||
| 42 | assert display_name is None | ||
| 43 | assert absolute_path is None | ||
| 44 | |||
| 45 | |||
| 46 | @pytest.mark.asyncio | ||
| 47 | async def test_prepare_file_source_absolute_path(): | ||
| 48 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 49 | |||
| 50 | with patch('os.path.exists', return_value=True): | ||
| 51 | with patch('os.path.isabs', return_value=True): | ||
| 52 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/tmp/test.mp3") | ||
| 53 | |||
| 54 | assert display_name == "test" | ||
| 55 | assert absolute_path == "/tmp/test.mp3" | ||
| 56 | |||
| 57 | |||
| 58 | @pytest.mark.asyncio | ||
| 59 | async def test_prepare_source_url(): | ||
| 60 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 61 | |||
| 62 | source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream") | ||
| 63 | |||
| 64 | assert is_url == True | ||
| 65 | assert source == "https://example.com/stream" | ||
| 66 | |||
| 67 | |||
| 68 | @pytest.mark.asyncio | ||
| 69 | async def test_prepare_source_local_file(): | ||
| 70 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 71 | |||
| 72 | with patch('os.path.exists', return_value=True): | ||
| 73 | source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3") | ||
| 74 | |||
| 75 | assert is_url == False | ||
| 76 | assert display_name == "test" | ||
| 77 | |||
| 78 | |||
| 79 | @pytest.mark.asyncio | ||
| 80 | async def test_cleanup_temp_file(): | ||
| 81 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 82 | |||
| 83 | with patch('os.path.exists', return_value=True): | ||
| 84 | with patch('os.remove'): | ||
| 85 | await AudioPlaybackManager.cleanup_temp_file("/tmp/test.mp3") | ||
| 86 | |||
| 87 | |||
| 88 | @pytest.mark.asyncio | ||
| 89 | async def test_cleanup_temp_file_not_exists(): | ||
| 90 | from cogs.voice import VoiceManager as AudioPlaybackManager | ||
| 91 | |||
| 92 | with patch('os.path.exists', return_value=False): | ||
| 93 | await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") | ||
diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py deleted file mode 100644 index 8e19423..0000000 --- a/tests/cogs/test_music_external_play.py +++ /dev/null | |||
| @@ -1,109 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | import os | ||
| 3 | import asyncio | ||
| 4 | import tempfile | ||
| 5 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 6 | from conftest import MockApplicationContext | ||
| 7 | |||
| 8 | |||
| 9 | @pytest.fixture | ||
| 10 | def play_commands_cog(bot): | ||
| 11 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 12 | from cogs.music.play_commands import PlayCommands | ||
| 13 | cog = PlayCommands(bot) | ||
| 14 | return cog | ||
| 15 | |||
| 16 | |||
| 17 | @pytest.fixture | ||
| 18 | def temp_mp3_file(): | ||
| 19 | with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: | ||
| 20 | f.write(b'fake mp3 content') | ||
| 21 | temp_path = f.name | ||
| 22 | yield temp_path | ||
| 23 | if os.path.exists(temp_path): | ||
| 24 | os.remove(temp_path) | ||
| 25 | |||
| 26 | |||
| 27 | @pytest.mark.asyncio | ||
| 28 | async def test_external_url_pattern_matches_youtube(play_commands_cog): | ||
| 29 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 30 | |||
| 31 | assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") | ||
| 32 | assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") | ||
| 33 | assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_external_url_pattern_matches_soundcloud(play_commands_cog): | ||
| 38 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 39 | |||
| 40 | assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") | ||
| 41 | assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") | ||
| 42 | |||
| 43 | |||
| 44 | @pytest.mark.asyncio | ||
| 45 | async def test_external_url_pattern_matches_bandcamp(play_commands_cog): | ||
| 46 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 47 | |||
| 48 | assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") | ||
| 49 | |||
| 50 | |||
| 51 | @pytest.mark.asyncio | ||
| 52 | async def test_external_url_pattern_rejects_copyparty(play_commands_cog): | ||
| 53 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 54 | |||
| 55 | assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") | ||
| 56 | assert not EXTERNAL_URL_PATTERN.match("some search query") | ||
| 57 | |||
| 58 | |||
| 59 | @pytest.mark.asyncio | ||
| 60 | async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 61 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 62 | |||
| 63 | with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: | ||
| 64 | mock_handler.return_value = None | ||
| 65 | mock_defer = AsyncMock() | ||
| 66 | ctx.defer = mock_defer | ||
| 67 | ctx.respond = AsyncMock() | ||
| 68 | |||
| 69 | await play_commands_cog.play.callback( | ||
| 70 | play_commands_cog, ctx, | ||
| 71 | melodia="https://youtube.com/watch?v=test", | ||
| 72 | file=None, compressed=False | ||
| 73 | ) | ||
| 74 | |||
| 75 | mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") | ||
| 76 | |||
| 77 | |||
| 78 | @pytest.mark.asyncio | ||
| 79 | async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 80 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 81 | |||
| 82 | with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: | ||
| 83 | mock_handler.return_value = None | ||
| 84 | mock_defer = AsyncMock() | ||
| 85 | ctx.defer = mock_defer | ||
| 86 | ctx.respond = AsyncMock() | ||
| 87 | |||
| 88 | await play_commands_cog.play.callback( | ||
| 89 | play_commands_cog, ctx, | ||
| 90 | melodia="some search query", | ||
| 91 | file=None, compressed=False | ||
| 92 | ) | ||
| 93 | |||
| 94 | mock_handler.assert_called_once_with(ctx, "some search query", False) | ||
| 95 | |||
| 96 | |||
| 97 | @pytest.mark.asyncio | ||
| 98 | async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): | ||
| 99 | monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") | ||
| 100 | monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") | ||
| 101 | |||
| 102 | mock_response = MagicMock() | ||
| 103 | mock_response.status_code = 200 | ||
| 104 | mock_response.text = '{"fileurl": "https://example.com/file.mp3"}' | ||
| 105 | mock_response.json = MagicMock(return_value={"fileurl": "https://example.com/file.mp3"}) | ||
| 106 | |||
| 107 | with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post: | ||
| 108 | await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) | ||
| 109 | mock_post.assert_called_once() | ||
diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py deleted file mode 100644 index 692e1c1..0000000 --- a/tests/cogs/test_music_integration.py +++ /dev/null | |||
| @@ -1,46 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock | ||
| 3 | import sys | ||
| 4 | sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests') | ||
| 5 | from conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def music_cog(bot): | ||
| 10 | from cogs.music.play_commands import PlayCommands | ||
| 11 | cog = PlayCommands(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_music_join_stop(music_cog, bot): | ||
| 18 | """Test music join and stop commands.""" | ||
| 19 | from cogs.music.join import Join | ||
| 20 | from cogs.music.stop import Stop | ||
| 21 | |||
| 22 | join_cog = Join(bot) | ||
| 23 | stop_cog = Stop(bot) | ||
| 24 | |||
| 25 | ctx = MockApplicationContext(bot=bot) | ||
| 26 | ctx.author.voice = MagicMock() | ||
| 27 | ctx.author.voice.channel = MagicMock() | ||
| 28 | ctx.guild = MagicMock() | ||
| 29 | |||
| 30 | # Test join | ||
| 31 | mock_vm = MagicMock() | ||
| 32 | mock_vm.connect = AsyncMock() | ||
| 33 | with patch('cogs.music.join.get_voice_manager', return_value=mock_vm): | ||
| 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 35 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 36 | await join_cog.join.callback(join_cog, ctx) | ||
| 37 | mock_vm.connect.assert_called_once_with(ctx.author.voice.channel) | ||
| 38 | |||
| 39 | # Test stop | ||
| 40 | mock_vm_stop = MagicMock() | ||
| 41 | mock_vm_stop.disconnect = AsyncMock() | ||
| 42 | with patch('cogs.music.stop.get_voice_manager', return_value=mock_vm_stop): | ||
| 43 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 44 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 45 | await stop_cog.stop.callback(stop_cog, ctx) | ||
| 46 | mock_vm_stop.disconnect.assert_called_once_with(ctx.guild) | ||
diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py deleted file mode 100644 index b40d93a..0000000 --- a/tests/cogs/test_music_join.py +++ /dev/null | |||
| @@ -1,56 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def join_cog(bot): | ||
| 8 | from cogs.music.join import Join | ||
| 9 | cog = Join(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.fixture | ||
| 14 | def stop_cog(bot): | ||
| 15 | from cogs.music.stop import Stop | ||
| 16 | cog = Stop(bot) | ||
| 17 | return cog | ||
| 18 | |||
| 19 | |||
| 20 | @pytest.mark.asyncio | ||
| 21 | async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 22 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 23 | ctx.user = mock_user | ||
| 24 | ctx.author = mock_user | ||
| 25 | |||
| 26 | mock_voice_channel = MagicMock() | ||
| 27 | ctx.author.voice = MagicMock() | ||
| 28 | ctx.author.voice.channel = mock_voice_channel | ||
| 29 | |||
| 30 | with patch('cogs.music.join.get_voice_manager') as mock_get_vm: | ||
| 31 | mock_vm = MagicMock() | ||
| 32 | mock_vm.connect = AsyncMock() | ||
| 33 | mock_get_vm.return_value = mock_vm | ||
| 34 | |||
| 35 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 36 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 37 | await join_cog.join.callback(join_cog, ctx) | ||
| 38 | mock_vm.connect.assert_called_once_with(mock_voice_channel) | ||
| 39 | |||
| 40 | |||
| 41 | @pytest.mark.asyncio | ||
| 42 | async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 43 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 44 | ctx.user = mock_user | ||
| 45 | ctx.author = mock_user | ||
| 46 | |||
| 47 | with patch('cogs.music.stop.get_voice_manager') as mock_get_vm: | ||
| 48 | mock_vm = MagicMock() | ||
| 49 | mock_vm.disconnect = AsyncMock() | ||
| 50 | mock_get_vm.return_value = mock_vm | ||
| 51 | |||
| 52 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 53 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 54 | await stop_cog.stop.callback(stop_cog, ctx) | ||
| 55 | |||
| 56 | mock_vm.disconnect.assert_called_once_with(mock_guild) | ||
diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py deleted file mode 100644 index 7684729..0000000 --- a/tests/cogs/test_music_play.py +++ /dev/null | |||
| @@ -1,104 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def play_commands_cog(bot): | ||
| 9 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 10 | from cogs.music.play_commands import PlayCommands | ||
| 11 | cog = PlayCommands(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.fixture | ||
| 16 | def loop_cog(bot): | ||
| 17 | from cogs.music.loop import Loop | ||
| 18 | cog = Loop(bot) | ||
| 19 | return cog | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_create_search_embed_empty(play_commands_cog): | ||
| 24 | embed, view = await play_commands_cog._create_search_embed([]) | ||
| 25 | |||
| 26 | assert embed.title == "Wyniki wyszukiwania" | ||
| 27 | assert len(view.children) == 0 | ||
| 28 | |||
| 29 | |||
| 30 | @pytest.mark.asyncio | ||
| 31 | async def test_create_search_embed_with_candidates(play_commands_cog): | ||
| 32 | candidates = [ | ||
| 33 | "https://example.com/test1.mp3", | ||
| 34 | "https://example.com/test2.mp3" | ||
| 35 | ] | ||
| 36 | |||
| 37 | embed, view = await play_commands_cog._create_search_embed(candidates) | ||
| 38 | |||
| 39 | assert embed.title == "Wyniki wyszukiwania" | ||
| 40 | assert len(embed.fields) == 2 | ||
| 41 | |||
| 42 | |||
| 43 | @pytest.mark.asyncio | ||
| 44 | async def test_play_command_no_file_or_search(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 45 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 46 | |||
| 47 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 48 | await play_commands_cog.play.callback(play_commands_cog, ctx, melodia=None, file=None, compressed=False) | ||
| 49 | mock_respond.assert_called_once() | ||
| 50 | args = mock_respond.call_args | ||
| 51 | assert "Musisz podać" in str(args) | ||
| 52 | |||
| 53 | |||
| 54 | @pytest.mark.asyncio | ||
| 55 | async def test_play_command_both_file_and_search(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 56 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 57 | |||
| 58 | mock_file = MagicMock() | ||
| 59 | mock_file.filename = "test.mp3" | ||
| 60 | |||
| 61 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 62 | await play_commands_cog.play.callback(play_commands_cog, ctx, melodia="test", file=mock_file, compressed=False) | ||
| 63 | mock_respond.assert_called_once() | ||
| 64 | args = mock_respond.call_args | ||
| 65 | assert "nie oba" in str(args) or "LUB" in str(args) | ||
| 66 | |||
| 67 | |||
| 68 | def test_loop_cog_is_looping_initial(loop_cog): | ||
| 69 | assert loop_cog.is_looping(123) == False | ||
| 70 | |||
| 71 | |||
| 72 | def test_loop_cog_set_looping(loop_cog): | ||
| 73 | loop_cog.set_looping(123, True) | ||
| 74 | assert loop_cog.is_looping(123) == True | ||
| 75 | |||
| 76 | |||
| 77 | @pytest.mark.asyncio | ||
| 78 | async def test_loop_command_toggle_on(loop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 79 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 80 | ctx.user = mock_user | ||
| 81 | ctx.author = mock_user | ||
| 82 | ctx.guild.id = 123 | ||
| 83 | |||
| 84 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 85 | await loop_cog.loop.callback(loop_cog, ctx) | ||
| 86 | mock_respond.assert_called_once() | ||
| 87 | args = mock_respond.call_args | ||
| 88 | assert "włączony" in str(args) | ||
| 89 | |||
| 90 | |||
| 91 | @pytest.mark.asyncio | ||
| 92 | async def test_loop_command_toggle_off(loop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 93 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 94 | ctx.user = mock_user | ||
| 95 | ctx.author = mock_user | ||
| 96 | ctx.guild.id = 456 | ||
| 97 | |||
| 98 | loop_cog.set_looping(456, True) | ||
| 99 | |||
| 100 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 101 | await loop_cog.loop.callback(loop_cog, ctx) | ||
| 102 | mock_respond.assert_called_once() | ||
| 103 | args = mock_respond.call_args | ||
| 104 | assert "wyłączony" in str(args) | ||
diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py deleted file mode 100644 index b5cb31f..0000000 --- a/tests/cogs/test_qr.py +++ /dev/null | |||
| @@ -1,75 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock, mock_open | ||
| 3 | import os | ||
| 4 | from cogs.qr import QR | ||
| 5 | from conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def qr_cog(bot): | ||
| 10 | cog = QR(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.fixture | ||
| 15 | def mock_temp_file(): | ||
| 16 | with patch('tempfile.NamedTemporaryFile') as mock: | ||
| 17 | mock_file = MagicMock() | ||
| 18 | mock_file.name = "/tmp/test_qr.png" | ||
| 19 | mock.return_value = mock_file | ||
| 20 | yield mock_file | ||
| 21 | |||
| 22 | |||
| 23 | @pytest.mark.asyncio | ||
| 24 | async def test_qr_generate_success(qr_cog, mock_channel, mock_guild, bot, mock_temp_file): | ||
| 25 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 26 | |||
| 27 | with patch('cogs.qr.QR._generate_qr') as mock_generate: | ||
| 28 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 29 | with patch('os.path.exists', return_value=True): | ||
| 30 | with patch('os.unlink'): | ||
| 31 | await qr_cog.qr.callback(qr_cog, ctx, text="https://example.com") | ||
| 32 | mock_generate.assert_called_once() | ||
| 33 | mock_respond.assert_called_once() | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_qr_generate_empty_text(qr_cog, mock_channel, mock_guild, bot): | ||
| 38 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 39 | |||
| 40 | with patch('cogs.qr.QR._generate_qr'): | ||
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 42 | with patch('os.path.exists', return_value=False): | ||
| 43 | await qr_cog.qr.callback(qr_cog, ctx, text="") | ||
| 44 | mock_respond.assert_called_once() | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_qr_decode_not_available(qr_cog, mock_channel, mock_guild, bot): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | mock_attachment = MagicMock() | ||
| 52 | mock_attachment.url = "https://example.com/qr.png" | ||
| 53 | |||
| 54 | with patch('cogs.qr._ensure_qr', return_value=False): | ||
| 55 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 56 | await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) | ||
| 57 | mock_respond.assert_called_once() | ||
| 58 | args = mock_respond.call_args | ||
| 59 | assert "nie jest dostępne" in str(args) or "not available" in str(args).lower() | ||
| 60 | |||
| 61 | |||
| 62 | @pytest.mark.asyncio | ||
| 63 | async def test_qr_decode_download_failure(qr_cog, mock_channel, mock_guild, bot): | ||
| 64 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 65 | |||
| 66 | mock_attachment = MagicMock() | ||
| 67 | mock_attachment.url = "https://example.com/qr.png" | ||
| 68 | |||
| 69 | with patch('cogs.qr._ensure_qr', return_value=True): | ||
| 70 | with patch.object(qr_cog, '_download_image', return_value=None): | ||
| 71 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 72 | await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) | ||
| 73 | mock_respond.assert_called_once() | ||
| 74 | args = mock_respond.call_args | ||
| 75 | assert "Nie udało się" in str(args) or "failed" in str(args).lower() | ||
diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py deleted file mode 100644 index 76f1b94..0000000 --- a/tests/cogs/test_say.py +++ /dev/null | |||
| @@ -1,38 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch | ||
| 3 | from cogs.say import Say | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def say_cog(bot): | ||
| 9 | cog = Say(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_say_command(say_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 18 | await say_cog.say.callback(say_cog, ctx, "Hello World") | ||
| 19 | mock_send.assert_called_once_with(content="Hello World") | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_say_command_empty_text(say_cog, mock_channel, mock_guild, bot): | ||
| 24 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 25 | |||
| 26 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 27 | await say_cog.say.callback(say_cog, ctx, "") | ||
| 28 | mock_send.assert_called_once_with(content="") | ||
| 29 | |||
| 30 | |||
| 31 | @pytest.mark.asyncio | ||
| 32 | async def test_say_command_long_text(say_cog, mock_channel, mock_guild, bot): | ||
| 33 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 34 | long_text = "A" * 2000 | ||
| 35 | |||
| 36 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 37 | await say_cog.say.callback(say_cog, ctx, long_text) | ||
| 38 | mock_send.assert_called_once_with(content=long_text) | ||
diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py deleted file mode 100644 index c9c7d0e..0000000 --- a/tests/cogs/test_search.py +++ /dev/null | |||
| @@ -1,83 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def search_cog(bot): | ||
| 9 | from cogs.search import Search | ||
| 10 | cog = Search(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_search_command_success(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | ctx.user = mock_user | ||
| 18 | ctx.author = mock_user | ||
| 19 | |||
| 20 | search_cog._cached_results[mock_user.id] = { | ||
| 21 | "testfile.mp4": "https://example.com/files/testfile.mp4" | ||
| 22 | } | ||
| 23 | |||
| 24 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 25 | await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") | ||
| 26 | mock_followup.assert_called_once_with("https://example.com/files/testfile.mp4") | ||
| 27 | assert mock_user.id not in search_cog._cached_results | ||
| 28 | |||
| 29 | |||
| 30 | @pytest.mark.asyncio | ||
| 31 | async def test_search_command_no_cache(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 32 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 33 | ctx.user = mock_user | ||
| 34 | ctx.author = mock_user | ||
| 35 | |||
| 36 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 37 | await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") | ||
| 38 | mock_followup.assert_called_once_with("Search results expired. Please search again.") | ||
| 39 | |||
| 40 | |||
| 41 | @pytest.mark.asyncio | ||
| 42 | async def test_search_command_file_not_found(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 43 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 44 | ctx.user = mock_user | ||
| 45 | ctx.author = mock_user | ||
| 46 | |||
| 47 | search_cog._cached_results[mock_user.id] = { | ||
| 48 | "testfile.mp4": "https://example.com/files/testfile.mp4" | ||
| 49 | } | ||
| 50 | |||
| 51 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 52 | await search_cog.search.callback(search_cog, ctx, filename="nonexistent.mp4") | ||
| 53 | mock_followup.assert_called_once_with("File 'nonexistent.mp4' not found.") | ||
| 54 | |||
| 55 | |||
| 56 | @pytest.mark.asyncio | ||
| 57 | async def test_search_command_empty_filename(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 58 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 59 | ctx.user = mock_user | ||
| 60 | ctx.author = mock_user | ||
| 61 | |||
| 62 | with patch.object(ctx, 'reply', new_callable=AsyncMock) as mock_reply: | ||
| 63 | await search_cog.search.callback(search_cog, ctx, filename="") | ||
| 64 | mock_reply.assert_called_once_with("Please select a file from the autocomplete options.") | ||
| 65 | |||
| 66 | |||
| 67 | @pytest.mark.asyncio | ||
| 68 | async def test_file_autocomplete(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 69 | mock_autocomplete_ctx = MagicMock() | ||
| 70 | mock_autocomplete_ctx.value = "test" | ||
| 71 | mock_autocomplete_ctx.interaction = MagicMock() | ||
| 72 | mock_autocomplete_ctx.interaction.user = mock_user | ||
| 73 | |||
| 74 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 75 | mock_search.return_value = [ | ||
| 76 | "https://example.com/files/test1.mp4", | ||
| 77 | "https://example.com/files/test2.mp4" | ||
| 78 | ] | ||
| 79 | |||
| 80 | choices = await search_cog.file_autocomplete(mock_autocomplete_ctx) | ||
| 81 | |||
| 82 | assert len(choices) == 2 | ||
| 83 | assert mock_user.id in search_cog._cached_results | ||
diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py deleted file mode 100644 index a60a139..0000000 --- a/tests/cogs/test_trigger.py +++ /dev/null | |||
| @@ -1,106 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import sqlite3 | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def trigger_cog(bot): | ||
| 9 | with patch('cogs.trigger.get_connection') as mock_conn: | ||
| 10 | mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock()) | ||
| 11 | mock_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 12 | from cogs.trigger import Trigger | ||
| 13 | cog = Trigger(bot) | ||
| 14 | return cog | ||
| 15 | |||
| 16 | |||
| 17 | @pytest.fixture | ||
| 18 | def mock_db_connection(): | ||
| 19 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 20 | mock_conn = MagicMock() | ||
| 21 | mock_cursor = MagicMock() | ||
| 22 | mock_conn.cursor.return_value = mock_cursor | ||
| 23 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 24 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 25 | yield mock_conn, mock_cursor | ||
| 26 | |||
| 27 | |||
| 28 | @pytest.mark.asyncio | ||
| 29 | async def test_trigger_create_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 30 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 31 | |||
| 32 | mock_conn, mock_cursor = mock_db_connection | ||
| 33 | mock_cursor.fetchone.return_value = None | ||
| 34 | |||
| 35 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 36 | mock_conn = MagicMock() | ||
| 37 | mock_cursor = MagicMock() | ||
| 38 | mock_conn.cursor.return_value = mock_cursor | ||
| 39 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 40 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 41 | |||
| 42 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 43 | await trigger_cog.create.callback(trigger_cog, ctx, name="test_trigger") | ||
| 44 | mock_respond.assert_called_once_with("trigger added") | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_trigger_add_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 52 | mock_conn = MagicMock() | ||
| 53 | mock_cursor = MagicMock() | ||
| 54 | mock_cursor.fetchone.return_value = (1,) # trigger exists | ||
| 55 | mock_conn.cursor.return_value = mock_cursor | ||
| 56 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 57 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 58 | |||
| 59 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 60 | await trigger_cog.add.callback(trigger_cog, ctx, name="test_trigger", trigger="hello", response="Hi there!") | ||
| 61 | mock_respond.assert_called_once_with("trigger updated") | ||
| 62 | |||
| 63 | |||
| 64 | @pytest.mark.asyncio | ||
| 65 | async def test_trigger_add_command_not_found(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 66 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 67 | |||
| 68 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 69 | mock_conn = MagicMock() | ||
| 70 | mock_cursor = MagicMock() | ||
| 71 | mock_cursor.fetchone.return_value = None # trigger not found | ||
| 72 | mock_conn.cursor.return_value = mock_cursor | ||
| 73 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 74 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 75 | |||
| 76 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 77 | await trigger_cog.add.callback(trigger_cog, ctx, name="nonexistent", trigger="hello", response="Hi") | ||
| 78 | mock_respond.assert_called_once_with("Trigger not found.") | ||
| 79 | |||
| 80 | |||
| 81 | @pytest.mark.asyncio | ||
| 82 | async def test_trigger_delete_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 83 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 84 | |||
| 85 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 86 | mock_conn = MagicMock() | ||
| 87 | mock_cursor = MagicMock() | ||
| 88 | mock_cursor.fetchone.return_value = (1,) # trigger exists | ||
| 89 | mock_conn.cursor.return_value = mock_cursor | ||
| 90 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 91 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 92 | |||
| 93 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 94 | await trigger_cog.delete.callback(trigger_cog, ctx, name="test_trigger") | ||
| 95 | mock_respond.assert_called_once_with("trigger deleted") | ||
| 96 | |||
| 97 | |||
| 98 | @pytest.mark.asyncio | ||
| 99 | async def test_trigger_help_command(trigger_cog, mock_channel, mock_guild, bot): | ||
| 100 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 101 | |||
| 102 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 103 | await trigger_cog.help.callback(trigger_cog, ctx) | ||
| 104 | mock_respond.assert_called_once() | ||
| 105 | args = mock_respond.call_args | ||
| 106 | assert "Trigger create" in str(args) | ||
diff --git a/tests/cogs/test_trigger_integration.py b/tests/cogs/test_trigger_integration.py deleted file mode 100644 index 4828387..0000000 --- a/tests/cogs/test_trigger_integration.py +++ /dev/null | |||
| @@ -1,54 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 3 | from cogs.trigger import Trigger | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def trigger_cog(bot): | ||
| 8 | from cogs.utils.database import init_database | ||
| 9 | init_database() | ||
| 10 | from cogs.trigger import Trigger | ||
| 11 | cog = Trigger(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_trigger_response_flow(trigger_cog): | ||
| 18 | """Test trigger creation and response in a simulated message flow.""" | ||
| 19 | cog = trigger_cog | ||
| 20 | |||
| 21 | ctx = MagicMock() | ||
| 22 | ctx.author = MagicMock() | ||
| 23 | ctx.author.id = 123 | ||
| 24 | ctx.guild = MagicMock() | ||
| 25 | ctx.guild.id = 456 | ||
| 26 | ctx.respond = AsyncMock() | ||
| 27 | |||
| 28 | # Create a trigger using the create command | ||
| 29 | await cog.create.callback(cog, ctx, name="test_trigger") | ||
| 30 | |||
| 31 | # Add a keyword and response | ||
| 32 | await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world") | ||
| 33 | |||
| 34 | # Verify trigger was created - check DB | ||
| 35 | from cogs.utils.database import get_connection | ||
| 36 | with get_connection() as conn: | ||
| 37 | c = conn.cursor() | ||
| 38 | c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",)) | ||
| 39 | result = c.fetchone() | ||
| 40 | assert result is not None | ||
| 41 | |||
| 42 | # Simulate a message that should trigger | ||
| 43 | message = MagicMock() | ||
| 44 | message.author = ctx.author | ||
| 45 | message.author.bot = False | ||
| 46 | message.guild = ctx.guild | ||
| 47 | message.content = "say hello to me" | ||
| 48 | message.channel.send = AsyncMock() # Make send awaitable | ||
| 49 | |||
| 50 | # Check if trigger matches using the static method | ||
| 51 | await Trigger.trigger(message) | ||
| 52 | |||
| 53 | # Verify message.channel.send was called with the response | ||
| 54 | message.channel.send.assert_called_once_with("world") | ||
diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py deleted file mode 100644 index c6563e6..0000000 --- a/tests/cogs/test_tts.py +++ /dev/null | |||
| @@ -1,58 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def tts_cog(bot): | ||
| 9 | from cogs.tts import TTS | ||
| 10 | cog = TTS(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_tts_file_output(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | ctx.user = mock_user | ||
| 18 | ctx.author = mock_user | ||
| 19 | |||
| 20 | "/tmp/test_tts.mp3" | ||
| 21 | |||
| 22 | with patch('cogs.tts.gTTS') as _: | ||
| 23 | with patch('os.path.exists', return_value=True): | ||
| 24 | with patch('os.unlink'): | ||
| 25 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 26 | with patch('cogs.tts.get_volume', return_value=1.0): | ||
| 27 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") | ||
| 28 | mock_respond.assert_called_once() | ||
| 29 | |||
| 30 | |||
| 31 | @pytest.mark.asyncio | ||
| 32 | async def test_tts_voice_output_not_in_channel(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 33 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 34 | ctx.user = mock_user | ||
| 35 | ctx.author = mock_user | ||
| 36 | ctx.author.voice = None | ||
| 37 | |||
| 38 | with patch('cogs.tts.gTTS'): | ||
| 39 | with patch('os.path.exists', return_value=True): | ||
| 40 | with patch('os.unlink'): | ||
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 42 | with patch('cogs.tts.get_volume', return_value=1.0): | ||
| 43 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="vc") | ||
| 44 | |||
| 45 | |||
| 46 | @pytest.mark.asyncio | ||
| 47 | async def test_tts_error_handling(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 48 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 49 | ctx.user = mock_user | ||
| 50 | ctx.author = mock_user | ||
| 51 | |||
| 52 | with patch('cogs.tts.gTTS', side_effect=Exception("TTS Error")): | ||
| 53 | with patch('os.path.exists', return_value=False): | ||
| 54 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 55 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") | ||
| 56 | mock_respond.assert_called_once() | ||
| 57 | args = mock_respond.call_args | ||
| 58 | assert "Error" in str(args) or "eror" in str(args) | ||
diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py deleted file mode 100644 index 0777126..0000000 --- a/tests/cogs/test_uploader.py +++ /dev/null | |||
| @@ -1,226 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | import discord | ||
| 5 | from conftest import MockApplicationContext, MockMessage, MockUser | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def uploader_cog(bot): | ||
| 10 | with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'): | ||
| 11 | from cogs.uploader import Uploader | ||
| 12 | cog = Uploader(bot) | ||
| 13 | return cog | ||
| 14 | |||
| 15 | |||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_file_autocomplete(uploader_cog): | ||
| 18 | mock_autocomplete_ctx = MagicMock() | ||
| 19 | mock_autocomplete_ctx.value = "test" | ||
| 20 | mock_interaction = MagicMock() | ||
| 21 | mock_user = MockUser(id=12345) | ||
| 22 | mock_interaction.user = mock_user | ||
| 23 | mock_autocomplete_ctx.interaction = mock_interaction | ||
| 24 | |||
| 25 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 26 | mock_search.return_value = [ | ||
| 27 | "https://copyparty.local/testfile1.mp3", | ||
| 28 | "https://copyparty.local/testfile2.mp3" | ||
| 29 | ] | ||
| 30 | result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) | ||
| 31 | assert len(result) == 2 | ||
| 32 | assert result[0].name == "testfile1.mp3" | ||
| 33 | |||
| 34 | |||
| 35 | @pytest.mark.asyncio | ||
| 36 | async def test_file_autocomplete_empty_query(uploader_cog): | ||
| 37 | mock_autocomplete_ctx = MagicMock() | ||
| 38 | mock_autocomplete_ctx.value = "" | ||
| 39 | mock_interaction = MagicMock() | ||
| 40 | mock_interaction.user = MockUser(id=12345) | ||
| 41 | mock_autocomplete_ctx.interaction = mock_interaction | ||
| 42 | |||
| 43 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 44 | result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) | ||
| 45 | mock_search.assert_called_once_with("", max_results=25) | ||
| 46 | |||
| 47 | |||
| 48 | @pytest.mark.asyncio | ||
| 49 | async def test_download_from_url_success(uploader_cog): | ||
| 50 | with patch('requests.get') as mock_get: | ||
| 51 | mock_response = MagicMock() | ||
| 52 | mock_response.status_code = 200 | ||
| 53 | mock_response.content = b"test file content" | ||
| 54 | mock_get.return_value = mock_response | ||
| 55 | |||
| 56 | mock_file = MagicMock() | ||
| 57 | mock_file.write = AsyncMock() | ||
| 58 | |||
| 59 | mock_cm = AsyncMock() | ||
| 60 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 61 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 62 | |||
| 63 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 64 | result = await uploader_cog._download_from_url("https://copyparty.local/file.txt") | ||
| 65 | |||
| 66 | assert result is not None | ||
| 67 | assert "file.txt" in result | ||
| 68 | mock_file.write.assert_called_once_with(b"test file content") | ||
| 69 | |||
| 70 | |||
| 71 | @pytest.mark.asyncio | ||
| 72 | async def test_download_from_url_failure(uploader_cog): | ||
| 73 | with patch('requests.get') as mock_get: | ||
| 74 | mock_response = MagicMock() | ||
| 75 | mock_response.status_code = 404 | ||
| 76 | mock_get.return_value = mock_response | ||
| 77 | |||
| 78 | result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt") | ||
| 79 | |||
| 80 | assert result is None | ||
| 81 | |||
| 82 | |||
| 83 | @pytest.mark.asyncio | ||
| 84 | async def test_upload_to_copyparty_success(uploader_cog): | ||
| 85 | mock_file = MagicMock() | ||
| 86 | mock_file.read = AsyncMock(return_value=b"test content") | ||
| 87 | |||
| 88 | mock_cm = AsyncMock() | ||
| 89 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 90 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 91 | |||
| 92 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 93 | with patch('requests.post') as mock_post: | ||
| 94 | mock_response = MagicMock() | ||
| 95 | mock_response.status_code = 200 | ||
| 96 | mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"} | ||
| 97 | mock_post.return_value = mock_response | ||
| 98 | |||
| 99 | with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}): | ||
| 100 | result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") | ||
| 101 | |||
| 102 | assert result == "https://copyparty.local/uploaded.txt" | ||
| 103 | |||
| 104 | |||
| 105 | @pytest.mark.asyncio | ||
| 106 | async def test_upload_to_copyparty_failure(uploader_cog): | ||
| 107 | mock_file = MagicMock() | ||
| 108 | mock_file.read = AsyncMock(return_value=b"test content") | ||
| 109 | |||
| 110 | mock_cm = AsyncMock() | ||
| 111 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 112 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 113 | |||
| 114 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 115 | with patch('requests.post') as mock_post: | ||
| 116 | mock_response = MagicMock() | ||
| 117 | mock_response.status_code = 500 | ||
| 118 | mock_post.return_value = mock_response | ||
| 119 | |||
| 120 | result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") | ||
| 121 | |||
| 122 | assert result is None | ||
| 123 | |||
| 124 | |||
| 125 | @pytest.mark.asyncio | ||
| 126 | async def test_upload_file_no_url(uploader_cog): | ||
| 127 | uploader_cog._uploadurl = None | ||
| 128 | |||
| 129 | result = await uploader_cog._upload_file("/tmp/test.txt") | ||
| 130 | |||
| 131 | assert result is None | ||
| 132 | |||
| 133 | |||
| 134 | @pytest.mark.asyncio | ||
| 135 | async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot): | ||
| 136 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 137 | |||
| 138 | mock_message = MagicMock() | ||
| 139 | mock_message.attachments = [] | ||
| 140 | |||
| 141 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 142 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 143 | mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True) | ||
| 144 | |||
| 145 | |||
| 146 | @pytest.mark.asyncio | ||
| 147 | async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot): | ||
| 148 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 149 | |||
| 150 | mock_attachment = MagicMock() | ||
| 151 | mock_attachment.filename = "test.txt" | ||
| 152 | mock_attachment.save = AsyncMock() | ||
| 153 | |||
| 154 | mock_message = MagicMock() | ||
| 155 | mock_message.attachments = [mock_attachment] | ||
| 156 | |||
| 157 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 158 | mock_upload.return_value = "https://copyparty.local/uploaded.txt" | ||
| 159 | |||
| 160 | with patch('os.path.exists', return_value=True): | ||
| 161 | with patch('os.remove'): | ||
| 162 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 163 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 164 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 165 | mock_respond.assert_called_once() | ||
| 166 | assert "Wysłano" in str(mock_respond.call_args) | ||
| 167 | |||
| 168 | |||
| 169 | @pytest.mark.asyncio | ||
| 170 | async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot): | ||
| 171 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 172 | |||
| 173 | mock_attachment = MagicMock() | ||
| 174 | mock_attachment.filename = "test.txt" | ||
| 175 | mock_attachment.save = AsyncMock() | ||
| 176 | |||
| 177 | mock_message = MagicMock() | ||
| 178 | mock_message.attachments = [mock_attachment] | ||
| 179 | |||
| 180 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 181 | mock_upload.return_value = None | ||
| 182 | |||
| 183 | with patch('os.path.exists', return_value=True): | ||
| 184 | with patch('os.remove'): | ||
| 185 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 186 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 187 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 188 | mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 189 | |||
| 190 | |||
| 191 | @pytest.mark.asyncio | ||
| 192 | async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot): | ||
| 193 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 194 | |||
| 195 | mock_attachment = MagicMock() | ||
| 196 | mock_attachment.filename = "test.txt" | ||
| 197 | mock_attachment.save = AsyncMock() | ||
| 198 | |||
| 199 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 200 | mock_upload.return_value = "https://copyparty.local/uploaded.txt" | ||
| 201 | |||
| 202 | with patch('os.path.exists', return_value=True): | ||
| 203 | with patch('os.remove'): | ||
| 204 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 205 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 206 | await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) | ||
| 207 | mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt") | ||
| 208 | |||
| 209 | |||
| 210 | @pytest.mark.asyncio | ||
| 211 | async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot): | ||
| 212 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 213 | |||
| 214 | mock_attachment = MagicMock() | ||
| 215 | mock_attachment.filename = "test.txt" | ||
| 216 | mock_attachment.save = AsyncMock() | ||
| 217 | |||
| 218 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 219 | mock_upload.return_value = None | ||
| 220 | |||
| 221 | with patch('os.path.exists', return_value=True): | ||
| 222 | with patch('os.remove'): | ||
| 223 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 224 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 225 | await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) | ||
| 226 | mock_respond.assert_called_once_with("Nie udało się wysłać pliku", ephemeral=True) \ No newline at end of file | ||
diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py deleted file mode 100644 index 17ff839..0000000 --- a/tests/cogs/test_vclogs.py +++ /dev/null | |||
| @@ -1,88 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | def test_format_duration(): | ||
| 7 | from cogs.vclogs import format_duration | ||
| 8 | |||
| 9 | assert format_duration(30) == "30s" | ||
| 10 | assert format_duration(90) == "1m 30s" | ||
| 11 | assert format_duration(3661) == "1h 1m 1s" | ||
| 12 | |||
| 13 | |||
| 14 | def test_load_activity_data(): | ||
| 15 | with patch("cogs.vclogs.get_connection") as mock_conn: | ||
| 16 | mock_cursor = MagicMock() | ||
| 17 | mock_conn.return_value.cursor.return_value = mock_cursor | ||
| 18 | mock_conn.return_value.close.return_value = None | ||
| 19 | mock_cursor.fetchall.return_value = [] | ||
| 20 | |||
| 21 | from cogs.vclogs import load_activity_data | ||
| 22 | data = load_activity_data() | ||
| 23 | assert isinstance(data, dict) | ||
| 24 | |||
| 25 | |||
| 26 | def test_save_activity_data(): | ||
| 27 | with patch("cogs.vclogs.get_connection") as mock_conn: | ||
| 28 | mock_cursor = MagicMock() | ||
| 29 | mock_conn.return_value.cursor.return_value = mock_cursor | ||
| 30 | mock_conn.return_value.commit.return_value = None | ||
| 31 | mock_conn.return_value.close.return_value = None | ||
| 32 | |||
| 33 | from cogs.vclogs import save_activity_data | ||
| 34 | save_activity_data({"123": {"active": True}}) | ||
| 35 | |||
| 36 | |||
| 37 | @pytest.fixture | ||
| 38 | def vclogs_cog(bot): | ||
| 39 | with patch('cogs.vclogs.get_voice_manager'): | ||
| 40 | with patch('cogs.vclogs.get_volume', return_value=1.0): | ||
| 41 | with patch('cogs.vclogs.save_settings'): | ||
| 42 | from cogs.vclogs import VcLogs | ||
| 43 | cog = VcLogs(bot) | ||
| 44 | return cog | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_handle_state_change_deaf_true(vclogs_cog): | ||
| 49 | mock_member = MagicMock() | ||
| 50 | mock_member.display_name = "TestUser" | ||
| 51 | mock_channel = MagicMock() | ||
| 52 | |||
| 53 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', True, None) | ||
| 54 | |||
| 55 | assert "jest bity przez ojca" in tts_text | ||
| 56 | |||
| 57 | |||
| 58 | @pytest.mark.asyncio | ||
| 59 | async def test_handle_state_change_deaf_false(vclogs_cog): | ||
| 60 | mock_member = MagicMock() | ||
| 61 | mock_member.display_name = "TestUser" | ||
| 62 | mock_channel = MagicMock() | ||
| 63 | |||
| 64 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', False, None) | ||
| 65 | |||
| 66 | assert "uciek od ojca" in tts_text | ||
| 67 | |||
| 68 | |||
| 69 | @pytest.mark.asyncio | ||
| 70 | async def test_handle_state_change_mute_true(vclogs_cog): | ||
| 71 | mock_member = MagicMock() | ||
| 72 | mock_member.display_name = "TestUser" | ||
| 73 | mock_channel = MagicMock() | ||
| 74 | |||
| 75 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', True, None) | ||
| 76 | |||
| 77 | assert "idzie robić jajecznice" in tts_text | ||
| 78 | |||
| 79 | |||
| 80 | @pytest.mark.asyncio | ||
| 81 | async def test_handle_state_change_mute_false(vclogs_cog): | ||
| 82 | mock_member = MagicMock() | ||
| 83 | mock_member.display_name = "TestUser" | ||
| 84 | mock_channel = MagicMock() | ||
| 85 | |||
| 86 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) | ||
| 87 | |||
| 88 | assert "zrobił jajecznice" in tts_text \ No newline at end of file | ||
diff --git a/tests/cogs/test_vclogs_integration.py b/tests/cogs/test_vclogs_integration.py deleted file mode 100644 index 9391876..0000000 --- a/tests/cogs/test_vclogs_integration.py +++ /dev/null | |||
| @@ -1,86 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | import asyncio | ||
| 3 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def vclogs_cog(bot): | ||
| 8 | from cogs.vclogs import VcLogs | ||
| 9 | cog = VcLogs(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.slow | ||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_full_voice_session_tracking(vclogs_cog, monkeypatch): | ||
| 16 | """Test complete voice session with AFK transitions using real time sleeps.""" | ||
| 17 | cog = vclogs_cog | ||
| 18 | |||
| 19 | # Set up environment | ||
| 20 | monkeypatch.setenv("STATUS_CHANNEL_ID", "123456") | ||
| 21 | |||
| 22 | member = MagicMock() | ||
| 23 | member.id = 123 | ||
| 24 | member.guild.id = 456 | ||
| 25 | member.bot = False | ||
| 26 | member.display_name = "TestUser" | ||
| 27 | member.guild.get_member.return_value = member | ||
| 28 | |||
| 29 | # Create a mock log channel | ||
| 30 | log_channel = MagicMock() | ||
| 31 | log_channel.send = AsyncMock(return_value=MagicMock()) | ||
| 32 | |||
| 33 | # Mock bot.get_channel to return our log channel | ||
| 34 | with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel): | ||
| 35 | # Join voice | ||
| 36 | before = MagicMock() | ||
| 37 | before.channel = None | ||
| 38 | after_join = MagicMock() | ||
| 39 | after_join.channel = MagicMock() | ||
| 40 | after_join.channel.id = 789 | ||
| 41 | after_join.channel.name = "Test Voice" | ||
| 42 | after_join.self_deaf = False | ||
| 43 | after_join.self_mute = False | ||
| 44 | after_join.deaf = False | ||
| 45 | after_join.mute = False | ||
| 46 | await cog.on_voice_state_update(member, before, after_join) | ||
| 47 | |||
| 48 | await asyncio.sleep(2) # Simulate 2s active | ||
| 49 | |||
| 50 | # Go AFK (self_deaf) | ||
| 51 | before_afk = after_join | ||
| 52 | after_afk = MagicMock() | ||
| 53 | after_afk.channel = before_afk.channel | ||
| 54 | after_afk.self_deaf = True | ||
| 55 | after_afk.self_mute = False | ||
| 56 | after_afk.deaf = True | ||
| 57 | after_afk.mute = False | ||
| 58 | await cog.on_voice_state_update(member, before_afk, after_afk) | ||
| 59 | |||
| 60 | await asyncio.sleep(3) # Simulate 3s AFK | ||
| 61 | |||
| 62 | # Return from AFK | ||
| 63 | before_back = after_afk | ||
| 64 | after_back = MagicMock() | ||
| 65 | after_back.channel = before_back.channel | ||
| 66 | after_back.self_deaf = False | ||
| 67 | after_back.self_mute = False | ||
| 68 | after_back.deaf = False | ||
| 69 | after_back.mute = False | ||
| 70 | await cog.on_voice_state_update(member, before_back, after_back) | ||
| 71 | |||
| 72 | await asyncio.sleep(1) # Simulate 1s active | ||
| 73 | |||
| 74 | # Leave voice | ||
| 75 | before_leave = after_back | ||
| 76 | after_leave = MagicMock() | ||
| 77 | after_leave.channel = None | ||
| 78 | after_leave.self_deaf = False | ||
| 79 | after_leave.self_mute = False | ||
| 80 | await cog.on_voice_state_update(member, before_leave, after_leave) | ||
| 81 | |||
| 82 | # Verify activity was saved (check DB) | ||
| 83 | from cogs.vclogs import load_activity_data | ||
| 84 | activity_data = load_activity_data() | ||
| 85 | # After leaving, data should be deleted, so check the log channel was called | ||
| 86 | assert log_channel.send.called | ||
diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py deleted file mode 100644 index 6eb226b..0000000 --- a/tests/cogs/test_voice.py +++ /dev/null | |||
| @@ -1,79 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import asyncio | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.mark.asyncio | ||
| 8 | async def test_get_voice_manager(): | ||
| 9 | from cogs.voice import get_voice_manager, VoiceManager | ||
| 10 | |||
| 11 | mock_bot = MagicMock() | ||
| 12 | mock_bot.user = MagicMock() | ||
| 13 | mock_bot.user.id = 12345 | ||
| 14 | |||
| 15 | manager1 = get_voice_manager(mock_bot) | ||
| 16 | manager2 = get_voice_manager(mock_bot) | ||
| 17 | |||
| 18 | assert isinstance(manager1, VoiceManager) | ||
| 19 | assert manager1 is manager2 | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_voice_manager_join_channel_not_found(): | ||
| 24 | from cogs.voice import VoiceManager | ||
| 25 | |||
| 26 | mock_bot = MagicMock() | ||
| 27 | mock_bot.get_channel.return_value = None | ||
| 28 | |||
| 29 | manager = VoiceManager(mock_bot) | ||
| 30 | result = await manager.join_by_channel_id(999) | ||
| 31 | |||
| 32 | assert result is None | ||
| 33 | |||
| 34 | |||
| 35 | @pytest.mark.asyncio | ||
| 36 | async def test_voice_manager_join_invalid_channel(): | ||
| 37 | from cogs.voice import VoiceManager | ||
| 38 | |||
| 39 | mock_bot = MagicMock() | ||
| 40 | mock_channel = MagicMock() | ||
| 41 | mock_bot.get_channel.return_value = mock_channel | ||
| 42 | |||
| 43 | manager = VoiceManager(mock_bot) | ||
| 44 | result = await manager.join_by_channel_id(999) | ||
| 45 | |||
| 46 | assert result is None | ||
| 47 | |||
| 48 | |||
| 49 | @pytest.mark.asyncio | ||
| 50 | async def test_voice_manager_get_voice_status_not_connected(): | ||
| 51 | from cogs.voice import VoiceManager | ||
| 52 | |||
| 53 | mock_bot = MagicMock() | ||
| 54 | mock_guild = MagicMock() | ||
| 55 | |||
| 56 | with patch('cogs.voice.VoiceManager.get_client', return_value=None): | ||
| 57 | manager = VoiceManager(mock_bot) | ||
| 58 | voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) | ||
| 59 | |||
| 60 | assert voice_client is None | ||
| 61 | assert is_connected == False | ||
| 62 | assert is_playing == False | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.asyncio | ||
| 66 | async def test_voice_manager_stop(): | ||
| 67 | from cogs.voice import VoiceManager | ||
| 68 | |||
| 69 | mock_bot = MagicMock() | ||
| 70 | mock_guild = MagicMock() | ||
| 71 | mock_voice_client = MagicMock() | ||
| 72 | mock_voice_client.disconnect = AsyncMock() | ||
| 73 | |||
| 74 | with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client): | ||
| 75 | manager = VoiceManager(mock_bot) | ||
| 76 | await manager.stop(mock_guild) | ||
| 77 | |||
| 78 | mock_voice_client.stop.assert_called_once() | ||
| 79 | mock_voice_client.disconnect.assert_called_once() | ||
diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py deleted file mode 100644 index 4de2e8b..0000000 --- a/tests/cogs/test_web.py +++ /dev/null | |||
| @@ -1,57 +0,0 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from cogs.web import Web | ||
| 4 | from conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def web_cog(bot): | ||
| 9 | cog = Web(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_web_command_success(web_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | mock_response = MagicMock() | ||
| 18 | mock_response.status_code = 200 | ||
| 19 | mock_response.text = ''' | ||
| 20 | <html> | ||
| 21 | <a class="result__a" href="https://example.com">Test Result</a> | ||
| 22 | <a class="result__a" href="https://example.com/2">Test Result 2</a> | ||
| 23 | </html> | ||
| 24 | ''' | ||
| 25 | |||
| 26 | with patch('cogs.web.requests.get', return_value=mock_response) as mock_get: | ||
| 27 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 28 | await web_cog.web.callback(web_cog, ctx, query="test query") | ||
| 29 | mock_get.assert_called_once() | ||
| 30 | mock_respond.assert_called_once() | ||
| 31 | |||
| 32 | |||
| 33 | @pytest.mark.asyncio | ||
| 34 | async def test_web_command_no_results(web_cog, mock_channel, mock_guild, bot): | ||
| 35 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 36 | |||
| 37 | mock_response = MagicMock() | ||
| 38 | mock_response.status_code = 200 | ||
| 39 | mock_response.text = '<html><body></body></html>' | ||
| 40 | |||
| 41 | with patch('cogs.web.requests.get', return_value=mock_response): | ||
| 42 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 43 | await web_cog.web.callback(web_cog, ctx, query="test") | ||
| 44 | mock_respond.assert_called_once_with(content="No results found.", ephemeral=True) | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_web_command_http_error(web_cog, mock_channel, mock_guild, bot): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | mock_response = MagicMock() | ||
| 52 | mock_response.status_code = 500 | ||
| 53 | |||
| 54 | with patch('cogs.web.requests.get', return_value=mock_response): | ||
| 55 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 56 | await web_cog.web.callback(web_cog, ctx, query="test") | ||
| 57 | mock_respond.assert_called_once_with(content="Error: 500", ephemeral=True) | ||
diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 6b7c3bc..0000000 --- a/tests/conftest.py +++ /dev/null | |||
| @@ -1,228 +0,0 @@ | |||
| 1 | import asyncio | ||
| 2 | import os | ||
| 3 | import sys | ||
| 4 | import tempfile | ||
| 5 | from unittest.mock import AsyncMock, MagicMock | ||
| 6 | import pytest | ||
| 7 | |||
| 8 | import discord | ||
| 9 | from discord.ext import commands | ||
| 10 | |||
| 11 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.fixture(autouse=True, scope="function") | ||
| 15 | def setup_test_env(monkeypatch): | ||
| 16 | test_db = os.path.join(tempfile.gettempdir(), "test_senator.db") | ||
| 17 | if os.path.exists(test_db): | ||
| 18 | os.remove(test_db) | ||
| 19 | monkeypatch.setenv("SENATOR_DB_PATH", test_db) | ||
| 20 | reload_database() | ||
| 21 | yield | ||
| 22 | if os.path.exists(test_db): | ||
| 23 | os.remove(test_db) | ||
| 24 | |||
| 25 | |||
| 26 | def reload_database(): | ||
| 27 | import cogs.utils.database as db_module | ||
| 28 | db_module._initialized = False | ||
| 29 | |||
| 30 | |||
| 31 | class MockUser: | ||
| 32 | def __init__(self, id=12345, name="testuser", discriminator="0001"): | ||
| 33 | self.id = id | ||
| 34 | self.name = name | ||
| 35 | self.discriminator = discriminator | ||
| 36 | self.mention = f"@{name}" | ||
| 37 | self.display_name = name | ||
| 38 | self.avatar = None | ||
| 39 | self.bot = False | ||
| 40 | self.guild = None | ||
| 41 | self._state = MagicMock() | ||
| 42 | |||
| 43 | def __str__(self): | ||
| 44 | return f"{self.name}#{self.discriminator}" | ||
| 45 | |||
| 46 | |||
| 47 | class MockChannel: | ||
| 48 | def __init__(self, id=11111, name="test-channel", type=discord.ChannelType.text): | ||
| 49 | self.id = id | ||
| 50 | self.name = name | ||
| 51 | self.type = type | ||
| 52 | self.mention = f"#{name}" | ||
| 53 | self.guild = None | ||
| 54 | self.category = None | ||
| 55 | self._state = MagicMock() | ||
| 56 | |||
| 57 | def send(self, *args, **kwargs): | ||
| 58 | return MagicMock() | ||
| 59 | |||
| 60 | |||
| 61 | class MockTextChannel(MockChannel): | ||
| 62 | def __init__(self, id=11111, name="test-channel"): | ||
| 63 | super().__init__(id, name, discord.ChannelType.text) | ||
| 64 | |||
| 65 | |||
| 66 | class MockVoiceChannel(MockChannel): | ||
| 67 | def __init__(self, id=11112, name="test-voice"): | ||
| 68 | super().__init__(id, name, discord.ChannelType.voice) | ||
| 69 | self.voice_states = {} | ||
| 70 | |||
| 71 | |||
| 72 | class MockGuild: | ||
| 73 | def __init__(self, id=99999, name="Test Server"): | ||
| 74 | self.id = id | ||
| 75 | self.name = name | ||
| 76 | self.channels = [] | ||
| 77 | self.roles = [] | ||
| 78 | self.emojis = [] | ||
| 79 | self.me = MockUser(id=111111, name="Sen Bot") | ||
| 80 | self._state = MagicMock() | ||
| 81 | self._default_role = None | ||
| 82 | |||
| 83 | def get_channel(self, channel_id): | ||
| 84 | for ch in self.channels: | ||
| 85 | if ch.id == channel_id: | ||
| 86 | return ch | ||
| 87 | return None | ||
| 88 | |||
| 89 | def get_role(self, role_id): | ||
| 90 | for role in self.roles: | ||
| 91 | if role.id == role_id: | ||
| 92 | return role | ||
| 93 | return None | ||
| 94 | |||
| 95 | def get_member(self, member_id): | ||
| 96 | return None | ||
| 97 | |||
| 98 | |||
| 99 | class MockMessage: | ||
| 100 | def __init__(self, id=22222, content="test message", author=None, channel=None, guild=None): | ||
| 101 | self.id = id | ||
| 102 | self.content = content | ||
| 103 | self.author = author or MockUser() | ||
| 104 | self.channel = channel or MockChannel() | ||
| 105 | self.guild = guild | ||
| 106 | self.attachments = [] | ||
| 107 | self.embeds = [] | ||
| 108 | self.jump_url = "https://discord.com/channels/..." | ||
| 109 | self.created_at = None | ||
| 110 | |||
| 111 | async def reply(self, *args, **kwargs): | ||
| 112 | return MagicMock() | ||
| 113 | # Stub for reply - kept async to match Discord interface signature | ||
| 114 | |||
| 115 | |||
| 116 | class MockInteraction: | ||
| 117 | def __init__(self, user=None, channel=None, guild=None): | ||
| 118 | self.user = user or MockUser() | ||
| 119 | self.channel = channel or MockChannel() | ||
| 120 | self.guild = guild or MockGuild() | ||
| 121 | self.message = None | ||
| 122 | self.response = AsyncMock() | ||
| 123 | self.followup = AsyncMock() | ||
| 124 | self.client = None | ||
| 125 | self.id = 33333 | ||
| 126 | self.token = "test_token" | ||
| 127 | self._state = MagicMock() | ||
| 128 | |||
| 129 | async def defer(self, *args, **kwargs): | ||
| 130 | pass | ||
| 131 | |||
| 132 | async def respond(self, *args, **kwargs): | ||
| 133 | pass | ||
| 134 | |||
| 135 | async def delete_original_response(self, *args, **kwargs): | ||
| 136 | pass | ||
| 137 | |||
| 138 | async def send(self, *args, **kwargs): | ||
| 139 | return MagicMock() | ||
| 140 | |||
| 141 | |||
| 142 | # Stub for Discord interaction - test implementation only | ||
| 143 | class MockApplicationContext: | ||
| 144 | def __init__(self, bot=None, interaction=None, user=None, channel=None, guild=None): | ||
| 145 | self.bot = bot | ||
| 146 | self.interaction = interaction or MockInteraction(user=user, channel=channel, guild=guild) | ||
| 147 | self.user = user or MockUser() | ||
| 148 | self.channel = channel or MockChannel() | ||
| 149 | self.guild = guild or MockGuild() | ||
| 150 | self.message = None | ||
| 151 | self.response = AsyncMock() | ||
| 152 | self.followup = AsyncMock() | ||
| 153 | self.client = self.bot | ||
| 154 | self.id = 33333 | ||
| 155 | self.token = "test_token" | ||
| 156 | self._state = self.interaction._state | ||
| 157 | self.author = self.user | ||
| 158 | |||
| 159 | async def defer(self, *args, **kwargs): | ||
| 160 | pass | ||
| 161 | # Stub for defer - tests don't verify interaction state | ||
| 162 | |||
| 163 | async def respond(self, *args, **kwargs): | ||
| 164 | pass | ||
| 165 | # Stub for respond - tests mock the response | ||
| 166 | |||
| 167 | async def delete_original_response(self, *args, **kwargs): | ||
| 168 | pass | ||
| 169 | # Stub for delete_original_response - not used in tests | ||
| 170 | |||
| 171 | async def send(self, *args, **kwargs): | ||
| 172 | return MagicMock() | ||
| 173 | # Stub for send - not used in tests | ||
| 174 | |||
| 175 | async def reply(self, *args, **kwargs): | ||
| 176 | return MagicMock() | ||
| 177 | # Stub for reply - not used in tests | ||
| 178 | |||
| 179 | async def send_followup(self, *args, **kwargs): | ||
| 180 | return MagicMock() | ||
| 181 | # Stub for send_followup - not used in tests | ||
| 182 | |||
| 183 | |||
| 184 | @pytest.fixture | ||
| 185 | def mock_guild(): | ||
| 186 | return MockGuild() | ||
| 187 | |||
| 188 | |||
| 189 | @pytest.fixture | ||
| 190 | def mock_channel(mock_guild): | ||
| 191 | channel = MockTextChannel() | ||
| 192 | channel.guild = mock_guild | ||
| 193 | return channel | ||
| 194 | |||
| 195 | |||
| 196 | @pytest.fixture | ||
| 197 | def mock_voice_channel(mock_guild): | ||
| 198 | channel = MockVoiceChannel() | ||
| 199 | channel.guild = mock_guild | ||
| 200 | return channel | ||
| 201 | |||
| 202 | |||
| 203 | @pytest.fixture | ||
| 204 | def mock_user(): | ||
| 205 | return MockUser() | ||
| 206 | |||
| 207 | |||
| 208 | @pytest.fixture | ||
| 209 | def mock_member(mock_guild): | ||
| 210 | member = MockUser() | ||
| 211 | member.guild = mock_guild | ||
| 212 | return member | ||
| 213 | |||
| 214 | |||
| 215 | @pytest.fixture | ||
| 216 | def mock_message(mock_channel, mock_user, mock_guild): | ||
| 217 | return MockMessage(channel=mock_channel, author=mock_user, guild=mock_guild) | ||
| 218 | |||
| 219 | |||
| 220 | @pytest.fixture | ||
| 221 | def mock_interaction(mock_channel, mock_user, mock_guild): | ||
| 222 | return MockInteraction(user=mock_user, channel=mock_channel, guild=mock_guild) | ||
| 223 | |||
| 224 | |||
| 225 | @pytest.fixture | ||
| 226 | def bot(): | ||
| 227 | intents = discord.Intents.all() | ||
| 228 | return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop()) | ||
