From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- .env.example | 2 +- .gitignore | 5 +- AGENTS.md | 70 +- Cargo.lock | 6967 +++++++++++++++++++++++++++++ Cargo.toml | 58 + README.md | 159 +- cogs/ai.py | 78 - cogs/assistant.py | 130 - cogs/compression.py | 306 -- cogs/conversion.py | 245 - cogs/cron.py | 403 -- cogs/downloader.py | 234 - cogs/math_commands.py | 83 - cogs/music/join.py | 29 - cogs/music/loop.py | 40 - cogs/music/media_processing.py | 135 - cogs/music/play_commands.py | 320 -- cogs/music/stop.py | 29 - cogs/qr.py | 154 - cogs/say.py | 28 - cogs/search.py | 73 - cogs/trigger.py | 260 -- cogs/tts.py | 111 - cogs/uploader.py | 156 - cogs/utils/database.py | 126 - cogs/utils/file_search.py | 167 - cogs/utils/settings.py | 114 - cogs/utils/temp.py | 18 - cogs/utils/url_helper.py | 21 - cogs/vclogs.py | 316 -- cogs/voice.py | 444 -- cogs/web.py | 90 - main.py | 96 - pyrightconfig.json | 19 - pytest.ini | 9 - requirements-dev.txt | 18 - requirements.txt | 15 - scripts/bump_reqs.py | 50 - src/commands/ai.rs | 76 + src/commands/autocomplete.rs | 128 + src/commands/cron.rs | 155 + src/commands/math.rs | 77 + src/commands/mod.rs | 26 + src/commands/music.rs | 414 ++ src/commands/qr.rs | 87 + src/commands/trigger.rs | 199 + src/commands/tts.rs | 106 + src/commands/utilities.rs | 918 ++++ src/commands/web.rs | 122 + src/config.rs | 145 + src/copyparty/mod.rs | 267 ++ src/database/mod.rs | 344 ++ src/error.rs | 108 + src/events/mod.rs | 368 ++ src/lang.rs | 387 ++ src/main.rs | 190 + src/openrouter/mod.rs | 134 + src/tasks/mod.rs | 172 + src/voice/mod.rs | 404 ++ tests/cogs/test_ai.py | 83 - tests/cogs/test_assistant.py | 66 - tests/cogs/test_compression_conversion.py | 139 - tests/cogs/test_cron.py | 147 - tests/cogs/test_cron_integration.py | 39 - tests/cogs/test_downloader.py | 93 - tests/cogs/test_downloader_integration.py | 37 - tests/cogs/test_math_commands.py | 55 - tests/cogs/test_music_audio.py | 93 - tests/cogs/test_music_external_play.py | 109 - tests/cogs/test_music_integration.py | 46 - tests/cogs/test_music_join.py | 56 - tests/cogs/test_music_play.py | 104 - tests/cogs/test_qr.py | 75 - tests/cogs/test_say.py | 38 - tests/cogs/test_search.py | 83 - tests/cogs/test_trigger.py | 106 - tests/cogs/test_trigger_integration.py | 54 - tests/cogs/test_tts.py | 58 - tests/cogs/test_uploader.py | 226 - tests/cogs/test_vclogs.py | 88 - tests/cogs/test_vclogs_integration.py | 86 - tests/cogs/test_voice.py | 79 - tests/cogs/test_web.py | 57 - tests/conftest.py | 228 - 84 files changed, 11991 insertions(+), 6659 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 cogs/ai.py delete mode 100644 cogs/assistant.py delete mode 100644 cogs/compression.py delete mode 100644 cogs/conversion.py delete mode 100644 cogs/cron.py delete mode 100644 cogs/downloader.py delete mode 100644 cogs/math_commands.py delete mode 100644 cogs/music/join.py delete mode 100644 cogs/music/loop.py delete mode 100644 cogs/music/media_processing.py delete mode 100644 cogs/music/play_commands.py delete mode 100644 cogs/music/stop.py delete mode 100644 cogs/qr.py delete mode 100644 cogs/say.py delete mode 100644 cogs/search.py delete mode 100644 cogs/trigger.py delete mode 100644 cogs/tts.py delete mode 100644 cogs/uploader.py delete mode 100644 cogs/utils/database.py delete mode 100644 cogs/utils/file_search.py delete mode 100644 cogs/utils/settings.py delete mode 100644 cogs/utils/temp.py delete mode 100644 cogs/utils/url_helper.py delete mode 100644 cogs/vclogs.py delete mode 100644 cogs/voice.py delete mode 100644 cogs/web.py delete mode 100644 main.py delete mode 100644 pyrightconfig.json delete mode 100644 pytest.ini delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 scripts/bump_reqs.py create mode 100644 src/commands/ai.rs create mode 100644 src/commands/autocomplete.rs create mode 100644 src/commands/cron.rs create mode 100644 src/commands/math.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/music.rs create mode 100644 src/commands/qr.rs create mode 100644 src/commands/trigger.rs create mode 100644 src/commands/tts.rs create mode 100644 src/commands/utilities.rs create mode 100644 src/commands/web.rs create mode 100644 src/config.rs create mode 100644 src/copyparty/mod.rs create mode 100644 src/database/mod.rs create mode 100644 src/error.rs create mode 100644 src/events/mod.rs create mode 100644 src/lang.rs create mode 100644 src/main.rs create mode 100644 src/openrouter/mod.rs create mode 100644 src/tasks/mod.rs create mode 100644 src/voice/mod.rs delete mode 100644 tests/cogs/test_ai.py delete mode 100644 tests/cogs/test_assistant.py delete mode 100644 tests/cogs/test_compression_conversion.py delete mode 100644 tests/cogs/test_cron.py delete mode 100644 tests/cogs/test_cron_integration.py delete mode 100644 tests/cogs/test_downloader.py delete mode 100644 tests/cogs/test_downloader_integration.py delete mode 100644 tests/cogs/test_math_commands.py delete mode 100644 tests/cogs/test_music_audio.py delete mode 100644 tests/cogs/test_music_external_play.py delete mode 100644 tests/cogs/test_music_integration.py delete mode 100644 tests/cogs/test_music_join.py delete mode 100644 tests/cogs/test_music_play.py delete mode 100644 tests/cogs/test_qr.py delete mode 100644 tests/cogs/test_say.py delete mode 100644 tests/cogs/test_search.py delete mode 100644 tests/cogs/test_trigger.py delete mode 100644 tests/cogs/test_trigger_integration.py delete mode 100644 tests/cogs/test_tts.py delete mode 100644 tests/cogs/test_uploader.py delete mode 100644 tests/cogs/test_vclogs.py delete mode 100644 tests/cogs/test_vclogs_integration.py delete mode 100644 tests/cogs/test_voice.py delete mode 100644 tests/cogs/test_web.py delete mode 100644 tests/conftest.py diff --git a/.env.example b/.env.example index 9623155..fe76c05 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ TOKEN= STATUS_CHANNEL_ID= ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant -# Optional - File Server (for music /search commands) +# Optional - Copyparty File Server (for music /search commands) COPYPARTY_URL=http://localhost:8080 COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads COPYPARTY_PASSWORD= diff --git a/.gitignore b/.gitignore index 40d765c..31136fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ .env -__pycache__/ data/ -.idea/ -.venv/ .zed/ -.pytest_cache/ +target diff --git a/AGENTS.md b/AGENTS.md index 5f62ec3..f5a78ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,47 @@ -# Agent Instructions for Senator Sperma +# senator-sperma-rust — Agent Guide -## Required Setup -- Requires **Python 3.14** -- System dependencies: `ffmpeg`, `zbar` library (installed system-wide) -- Bot token in `.env` file (copy from `.env.example`) +## Project Map -## Commands -- Run bot: `.venv/bin/python main.py` -- Type check: `.venv/bin/basedpyright` -- Test: `.venv/bin/pytest` +``` +src/ +├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity +├── config.rs — Config struct (env-driven), pub const TEMP_PATH +├── error.rs — Unified Error enum + From impls, pub type Result +├── commands/ +│ ├── mod.rs — all_commands() aggregates every submodule +│ ├── ai.rs — /senator (OpenRouter chat + optional TTS) +│ ├── cron.rs — /cron group (6 subcommands) +│ ├── math.rs — /math (qalc subprocess) +│ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle +│ ├── qr.rs — /qr encode + decode +│ ├── trigger.rs — /trigger group (6 subcommands) +│ ├── tts.rs — /tts (Google TTS → file/vc), /log_volume +│ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say +│ └── web.rs — /web (DuckDuckGo scrape) +├── copyparty/mod.rs — CopypartyClient (search, upload) +├── database/mod.rs — DbPool, 21 query fns, 6 tables, migration +├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message +├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) +├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) +└── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input +``` -## Architecture -- Slash commands defined as cogs in `cogs/*.py` -- Discord API uses `discord.ApplicationContext` -- Music auto-complete queries copyparty server (`COPYPARTY_URL` config) +## Key Conventions -## Environment -- Copyparty URL for read-only access is COPYPARTY_URL -- Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD -- Above should be accessed using COPYPARTY_PASSWORD as a cookie -- Channel for vclogs and alike is STATUS_CHANNEL_ID -- Trigger word for assistant is ASSISTANT_KEYWORD +- **Framework**: poise 0.6 + serenity 0.12 + songbird 0.6 +- **DB**: sqlx 0.9 async SQLite (pool stored in `Data.db_pool`) +- **Errors**: `crate::error::Error` enum with `?` propagation; use `Error::Custom(string)` for ad-hoc +- **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` +- **Commands**: each submodule exports `pub fn commands() -> Vec>` +- **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`) -## Testing -- Tests in `tests/cogs/test_*.py` -- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` -- `conftest.py` provides fixtures for mocking Discord objects -- **Slow integration tests** marked with `@pytest.mark.slow` - - Test real-world feature usage with longer execution times - - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses - - Run only fast tests: `.venv/bin/pytest -m "not slow"` - - Run only slow tests: `.venv/bin/pytest -m slow` +## Stubs to Fill (see COMPARISON.md) +- `commands/music.rs` — `/play` is placeholder +- `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part +- `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action + +## Database Tables +`settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` + +## All Commands (26 total) +`/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..27964a6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6967 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "command_attr" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8208103c5e25a091226dfa8d61d08d0561cc14f31b25691811ba37d4ec9b157b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-models" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.4", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cron" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09" +dependencies = [ + "chrono", + "once_cell", + "phf 0.11.3", + "winnow", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +dependencies = [ + "darling_core 0.10.2", + "darling_macro 0.10.2", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.9.3", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" +dependencies = [ + "darling_core 0.10.2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.12", + "serde", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "davey" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "623f518197232b3e3d23697fd0060e0291e21b9ab02376b6a4c71a235bb9aced" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "hex-literal", + "hmac 0.12.1", + "num-derive", + "num-traits", + "openmls", + "openmls_basic_credential", + "openmls_rust_crypto", + "p256", + "rand 0.8.6", + "scrypt", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" +dependencies = [ + "darling 0.10.2", + "derive_builder_core", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" +dependencies = [ + "darling 0.10.2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "discortp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c793408a15d361754613fa68123ffa60424c2617fafdf82127b4bedf37d3f5d" +dependencies = [ + "pnet_macros", + "pnet_macros_support", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fluvio-wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b768c170dc045fa587a8f948c91f9bcfb87f774930477c6215addf54317f137f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "from_map" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99f31122ab0445ff8cee420b805f24e07683073815de1dd276ee7d588d301700" +dependencies = [ + "hashmap_derive", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.12.5", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hashmap_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb30bf173e72cc31b5265dac095423ca14e7789ff7c3b0e6096a37a996f12883" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hax-lib" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hls_m3u8" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b906521a5b0e6d2ec07ea0bb855d92a1db30b48812744a645a3b2a1405cb8159" +dependencies = [ + "derive_builder", + "derive_more 0.99.20", + "hex", + "shorthand", + "stable-vec", + "strum", + "thiserror 1.0.69", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hpke-rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ad6a58eb3e0ee30be8bfc7a9770ae98adcfa1d9bc820a5847732ce84f70837" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "rand_core 0.9.5", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a73a99d9008010d73289f41335a3f6e14fb8c04eaf60e9111b450463b1bbc7f" +dependencies = [ + "rand_core 0.9.5", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ce6b7e54aebe540faee869c67ee253bede44ea6cb67c6e72c7847d6c59f1df" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand 0.10.1", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b28be6cba9081c7feda2651d51c2a900029798e78b4c1e093e792f4571a870" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "hkdf 0.12.4", + "hpke-rs-crypto", + "k256", + "p256", + "p384", + "rand 0.8.6", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.40", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.8", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools", + "nalgebra", + "num", + "rand 0.8.6", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libcrux-aead" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13297ce29869a5c0edab0378837b0fc5f88bf99a843712d9201c3b1150b3b476" +dependencies = [ + "libcrux-aesgcm", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aesgcm" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99f2a019dab4097585a7d4f5b9deebe46cd1e628b16a5bc4cb0ce35e1da334e6" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc08d044676af21343b32b988411fa98dbb5cf65a03c9df478ced221bbdfdb1b" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1e5fd8476a6ed609d24ef42aee5ab6f99f7c65d054f92412da9f499e423299" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65f73ce79337c762eb38bbac91e4c9b9e60cf318e8501b812750c640814d45e" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.9.4", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2637dc87d158e1f1b550fd9b226443e84153fded4de69028d897b534d16d22e6" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1a89ca0c89be3a268a921e47105fb7873badf7267f5e3ebf4ea46baedd73ef" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7a242707d65960770bd7e14e4f18a92bdf0b967777dd404887db8d087a643b" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b5db005ff8001e026b73a6842ee81bbef8ec5ff0e1915a67ae65fd2a9fafa5" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12631592f491d22fd1a176d32b2c6edfb673998fd3987e9d95f8fa79ad2a737b" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.4", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14ab3e477de9df6ee1273a114018ff62c4996ca9220070c4e5cb1743f94a67d" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.4", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4778ba25cb08bb8a96bd100e19ed9aecf78337198fd176036e21042b2dd99bc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02491808ee5b9db8cb65fad64ae0be812db64beef179d945c00c7787dc7dfcf9" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d253473f259fc74a280c43f29c464f7e374abdf28b4942234dc707f529d4b7" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ae0b7d0e1cc4793a609fd0ff2ca3b3a3fabae523770c619a3d4bc86417b0d7" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e4fa89f3f5e34b47f928b22b1b78395a0d4ec23b1f583db635f128159d65f" +dependencies = [ + "libcrux-secrets", + "rand 0.9.4", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libopus_sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81c32f233fb2507347a93f97b9919493be9db7ec2249ce2c3ed0c89ad8edf60" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap 5.5.3", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openmls" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb512bfe6a55777518853ea535c6241f069cb0e8984678c117151d2a1e7e903" +dependencies = [ + "fluvio-wasm-timer", + "getrandom 0.3.4", + "log", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror 2.0.18", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_basic_credential" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "983e8be1457dd6f316f409292cec334af3b57b49a19deadc925c83c3c35e15b6" +dependencies = [ + "ed25519-dalek", + "openmls_traits", + "p256", + "rand 0.8.6", + "serde", + "tls_codec", +] + +[[package]] +name = "openmls_memory_storage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52c927ddb9940acb96d51aebd54b8b9c601c7119e6609622fb3f2cbe16abe3" +dependencies = [ + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafcc8a3552b10fbb3ab757cccaf1a34081e826ca819f49aa7e6645b1d95c00f" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "ed25519-dalek", + "hkdf 0.12.4", + "hmac 0.12.1", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "openmls_memory_storage", + "openmls_traits", + "p256", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", + "tls_codec", +] + +[[package]] +name = "openmls_traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f88ccdd53448dfdbfa5b8da8ba4e527c418fdb966418172bace2e3b41eedd56" +dependencies = [ + "serde", + "tls_codec", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opus2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49521e33fbf825d2abc8d696506c278cb8469d0c6cc05d3785bef5c5f7ee957b" +dependencies = [ + "libopus_sys", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "patricia_tree" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f2f4539bffe53fc4b4da301df49d114b845b077bd5727b7fe2bd9d8df2ae68" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "pnet_base" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_macros" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.118", +] + +[[package]] +name = "pnet_macros_support" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poise" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711433114bca9ff68582aa58af5cbcf573e83250a8714c2dca1c36f453cad3ae" +dependencies = [ + "async-trait", + "derivative", + "futures-util", + "indexmap", + "parking_lot 0.12.5", + "regex", + "serenity", + "serenity_poise_macros", + "tokio", + "tracing", + "trim-in-place", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "unicase", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.40", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.6", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.8", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "rubato" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty_pool" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed36cdb20de66d89a17ea04b8883fc7a386f2cf877aaedca5005583ce4876ff" +dependencies = [ + "crossbeam-channel", + "futures", + "futures-channel", + "futures-executor", + "num_cpus", +] + +[[package]] +name = "rxing" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc56bde1cf7f3c9cc908ef2670efede68bbad283acb6674b709aa3befdab451d" +dependencies = [ + "chrono", + "chrono-tz", + "codepage-437", + "encoding_rs", + "fancy-regex", + "image", + "imageproc", + "multimap", + "num", + "once_cell", + "regex", + "rxing-one-d-proc-derive", + "thiserror 2.0.18", + "unicode-segmentation", + "uriparse", + "urlencoding", +] + +[[package]] +name = "rxing-one-d-proc-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e5f136594d614d0d8e44f63508abd25c8fe0280655bb1d477b177bf718d0a4" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "senator-sperma-rust" +version = "0.2.0" +dependencies = [ + "chrono", + "cron", + "dotenvy", + "futures-util", + "glob", + "image", + "poise", + "qrcode", + "rand 0.10.1", + "reqwest", + "rustls 0.23.40", + "rxing", + "scraper", + "serde", + "serde_json", + "serenity", + "songbird", + "sqlx", + "symphonia 0.5.5", + "symphonia 0.6.0", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-aux" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" +dependencies = [ + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_cow" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serenity" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" +dependencies = [ + "arrayvec", + "async-trait", + "base64", + "bitflags 2.13.0", + "bytes", + "chrono", + "command_attr", + "dashmap 5.5.3", + "flate2", + "futures", + "levenshtein", + "mime_guess", + "parking_lot 0.12.5", + "percent-encoding", + "reqwest", + "rustc-hash", + "secrecy", + "serde", + "serde_cow", + "serde_json", + "static_assertions", + "time", + "tokio", + "tokio-tungstenite 0.21.0", + "tracing", + "typemap_rev", + "typesize", + "url", + "uwl", +] + +[[package]] +name = "serenity-voice-model" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "910c6511e59df6fa362921e4900cdbd3192210231a2ba135594c480871422af6" +dependencies = [ + "bitflags 2.13.0", + "num-traits", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "serenity_poise_macros" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bc8407893be0d973a7bb48325e10e0160a0db44f4099467c83179688220e5f5" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shorthand" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "474f77f985d8212610f170332eaf173e768404c0c1d4deb041f32c297cf18931" +dependencies = [ + "from_map", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "songbird" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9bf4ecb5083685b79a8fc60222f12e4f036e2817c5c341cd2b9ff2b429d237" +dependencies = [ + "aead", + "aes-gcm", + "async-trait", + "byteorder", + "bytes", + "chacha20poly1305", + "crypto-common 0.1.7", + "dashmap 6.2.1", + "davey", + "derivative", + "discortp", + "either", + "enum-map", + "flume 0.11.1", + "futures", + "nohash-hasher", + "nonmax", + "opus2", + "parking_lot 0.12.5", + "pin-project", + "rand 0.9.4", + "reqwest", + "ringbuf", + "rubato", + "rusty_pool", + "serde", + "serde-aux", + "serde_json", + "serenity", + "serenity-voice-model", + "socket2 0.5.10", + "stream_lib", + "streamcatcher", + "symphonia 0.5.5", + "symphonia-core 0.5.5", + "tokio", + "tokio-tungstenite 0.26.2", + "tokio-util", + "tokio-websockets", + "tracing", + "tracing-futures", + "twilight-gateway", + "typenum", + "url", + "uuid", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume 0.12.0", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stable-vec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stream_lib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e106dd009a0dfd2cf57777c39cad08f852debd366df6e841b250d956cec3277e" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "hls_m3u8", + "patricia_tree", + "reqwest", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "streamcatcher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71664755c349abb0758fda6218fb2d2391ca2a73f9302c03b145491db4fcea29" +dependencies = [ + "crossbeam-utils", + "futures-util", + "loom", +] + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "530efb820d53b712f4e347916c5e7ed20deb76a4f0457943b3182fb889b06d2c" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6e163a520367c465f59e0a61a23cfae3b10b6546d78b6f672a382be79f7110" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac 0.5.5", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core 0.5.5", + "symphonia-format-mkv", + "symphonia-format-ogg 0.5.5", + "symphonia-format-riff 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac 0.6.0", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-core 0.6.0", + "symphonia-format-isomp4", + "symphonia-format-ogg 0.6.0", + "symphonia-format-riff 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "serde", + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "rustls 0.22.4", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite 0.21.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.40", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" +dependencies = [ + "base64", + "bytes", + "fastrand", + "futures-core", + "futures-sink", + "http", + "httparse", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls 0.22.4", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls 0.23.40", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 2.0.18", + "url", + "utf-8", +] + +[[package]] +name = "twilight-gateway" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ef55467bcf6a2958162766fd0b72f33112e36971b37f9ec09b86d4fd0f7d1" +dependencies = [ + "bitflags 2.13.0", + "fastrand", + "futures-core", + "futures-sink", + "serde", + "serde_json", + "tokio", + "tokio-websockets", + "tracing", + "twilight-gateway-queue", + "twilight-model", +] + +[[package]] +name = "twilight-gateway-queue" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c962bd4693da0a215abe6b431fd73eb87111f49c431b87951780f9f7985002" +dependencies = [ + "tokio", + "tracing", +] + +[[package]] +name = "twilight-model" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191e2efa051dfbd9bed4c9f6bd3f5e007bda909c687a1db2760371a3d566617d" +dependencies = [ + "bitflags 2.13.0", + "serde", + "serde-value", + "serde_repr", + "time", +] + +[[package]] +name = "typemap_rev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typesize" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a313188364f7e10138257cb288c64e9b0e282510abefc91c796799559fa14762" +dependencies = [ + "chrono", + "dashmap 5.5.3", + "hashbrown 0.14.5", + "mini-moka", + "parking_lot 0.12.5", + "secrecy", + "serde_json", + "time", + "typesize-derive", + "url", +] + +[[package]] +name = "typesize-derive" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uwl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6edc36f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "senator-sperma-rust" +version = "0.2.0" +edition = "2024" + +[profile.dev] +debug = 1 + +[dependencies] +# Discord +poise = "0.6.2" +serenity = "0.12.5" +songbird = "0.6" + +# Async runtime +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "process", "time", "io-util"] } + +# Database +sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "chrono"] } + +# HTTP +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } +rustls = { version = "0.23", features = ["ring"] } + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" + +# Config +dotenvy = "0.15.7" + +# Logging +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +# Time & Cron +chrono = { version = "0.4.45", features = ["serde"] } +cron = "0.17" + +# QR +qrcode = "0.14.1" +image = "=0.25.8" +rxing = { version = "0.8", default-features = false, features = ["image", "encoding_rs"] } + +# HTML parsing +scraper = "0.27" + +# Audio metadata +symphonia = { version = "0.6", default-features = false, features = ["mp3", "aac", "alac", "flac", "ogg", "wav", "isomp4"] } +# Enable pcm codec on songbird's internal symphonia 0.5 for RawReader support +songbird-codec-pcm = { package = "symphonia", version = "0.5", features = ["pcm"] } + +# Utilities +rand = "0.10.1" +uuid = { version = "1.23.3", features = ["v4"] } +glob = "0.3.3" +tempfile = "3.27" +futures-util = "0.3" diff --git a/README.md b/README.md index e9cf127..9b1fea9 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,123 @@ -# Senator Sperma +# Senator Sperma (Rust) -A Discord bot with music playback, TTS, web search, and utility commands. +A Discord bot rebuilt in Rust from the original Python implementation. Music +playback via songbird, AI chat via OpenRouter or anything OpenAI compatible (ollama.cpp and such), TTS, web/file search +scheduled jobs, per-server triggers, QR codes, media conversion, and misc utilities. -Requires Python 3.14. +Built on poise 0.6 + serenity 0.12 + songbird 0.6, with async SQLite (sqlx 0.9) +for persistence. ## Setup -### 1. Clone & Create Virtual Environment +### 1. Clone ```bash git clone https://codeberg.org/salmonik/senator-sperma cd senator-sperma -python -m venv .venv -source .venv/bin/activate # Linux/macOS -# or .venv\Scripts\activate # Windows ``` -### 2. Install Dependencies +### 2. System Dependencies -```bash -pip install -r requirements.txt -``` +The following binaries must be available on `PATH`: + +- `ffmpeg` — audio decoding/encoding for playback, conversion, and compression +- `qalc` (Qalculate) — used by `/math` to evaluate expressions +- `yt-dlp` (optional) — used by `/download` -You also need `ffmpeg` and `zbar` library and installed system-wide +Unlike the Python version, QR encode/decode is handled by the pure-Rust +`qrcode`/`rxing` crates — no `zbar` system library required. -### 3. Create .env File +### 3. Build -Create a `.env` file in the project root: +Requires a recent Rust toolchain (edition 2024). ```bash -cp .env.example .env +cargo build --release ``` -Edit `.env` with your credentials: +### 4. Configure Environment -```env -# Required -TOKEN=your_discord_bot_token +Create a `.env` in the project root +look at `.env.example` on what to put there -# Optional - Bot Configuration -STATUS_CHANNEL_ID=123456789012345678 # Channel for startup message -ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant +On launch, missing variables are reported via tracing (`error!` for required, +`warn!` for optional with defaults). The database and schema migrations are +created automatically on first run. -# Optional - File Server (for music /search commands) -COPYPARTY_URL=http://localhost:8080 -COPYPARTY_PASSWORD=your_password - -# Optional - AI commands -OPENROUTER_API_KEY= -``` - -### 6. Run the Bot +### 5. Run ```bash -python main.py +cargo run --release ``` -## Configuration - -All settings are optional - defaults are provided if not set. - -| Variable | Description | Default | -|---------|-------------|---------| -| `TOKEN` | Discord bot token | (required) | -| `STATUS_CHANNEL_ID` | Channel for startup message | none | -| `ASSISTANT_KEYWORD` | Keyword to trigger AI assistant | `jarvis` | -| `COPYPARTY_URL` | File server base URL | `http://localhost:8080` | -| `COPYPARTY_PASSWORD` | File server password | none | -| `OPENROUTER_API_KEY` | AI API key (for /senator command) | none | - ## Commands -### Music -- `/play` - Play music from file server or an internet audio stream -- `/stop` - Stop playback -- `/join` - Join voice channel -- `/loop` - Loop current track - -### Utility -- `/search` - Search files on server -- `/web` - DuckDuckGo search -- `/download` - Download video/audio -- `/tts` - Text to speech -- `/qr_koduj` / `qr_dekoduj` - QR encode/decode -- `/kompresja` - Compress images -- `/konwersja` - Convert audio/video - -## Dependencies - -- `ffmpeg` - System-wide audio processing -- `py-cord` - Discord library (due to instable nature of discord's voice features, py-cord has to be installed directly from git) +Slash commands are autoregistered globally on startup. Some are registered +under their original Polish verb names to match the UI expected by existing +servers. + +### AI +- `/senator` — chat with the OpenRouter model + +### Music (songbird) +- `/grajcuj` — play from Copyparty search, a direct URL, or an uploaded file +- `/stop` — stop playback and leave voice +- `/join` — join the caller's voice channel + +### TTS +- `/tts` — Google Translate TTS (Polish), sent as a file or played in voice + +### Search & Web +- `/web` — DuckDuckGo HTML scrape (soon to use 4get) +- `/search` — search Copyparty and return a direct download URL +- `/download` — fetch media from any yt-dlp-supported URL + +### Media Tools +- `/compress` (alias `kompresja`) — compress a media attachment to low quality +- `/convert` (alias `konwersja`) — convert media between formats + +### Utilities +- `/upload` — push Discord attachments into Copyparty +- `/say` — repeat the given text as the bot +- `/math` — evaluate an ASCII math expression through `qalc` +- `/qr generowanie` / `/qr dekodowanie` — QR encode / decode + +### Triggers +- `/trigger create` — new trigger group +- `/trigger add` — add keywords/responses to a group +- `/trigger remove` — remove a keyword/response +- `/trigger delete` — delete a group +- `/trigger list` — list a group's keywords and responses +- `/trigger help` — usage info + +### Cron +- `/cron dodaj_audio` — schedule audio playback +- `/cron dodaj_wiadomość` — schedule a periodic message +- `/cron pokaż` — list all cron jobs +- `/cron usuń` — remove a cron job +- `/cron pauza` — pause a job +- `/cron wznów` — resume a job + +### Message Context Menu +- "Wyślij załączniki" (right-click message) — upload attachments to Copyparty +- "kompresja" (right-click message) — compress all attachments + +## Architecture + +The runtime is organised around a shared `Data` struct (poise `user_data`) +that holds the DB pool, HTTP client, Copyparty client, voice manager, the +in-memory trigger cache, autocomplete cache, and per-guild music track handles. + +- `src/main.rs` — boot: tracing, dotenvy, Config, DB pool, framework, cron task +- `src/config.rs` — env-driven `Config` + variable validation +- `src/commands/` — one module per command group +- `src/copyparty/` — Copyparty search/upload client +- `src/openrouter/` — OpenRouter chat client +- `src/database/` — sqlx pool, migrations, query helpers (6 tables) +- `src/voice/` — `VoiceManager`, mp3 → songbird input +- `src/events/` — `handle_ready`, voice-state, message (trigger matching) +- `src/tasks/` — 30s cron scheduler poll loop +- `src/lang.rs` — user-facing Polish strings + +All temporary files go through `crate::config::TEMP_PATH` +(`/tmp/senator_sperma`). diff --git a/cogs/ai.py b/cogs/ai.py deleted file mode 100644 index ef2ba7c..0000000 --- a/cogs/ai.py +++ /dev/null @@ -1,78 +0,0 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import discord -from discord.ext import commands -import logging -import httpx -from gtts import gTTS -import io - -logger = logging.getLogger(__name__) - -class AI(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command( - name="senator", - description="SEENATOR", - ) - async def senator( - self, - ctx: discord.ApplicationContext, - text: str = discord.Option(str, "message", required=True), - tts: str = discord.Option(bool, "face?", default=False) - ): - await ctx.defer() - - - url = "https://openrouter.ai/api/v1/chat/completions" - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "Content-Type": "application/json", - } - payload = { - "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", - "messages": [ - { - "role": "system", - "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \ - You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \ - If the message is in Polish, respond in Polish. Respond in 100 words." - }, - { - "role": "user", - "content": text - } - ], - - } - async with httpx.AsyncClient() as client: - response = await client.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - if not response_data["choices"]: - await ctx.send_followup("bot siem zesroł") - else: - response_text = response_data["choices"][0]["message"]["content"] - if tts: - tts_obj = gTTS(text=response_text, lang='pl') - tts_buffer = io.BytesIO() - tts_obj.write_to_fp(tts_buffer) - tts_buffer.seek(0) - from cogs.voice import get_voice_manager - vm = get_voice_manager(self.bot) - channel = ctx.author.voice.channel - await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False) - - else: - await ctx.send_followup(response_text) - - - -def setup(bot): - bot.add_cog(AI(bot)) diff --git a/cogs/assistant.py b/cogs/assistant.py deleted file mode 100644 index fed7825..0000000 --- a/cogs/assistant.py +++ /dev/null @@ -1,130 +0,0 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -from discord.ext import commands -import httpx -import logging -import os - -logger = logging.getLogger(__name__) - -class Jarvis(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower() - - @commands.Cog.listener() - async def on_message(self, message): - if message.author == self.bot.user: - return - - logger.info(message.content) - - if message.mentions: - if message.mentions[0].id == self.bot.user.id: - await self.assistant(message) - - elif self.keyword in message.content.lower(): - await self.assistant(message) - - @staticmethod - async def assistant(message): - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "Content-Type": "application/json" - } - - chain = await Jarvis._build_message_chain(message) - messages = Jarvis._build_messages(message, chain) - payload = Jarvis._build_payload(messages) - - response_data = await Jarvis._safe_api_call(payload, headers) - if response_data is None: - await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") - return - - response_message = Jarvis._parse_response(response_data) - - channel = message.channel - await message.reply(response_message) - - @staticmethod - async def _build_message_chain(message): - chain = [] - current = message - max_depth = 5 - - while current and len(chain) < max_depth: - chain.append(current) - if not current.reference: - break - try: - if current.reference.resolved: - current = current.reference.resolved - else: - current = await current.channel.fetch_message(current.reference.message_id) - except discord.NotFound: - break - - return list(reversed(chain)) - - @staticmethod - def _build_messages(message, chain=None): - messages = [{ - "role": "system", - "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." - "Twoim zadaniem jest asystowanie użytkownika" - "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" - "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" - "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia" - }] - - if chain is None: - chain = [message] - - for msg in chain: - messages.append({ - "role": "user", - "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}" - }) - - return messages - - @staticmethod - def _build_payload(messages): - return { - "model": os.getenv("OPENROUTER_MODEL"), - "messages": messages, - "plugins": [{"id": "response-healing"}] - } - - @staticmethod - async def _make_api_request(payload, headers): - async with httpx.AsyncClient(timeout=30.0) as client: - url = "https://openrouter.ai/api/v1/chat/completions" - response = await client.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - return response_data - - @staticmethod - async def _safe_api_call(payload, headers): - try: - return await Jarvis._make_api_request(payload, headers) - except httpx.HTTPError as e: - logger.error(f"API request failed: {e}") - return None - - @staticmethod - def _parse_response(response_data): - content = response_data['choices'][0]['message']['content'] - - logger.info(content) - - return content - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import mimetypes -import os -import asyncio -import aiofiles -import requests -import urllib.parse -from typing import List, Optional -from os import PathLike -import discord -from discord.ext import commands -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils -import shlex -import logging - -logger = logging.getLogger(__name__) - -def filetype(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - if mime_type.startswith('video/'): - return "Video" - elif mime_type.startswith('audio/'): - return "Audio" - elif mime_type.startswith('image/'): - return "Image" - return "Unknown" - -class Compression(commands.Cog): - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - from cogs.utils.settings import get_baseurl - self.bot = bot - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._baseurl = get_baseurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(file_path, 'rb') as f: - file_content = await f.read() - - files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - - response = requests.post(self._baseurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - return result.get('fileurl') - except Exception as e: - logger.error(f"Upload error: {e}") - return None - - def _process_video(self, file_path: str, output_path: str) -> str: - """Process video compression""" - 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)}" - return command - - def _process_audio(self, file_path: str, output_path: str) -> str: - """Process audio compression""" - if output_path.lower().endswith('.mp3'): - output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') - if not output_path.lower().endswith('.ogg'): - output_path = os.path.splitext(output_path)[0] + '.ogg' - command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}" - return command - - def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: - """Process image compression""" - ext = os.path.splitext(file_path)[1].lower() - if ext in [".jpg", ".jpeg"]: - command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" - elif ext == ".gif": - command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}" - else: - output_path = os.path.splitext(output_path)[0] + ".jpg" - command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" - return command, output_path - - async def _execute_compression(self, command: str) -> bool: - """Execute compression command""" - proc = await asyncio.create_subprocess_exec( - *shlex.split(command), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - await proc.wait() - return proc.returncode == 0 - - async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: - """Compress a single attachment and return the compressed file""" - try: - file_path: str = os.path.join(self.temp_dir, attachment.filename) - output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") - - # Save the original file - await attachment.save(file_path) - - # Process the file - file_type = filetype(file_path) - logger.info(f"Compression started for: {file_type}") - - command = self._get_compression_command(file_type, file_path, output_path) - if command is None: - logger.info(f"Unsupported file type: {file_type}") - os.remove(file_path) - return None - - # Update output_path if it was modified - if file_type == "Image": - command, output_path = command - elif file_type == "Audio": - # output_path is already modified in _process_audio - pass - - # Run the compression - success = await self._execute_compression(command) - - if success and os.path.exists(output_path): - logger.info(f"Successfully compressed: {attachment.filename}") - return discord.File(output_path, filename=f"compressed_{attachment.filename}") - return None - - except Exception as e: - logger.error(f"Error processing {attachment.filename}: {str(e)}") - return None - - finally: - # Clean up temporary files - if 'file_path' in locals() and os.path.exists(file_path): - os.remove(file_path) - - def _get_compression_command(self, file_type: str, file_path: str, output_path: str): - """Get the appropriate compression command based on file type""" - if file_type == "Video": - return self._process_video(file_path, output_path) - elif file_type == "Audio": - return self._process_audio(file_path, output_path) - elif file_type == "Image": - return self._process_image(file_path, output_path) - else: - return None - - async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]: - """Compress multiple attachments and return list of compressed files""" - compressed_files = [] - for attachment in attachments: - compressed_file = await self._compress_attachment(attachment) - if compressed_file: - compressed_files.append(compressed_file) - return compressed_files - - @commands.message_command( - name="kompresja", - description="skompresuj wideło albo dźwienk :)", - ) - async def message_compression(self, ctx, message: discord.Message): - if not message.attachments: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - return - - await ctx.defer() - compressed_files = await self._compress_attachments(message.attachments) - - # Send all compressed files in one message - if compressed_files: - await ctx.respond( - f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", - files=compressed_files - ) - - # Clean up compressed files - for file in compressed_files: - if os.path.exists(file.fp.name): # type: ignore[attr-defined] - os.remove(file.fp.name) # type: ignore[attr-defined] - else: - await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) - - @commands.slash_command( - name="kompresja", - description="skompresuj wideło albo dźwienk :)", - ) - @discord.option( - name="plik", - description="plik z copyparty", - required=False, - autocomplete=_file_autocomplete - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=False, - type=discord.Attachment - ) - @discord.option( - name="serwer?", - description="wysłać plik na serwer?", - required=False, - default=False, - type=bool - ) - async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False): - await ctx.defer() - - file_path = None - original_filename = None - - if plik: - url = self._cached_urls.get(ctx.author.id, {}).get(plik) - if not url: - await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) - return - file_path = await self._download_from_url(url) - original_filename = plik - elif zalacznik: - file_path = os.path.join(self.temp_dir, zalacznik.filename) - original_filename = zalacznik.filename - await zalacznik.save(file_path) - else: - await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) - return - - if not file_path or not os.path.exists(file_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - try: - file_type = filetype(file_path) - output_path = os.path.join(self.temp_dir, f"better_{original_filename}") - - command = self._get_compression_command(file_type, file_path, output_path) - if command is None: - await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True) - return - - if file_type == "Image": - command, output_path = command - elif file_type == "Audio": - # Audio output path remains unchanged - already processed in _process_audio - pass - - success = await self._execute_compression(command) - - if not success or not os.path.exists(output_path): - await ctx.respond("Kompresja nie powiodła się", ephemeral=True) - return - - file_size = os.path.getsize(output_path) - if file_size > 1024 * 1024 * 10 or server: - dl_url = await self._upload_to_copyparty(output_path) - if dl_url: - await ctx.respond(f"Skompresowano\n{dl_url}") - else: - await ctx.respond("Nie udało wysłać na serwer", ephemeral=True) - else: - await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}")) - - finally: - if file_path and os.path.exists(file_path): - os.remove(file_path) - if 'output_path' in locals() and os.path.exists(output_path): - os.remove(output_path) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -import requests -from discord.ext import commands -import logging -import os -import asyncio -import aiofiles -import mimetypes -import subprocess -import urllib.parse -import shlex -from typing import Optional -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils - -logger = logging.getLogger(__name__) - -def filetype(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - if mime_type.startswith('video/'): - return "Video" - elif mime_type.startswith('audio/'): - return "Audio" - elif mime_type.startswith('image/'): - return "Image" - return "Unknown" - -class Conversion(commands.Cog): - def __init__(self, bot): - self.bot = bot - from cogs.utils.temp import get_temp_dir - from cogs.utils.settings import get_baseurl, get_uploadurl - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._baseurl = get_baseurl() - self._uploadurl = get_uploadurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _setup_file_paths(self, plik, output_format): - """Setup input and output file paths""" - from cogs.utils.temp import get_temp_dir - - temp_dir = get_temp_dir() - file_path = os.path.join(temp_dir, plik.filename) - filename_noext = os.path.splitext(plik.filename)[0] - await plik.save(file_path) - - input_path = file_path - output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}") - return input_path, output_path - - def _build_conversion_command(self, file_type, input_path, output_path, args): - """Build the appropriate conversion command based on file type""" - if file_type in ["Video", "Audio"]: - return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" - elif file_type == "Image": - return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" - else: - return None - - async def _upload_to_copyparty(self, output_path): - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(output_path, 'rb') as f: - file_content = await f.read() - - files = { - 'f': (os.path.basename(output_path), file_content, 'application/octet-stream') - } - headers = { - 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' - } - params = { - 'j': '1', - 'ck': 'b2' - } - - response = requests.post(self._uploadurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - if 'fileurl' in result: - return result['fileurl'] - else: - raise ValueError(f"Server response missing fileurl: {result}") - else: - error_msg = response.text if response.text else 'Unknown error' - raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") - - except Exception as e: - logger.error(f"copyparty upload error: {e}", exc_info=True) - raise - - async def _handle_file_output(self, ctx, output_path): - """Handle file output based on size""" - if os.path.getsize(output_path) > 1024*1024*10: - try: - dl_url = await self._upload_to_copyparty(output_path) - await ctx.followup.send(f"wysyłon\n{dl_url}\n") - except Exception as upload_error: - await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}") - else: - await ctx.respond(file=discord.File(output_path)) - - def _cleanup_files(self, *file_paths): - """Clean up temporary files""" - for path in file_paths: - if os.path.exists(path): - os.remove(path) - - @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") - @discord.option( - name="plik", - description="plik z copyparty", - required=False, - autocomplete=_file_autocomplete - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=False, - type=discord.Attachment - ) - @discord.option( - name="serwer?", - description="wysłać plik na serwer?", - required=False, - type=bool, - default=False - ) - @discord.option( - name="format", - description="format konwersji", - required=True, - type=str - ) - async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None): - await ctx.defer() - - input_path = None - original_filename = None - - try: - if plik: - url = self._cached_urls.get(ctx.author.id, {}).get(plik) - if not url: - await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) - return - input_path = await self._download_from_url(url) - original_filename = plik - elif zalacznik: - input_path = os.path.join(self.temp_dir, zalacznik.filename) - original_filename = zalacznik.filename - await zalacznik.save(input_path) - else: - await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) - return - - if not input_path or not os.path.exists(input_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}") - - file_type = filetype(input_path) - logger.info(f"Conversion started for: {file_type}") - - command = self._build_conversion_command(file_type, input_path, output_path, "") - logger.info(f"Running: {command}") - if not command: - await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) - return - - proc = await asyncio.create_subprocess_exec( - *shlex.split(command), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}") - await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True) - return - - if os.path.getsize(output_path) > 1024 * 1024 * 10 or server: - dl_url = await self._upload_to_copyparty(output_path) - if dl_url: - await ctx.respond(f"Skonwertowano\n{dl_url}") - else: - await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True) - else: - await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}")) - - logger.info(f"Conversion completed for: {file_type}") - - except subprocess.CalledProcessError as e: - await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) - except Exception as e: - await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) - finally: - if input_path and os.path.exists(input_path): - os.remove(input_path) - if 'output_path' in locals() and os.path.exists(output_path): - os.remove(output_path) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -from datetime import datetime -from venv import logger - -import discord -from croniter import croniter -from discord.ext import commands - -from cogs.utils import file_search as search_utils -from cogs.voice import get_voice_manager -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -init_database() - - -class Cron(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.scheduled_tasks = [] - self.load_tasks() - self.task = bot.loop.create_task(self.schedule_checker()) - - def load_tasks(self): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") - rows = c.fetchall() - - self.scheduled_tasks = [ - { - "id": row[0], - "cron_expression": row[1], - "type": row[2], - "channel_id": row[3], - "active": bool(row[4]), - "audio_filename": row[5], - "message": row[6], - } - for row in rows - ] - logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") - except Exception as e: - logger.error(f"Error loading cron jobs: {e}") - self.scheduled_tasks = [] - - def save_tasks(self): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM cron_jobs") - - for task in self.scheduled_tasks: - c.execute( - """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ( - task.get("id"), - task["cron_expression"], - task["type"], - task["channel_id"], - int(task.get("active", True)), - task.get("audio_filename"), - task.get("message"), - ), - ) - conn.commit() - except Exception as e: - logger.error(f"Error saving cron jobs: {e}") - - async def schedule_checker(self): - """Background task to check and execute scheduled jobs""" - await self.bot.wait_until_ready() - while not self.bot.is_closed(): - try: - now = datetime.now() - for task in self.scheduled_tasks: - if not task.get("active", True): - continue - if self.should_run_task(task, now): - await self.execute_task(task) - await asyncio.sleep(30) - except Exception as e: - logger.error(f"Error in schedule checker: {e}") - await asyncio.sleep(30) - - def should_run_task(self, task, now): - """Check if a task should run now based on its cron expression""" - try: - cron_expr = task["cron_expression"] - cron = croniter(cron_expr, now) - prev_time = cron.get_prev(datetime) - time_diff = abs((now - prev_time).total_seconds()) - result = time_diff <= 30 - logger.debug( - f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" - ) - return result - except Exception as e: - logger.error( - f"Error checking cron expression {task.get('cron_expression')}: {e}" - ) - return False - - async def execute_task(self, task): - """Execute a scheduled task""" - try: - task_type = task["type"] - channel_id = task["channel_id"] - - channel = self.bot.get_channel(channel_id) - if not channel: - logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") - return - - logger.info(f"Found channel {channel} (type: {type(channel).__name__})") - - perms = channel.permissions_for(channel.guild.me) - logger.debug( - f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" - ) - - if task_type in ("zagraj_audio", "audio"): - await self.play_audio_task(task, channel) - elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): - await self.send_message_task(task, channel) - - logger.info(f"Executed task {task['id']} of type {task_type}") - except Exception as e: - logger.error(f"Error executing task {task.get('id')}: {e}") - - async def play_audio_task(self, task, channel): - """Play audio task implementation using voice manager""" - audio_url = task["audio_filename"] - - if isinstance(channel, discord.VoiceChannel): - voice_mgr = get_voice_manager(self.bot) - - try: - audio_source = discord.FFmpegPCMAudio(audio_url) - success = await voice_mgr.play_source(channel, audio_source) - - if success: - logger.info(f"Finished playing audio: {audio_url}") - else: - raise Exception("Playback failed") - - await voice_mgr.disconnect(channel.guild) - except Exception as e: - logger.error(f"Error playing audio: {e}") - try: - await voice_mgr.disconnect(channel.guild) - except Exception: - pass - if channel.guild.system_channel: - await channel.guild.system_channel.send( - f"Błąd podczas odgrywania audio: {audio_url}" - ) - else: - if hasattr(channel, "send"): - await channel.send("Odtwarzanie audio wymaga kanału głosowego") - - async def send_message_task(self, task, channel): - """Send message task implementation""" - message = task["message"] - logger.info(f"Message to send: '{message}' | Channel: {channel.id}") - await channel.send(message) - logger.debug("Message sent successfully") - - async def audio_autocomplete(self, ctx: discord.AutocompleteContext): - """Autocomplete for audio files from file_search""" - query = ctx.value.lower() if ctx.value else "" - if not query: - return "" - - audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"] - results = await search_utils.UniversalSearcher.search_copyparty( - query, max_results=25, file_extensions=audio_extensions - ) - - choices = [] - for url in results[:25]: - filename = url.split("/")[-1] - display_name = filename[:98] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=url)) - - return ( - choices - if choices - else [discord.OptionChoice(name="Brak wyników", value="")] - ) - - def _get_baseurl(self): - """Get base URL from settings""" - try: - from cogs.utils.settings import get_baseurl - - return get_baseurl() - except Exception: - return "" - - trigger_group = discord.SlashCommandGroup("cron", "komendy cron") - - @trigger_group.command( - name="dodaj_audio", description="Dodaj nowe zadanie cron audio" - ) - async def cron_add_audio( - self, - ctx, - cron_expression: str = discord.Option( - str, - "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", - required=True, - ), - channel: discord.VoiceChannel = discord.Option( - discord.VoiceChannel, - "Kanał głosowy na którym będzie wykonywana akcja", - required=True, - ), - audio_filename: str = discord.Option( - str, - "Plik audio z serwera copyparty", - required=True, - autocomplete=audio_autocomplete, - ), - ): - try: - croniter(cron_expression) - except Exception: - await ctx.respond( - "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", - ephemeral=True, - ) - return - - task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 - - task = { - "id": task_id, - "cron_expression": cron_expression, - "type": "audio", - "channel_id": channel.id, - "active": True, - } - - task["audio_filename"] = audio_filename - - self.scheduled_tasks.append(task) - self.save_tasks() - - await ctx.respond( - f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" - ) - - @trigger_group.command( - name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" - ) - async def cron_add_message( - self, - ctx, - cron_expression: str = discord.Option( - str, - "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", - required=True, - ), - channel: discord.TextChannel = discord.Option( - discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True - ), - message: str = discord.Option(str, "Wiadomość do wysłania", required=True), - ): - """Add a new cron job""" - - try: - croniter(cron_expression) - except Exception: - await ctx.respond( - "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", - ephemeral=True, - ) - return - - task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 - - task = { - "id": task_id, - "cron_expression": cron_expression, - "type": "wiadomość", - "channel_id": channel.id, - "active": True, - "message": message, - } - - self.scheduled_tasks.append(task) - self.save_tasks() - - await ctx.respond( - f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" - ) - - @trigger_group.command( - name="pokaż", description="Pokaż wszystkie zadania cron" - ) - async def cron_list(self, ctx): - """List all cron jobs""" - if not self.scheduled_tasks: - await ctx.respond("Brak zadań cron") - return - - embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00) - - for task in self.scheduled_tasks: - channel = self.bot.get_channel(task["channel_id"]) - channel_name = f"#{channel}" - status = "AKTYWNA" if task.get("active", True) else "PAUZA" - - if task["type"] in ("zagraj_audio", "audio"): - action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`" - else: - msg = task.get("message", "") - action_desc = ( - f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`" - ) - - embed.add_field( - name=f"Zadanie #{task['id']} ({status})", - value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}", - inline=False, - ) - - await ctx.respond(embed=embed) - - @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") - async def cron_remove( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do usunięcia", required=True - ), - ): - """Remove a cron job by ID""" - for i, task in enumerate(self.scheduled_tasks): - if task["id"] == job_id: - self.scheduled_tasks.pop(i) - self.save_tasks() - await ctx.respond(f"Usunięto zadanie cron #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") - async def cron_pause( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do zatrzymania", required=True - ), - ): - """Pause a cron job by ID""" - for task in self.scheduled_tasks: - if task["id"] == job_id: - task["active"] = False - self.save_tasks() - await ctx.respond(f"Zapauzowano zadanie cron #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") - async def cron_resume( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do wznowienia", required=True - ), - ): - """Resume a paused cron job by ID""" - for task in self.scheduled_tasks: - if task["id"] == job_id: - task["active"] = True - self.save_tasks() - await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - def cog_unload(self): - """Clean up when cog is unloaded""" - if hasattr(self, "task"): - self.task.cancel() - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import tempfile -import logging -import requests -import discord -import re -import yt_dlp -from discord.ext import commands -import aiofiles -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class Downloader(commands.Cog): - def __init__(self, bot): - self.bot = bot - - URL_PATTERN = re.compile( - r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*' - ) - - @commands.message_command( - name="download", - description="Download and convert video/audio from internet" - ) - async def message_download(self, ctx, message: discord.Message): - # check if message has a valid url - url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)] - if url: - for url in url: - await self.download(ctx=ctx, url=url, fformat="mp4") - else: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - - @commands.slash_command( - name="download", - description="Download and convert video/audio from internet" - ) - async def download( - self, - ctx: discord.ApplicationContext, - url: str = discord.Option(str, description="Link media", required=True), - catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False), - fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"), - filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False) - ): - """Download and convert media from a URL to the specified format""" - await ctx.defer() - # Call the function and get the response - response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user) - if isinstance(response, dict): - if response["status"] == "success": - if response["type"] == "url": - await ctx.respond(f"pobioron i wysyłon: {response['content']}") - elif response["type"] == "file": - file_data = response["content"] - await ctx.respond(file=discord.File(file_data)) - else: - error_msg = response["content"] - if 'details' in response: - error_msg += f"\n{response['details']}" - await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) - logger.error(f"Error: {response['content']}") - if 'details' in response: - logger.error(f"Details: {response['details']}") - - @staticmethod - async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): - try: - url = clean_url_for_ytdlp(url) - logger.info(f"Downloading {url} as {fformat}") - - with tempfile.TemporaryDirectory(delete=False) as temp_dir: - ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user) - - if Downloader._should_upload_to_server(actual_file, catbox): - return await Downloader._upload_to_server(actual_file, filename, temp_dir) - else: - return Downloader._return_file_response(actual_file, filename, temp_dir) - - except Exception as e: - return Downloader._create_error_response("Download failed", str(e), None) - - @staticmethod - def _sanitize_filename(name: str) -> str: - invalid = '<>:"/\\|?*' - for c in invalid: - name = name.replace(c, '') - return name.strip() - - @staticmethod - def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict: - if filename: - filename = Downloader._sanitize_filename(filename)[:100] - ydl_opts = { - 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), - 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', - 'merge_output_format': 'mp4', - 'postprocessors': [], - 'remote-components': 'ejs:github', - 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, - } - - if fformat in ['mp3', 'wav', 'm4a']: - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': fformat, - 'preferredquality': '192', - }) - elif fformat != 'mp4': - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegVideoConvertor', - 'preferedformat': fformat, - }) - - return ydl_opts - - @staticmethod - def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple: - files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] - if not files: - raise FileNotFoundError("No file was downloaded") - - actual_file = os.path.join(temp_dir, files[0]) - extension = actual_file.split('.')[-1] - extension = Downloader._sanitize_filename(extension) - - if not filename_user: - filename = os.path.basename(actual_file) - filename = Downloader._sanitize_filename(filename) - if len(filename) > 100: - filename = filename[:100] - else: - filename_user = Downloader._sanitize_filename(filename_user)[:100] - filename = filename_user + '.' + extension - actual_file = os.path.join(temp_dir, filename) - - if not os.path.exists(actual_file): - raise FileNotFoundError(f"Downloaded file not found: {actual_file}") - - return actual_file, filename - - @staticmethod - def _should_upload_to_server(actual_file: str, catbox: bool) -> bool: - return os.path.getsize(actual_file) > 1024*1024*10 or catbox - - @staticmethod - async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict: - try: - async with aiofiles.open(actual_file, 'rb') as f: - content = await f.read() - files = {'f': (filename, content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - base_url = os.getenv("COPYPARTY_URL_UPLOAD") - - response = requests.post(base_url, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - return Downloader._handle_upload_success(response, filename, temp_dir) - elif response.status_code == 413: - return Downloader._create_error_response( - "File too large for upload", - "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", - temp_dir - ) - else: - return Downloader._create_error_response( - f"Server returned {response.status_code}", - response.text or 'Unknown error', - temp_dir - ) - except Exception as upload_error: - return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir) - - @staticmethod - def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict: - try: - result = response.json() - if 'fileurl' in result: - return { - "status": "success", - "type": "url", - "content": result['fileurl'], - "filename": filename, - "temp_dir": temp_dir - } - else: - return Downloader._create_error_response( - "Invalid response from file server", - str(result), - temp_dir - ) - except ValueError: - return Downloader._create_error_response( - "Failed to parse server response", - response.text[:200], - temp_dir - ) - - @staticmethod - def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict: - return { - "status": "success", - "type": "file", - "content": actual_file, - "filename": filename, - "temp_dir": temp_dir - } - - @staticmethod - def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: - return { - "status": "error", - "type": "error", - "content": content, - "details": details, - "temp_dir": temp_dir - } - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import asyncio -import re - -from discord import ApplicationContext -from discord.ext import commands - -logger = logging.getLogger(__name__) - -MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$') - - -def sanitize_math_input(equation: str) -> str | None: - if not equation or len(equation) > 1000: - return None - equation = equation.strip() - if not equation: - return None - if not MATH_SANITIZE_PATTERN.match(equation): - return None - for char in equation: - if ord(char) < 32 or ord(char) > 126: - return None - if equation.count('(') != equation.count(')'): - return None - if equation.count('[') != equation.count(']'): - return None - if equation.count('{') != equation.count('}'): - return None - dangerous_patterns = [ - r'eval', r'exec', r'__import__', r'os\.', r'sys\.', - r'subprocess', r'open(', r'file', r'shutil', - r'lambda:', r'compile', r'dir(', r'vars(', - r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r', - r'\x', r'\0', r'\a', r'\b', r'\f', r'\v', - ] - for pattern in dangerous_patterns: - if re.search(pattern, equation, re.IGNORECASE): - return None - return equation - - -class Math(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command( - name="math", - description="wrug szyskih matfisuf" - ) - async def math(self, ctx: ApplicationContext, equation: str): - sanitized = sanitize_math_input(equation) - if sanitized is None: - await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True) - logger.warning(f"Math command rejected invalid input: {equation}") - return - try: - math_proc = await asyncio.create_subprocess_exec( - "qalc", "-e", sanitized, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await math_proc.communicate() - if math_proc.returncode != 0: - error_output = stderr.decode('utf-8', errors='replace').strip() - await ctx.respond(f"Calculation error: {error_output}", ephemeral=True) - logger.error(f"qalc error: {error_output}") - return - result = stdout.decode('utf-8', errors='replace').strip() - await ctx.respond(f"{sanitized} = {result}") - logger.info(f"Math command executed: {sanitized}") - except Exception as e: - await ctx.respond(f"Error: {str(e)}", ephemeral=True) - logger.error(f"Error in math command: {e}", exc_info=True) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging - -from discord.ext import commands -from cogs.voice import get_voice_manager - -logger = logging.getLogger(__name__) - - -class Join(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command(name="join", description="dołącza do kanalu") - async def join(self, ctx): - voice_mgr = get_voice_manager(self.bot) - channel = ctx.author.voice.channel - await voice_mgr.connect(channel) - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - logger.info("Bot connected to voice channel") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging - -from discord.ext import commands - -logger = logging.getLogger(__name__) - - -class Loop(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.looping = {} - - def is_looping(self, guild_id): - return self.looping.get(guild_id, False) - - def set_looping(self, guild_id, value): - self.looping[guild_id] = value - - @commands.slash_command( - name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" - ) - async def loop(self, ctx): - guild_id = ctx.guild.id - looping = not self.is_looping(guild_id) - self.set_looping(guild_id, looping) - await ctx.respond( - f"looping jest teraz {'włączony' if looping else 'wyłączony'}." - ) - logger.info( - f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" - ) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import os - -import yt_dlp -from youtubesearchpython import VideosSearch -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class MediaDownloader: - """Handles downloading media from various sources.""" - - @staticmethod - async def download(ctx, url): - """Download audio from YouTube URL.""" - from cogs.utils.temp import get_temp_dir - - temp_dir = get_temp_dir("senator_sperma_music") - - ytdl_opts = { - "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - } - ], - "force_ipv4": True, - } - - try: - url = clean_url_for_ytdlp(url) - with yt_dlp.YoutubeDL(ytdl_opts) as ydl: - info_dict = ydl.extract_info(url, download=True) - # Get the base filename without extension - base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] - # Add .mp3 extension since we're extracting audio - filename = f"{base_filename}.mp3" - logger.info(f"Downloaded file: {filename}") - return filename - except Exception as e: - logger.error(f"Error downloading video: {e}") - await ctx.respond("eror pobirajync wideło", ephemeral=True) - - @staticmethod - async def get_url(ctx, query): - """Search YouTube and get URL for a query.""" - search = VideosSearch(query, limit=1) - if search.result()["result"]: - title = search.result()["result"][0]["title"] - url = search.result()["result"][0]["link"] - logger.info(f"Found video: {title}") - return url, title - else: - logger.warning("No results found for the query.") - await ctx.respond("nic ni ma", ephemeral=True) - return None - - -class AudioCompressor: - """Handles audio compression using FFmpeg.""" - - @staticmethod - async def compress(ctx, filename): - """Compress audio file to opus format.""" - from cogs.utils.temp import get_temp_dir - - if not filename: - await ctx.respond("dzie wideło", ephemeral=True) - return None - - temp_dir = get_temp_dir("senator_sperma_compressed") - - # Create a temporary output filename in the temp directory - base_name = os.path.basename(filename) - output_filename = os.path.join( - temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" - ) - - # Ensure the output directory exists - os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) - - # Use proper quoting for filenames with spaces - ffmpeg_command = [ - "ffmpeg", - "-y", # Overwrite output file if it exists - "-i", - filename, - "-ac", - "1", # Mono audio - "-b:a", - "500", # 500k bitrate - "-acodec", - "libopus", - output_filename, - ] - - try: - logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") - process = await asyncio.create_subprocess_exec( - *ffmpeg_command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - _, stderr = await process.communicate() - - if process.returncode != 0: - error_msg = stderr.decode() - logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") - await ctx.respond("eror kompresujync pilk", ephemeral=True) - return None - - if not os.path.exists(output_filename): - logger.error(f"Output file was not created: {output_filename}") - await ctx.respond( - "eror kompresujync pilk - output file not created", ephemeral=True - ) - return None - - logger.info(f"Successfully compressed file: {output_filename}") - return output_filename - - except Exception as e: - logger.error(f"Error compressing file: {str(e)}", exc_info=True) - await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import os -import urllib.parse -import asyncio -from os import PathLike -from typing import Awaitable, Callable, List, Tuple -import re -import requests -from yt_dlp import YoutubeDL - -import discord -from discord.commands import OptionChoice -from discord.ext import commands - -from cogs.music import media_processing -from cogs.utils.file_search import UniversalSearcher -from cogs.utils.settings import get_uploadurl -from cogs.utils.url_helper import clean_url_for_ytdlp -from cogs.voice import get_voice_manager - -logger = logging.getLogger(__name__) - -EXTERNAL_URL_PATTERN = re.compile( - r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" -) - - -class PlayCommands(commands.Cog): - """Discord slash commands for music playback.""" - - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - - self.bot = bot - self.temp_dir = get_temp_dir() - self.path_mapping = {} # Maps display names to full paths - - @staticmethod - async def _create_search_embed( - candidates: List[str], - ) -> Tuple[discord.Embed, discord.ui.View]: - """Create an embed and view for search results.""" - embed = discord.Embed( - title="Wyniki wyszukiwania", - description="Wybierz plik do odtworzenia:", - color=discord.Color.blue(), - ) - - view = discord.ui.View(disable_on_timeout=True) - - for i, candidate in enumerate(candidates, start=1): - filename = candidate.split("/")[-1].split("?")[0] - display_name = os.path.splitext(filename)[0] - embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) - - return embed, view - - async def _handle_search_results( - self, - ctx: discord.ApplicationContext, - candidates: List[str], - play_callback: Callable[[discord.Interaction, str], Awaitable[None]], - ) -> None: - """Handle the display of search results and button interactions.""" - if not candidates: - await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) - return - - embed, view = await self._create_search_embed(candidates) - - # Add buttons for each candidate - for i, candidate in enumerate(candidates, start=1): - button = discord.ui.Button(label=f"Play {i}") - - async def button_callback( - interaction: discord.Interaction, candidate_path=candidate - ): - await interaction.response.defer(ephemeral=True) - await ctx.interaction.delete_original_response() - try: - await play_callback(interaction, candidate_path) - except Exception as e: - logger.error(f"Error in play callback: {str(e)}", exc_info=True) - await interaction.followup.send( - f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True - ) - - button.callback = button_callback - view.add_item(button) - - await ctx.respond(embed=embed, view=view, ephemeral=True) - - async def _search_autocomplete( - self, ctx: discord.AutocompleteContext - ) -> List[OptionChoice]: - """Autocomplete handler for search queries.""" - if ctx.value.lower().startswith(("http://", "https://")): - return [] - - query = ctx.value.lower() - try: - results = await UniversalSearcher.search_copyparty_autocomplete_audio( - query, max_results=25 - ) - choices = [] - self.path_mapping = {} # Reset the mapping for each new query - - for url in results: - filename = url.split("/")[-1] - filename = filename.split("?")[0] # Remove query params if any - filename = urllib.parse.unquote(filename) # Decode URL encoding - display = os.path.splitext(filename)[0] # Remove extension - - display = display.strip() - - if len(display) > 100: - display = display[:99] + "…" - - self.path_mapping[display] = url - choices.append(OptionChoice(display, display)) - - if len(choices) >= 25: - break - - return choices - - except Exception as e: - logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) - return [] - - async def _handle_compressed_file_play( - self, ctx: discord.ApplicationContext, url: str - ) -> None: - """Download from copyparty, compress, then play locally.""" - import aiohttp - - local_path = None - compressed_path = None - try: - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status != 200: - await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) - return - - filename = url.split("/")[-1].split("?")[0] - filename = urllib.parse.unquote(filename) - local_path = os.path.join(self.temp_dir, filename) - os.makedirs(self.temp_dir, exist_ok=True) - - loop = asyncio.get_event_loop() - with open(local_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - await loop.run_in_executor(None, f.write, chunk) - - compressed_path = await media_processing.AudioCompressor.compress( - ctx, local_path - ) - if not compressed_path: - return - - await get_voice_manager(self.bot).play( - ctx, compressed_path, delete_after_play=True - ) - except Exception as e: - logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) - await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) - finally: - if local_path and os.path.exists(local_path): - try: - os.remove(local_path) - except Exception: - pass - - async def _handle_external_url_play( - self, ctx: discord.ApplicationContext, url: str - ) -> None: - """Download from external URL, upload to copyparty, then play.""" - local_path = None - try: - ytdl_opts = { - "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - } - ], - "force_ipv4": True, - } - - loop = asyncio.get_event_loop() - url = clean_url_for_ytdlp(url) - with YoutubeDL(ytdl_opts) as ydl: - info_dict = await loop.run_in_executor( - None, lambda: ydl.extract_info(url, download=True) - ) - title = info_dict.get("title", "nieznany") - base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] - local_path = f"{base_filename}.mp3" - - if not os.path.exists(local_path): - await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) - return - - upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) - - await get_voice_manager(self.bot).play( - ctx, local_path, delete_after_play=True - ) - - try: - await asyncio.wait_for(upload_task, timeout=30.0) - except asyncio.TimeoutError: - logger.warning("Upload timed out") - except Exception as e: - logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) - if local_path and os.path.exists(local_path): - try: - os.remove(local_path) - except Exception: - pass - await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) - - async def _upload_to_copyparty_async(self, file_path: str) -> None: - """Upload file to copyparty asynchronously (fire and forget).""" - try: - logger.info(f"Starting async upload: {file_path}") - baseurl = get_uploadurl() - password = os.getenv("COPYPARTY_PASSWORD", "") - - logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") - - if baseurl == "http://localhost:3939": - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") - - with open(file_path, "rb") as f: - content = f.read() - - files = {"f": (os.path.basename(file_path), content, "application/octet-stream")} - headers = {"Cookie": f"cppwd={password}"} - params = {"j": "1", "ck": "b2"} - - logger.info(f"Posting to {baseurl}") - response = requests.post(baseurl, files=files, headers=headers, params=params) - text = response.text - logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}") - - if response.status_code in [200, 201]: - result = response.json() - logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") - except Exception as e: - logger.error(f"Async upload failed: {e}", exc_info=True) - - async def _handle_local_file_play( - self, ctx: discord.ApplicationContext, file: str, compressed: bool - ) -> None: - """Handle file playback from copyparty URL.""" - url = self.path_mapping.get(file, file) # Look up URL from display name - - if compressed: - await self._handle_compressed_file_play(ctx, url) - else: - await get_voice_manager(self.bot).play(ctx, url) - - @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") - async def play( - self, - ctx: discord.ApplicationContext, - melodia: str = discord.Option( - str, - "odtwórz plik z serwera bądź strumienia", - autocomplete=_search_autocomplete, - required=False, - ), - file: discord.Attachment = discord.Option( - discord.Attachment, "plik do zagrania", required=False - ), - compressed: bool = discord.Option( - bool, "Czy skompresować dźwięk?", default=False - ), - ): - """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" - await ctx.defer() - - if melodia and file: - await ctx.respond( - "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True - ) - return - - if not melodia and not file: - await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) - return - - if file: - file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) - await file.save(file_path) - await get_voice_manager(self.bot).play(ctx, file_path) - else: - try: - if EXTERNAL_URL_PATTERN.match(melodia.lower()): - await self._handle_external_url_play(ctx, melodia) - else: - await self._handle_local_file_play(ctx, melodia, compressed) - except Exception as e: - logger.error(f"Error playing: {str(e)}", exc_info=True) - await ctx.respond( - f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True - ) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -from discord.ext import commands - -from cogs.voice import get_voice_manager - -import logging - -logger = logging.getLogger(__name__) - - -class Stop(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command(name="stop", description="stopuj bota") - async def stop(self, ctx): - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - voice_mgr = get_voice_manager(self.bot) - await voice_mgr.disconnect(ctx.guild) - logger.info("Bot disconnected from voice channel") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import os -import tempfile - -import aiohttp -import discord -import qrcode -from discord.ext import commands - -logger = logging.getLogger(__name__) - -_pil_image = None -_decode_func = None -_decode_available = False - - -def _ensure_qr(): - global _pil_image, _decode_func, _decode_available - - if _pil_image is not None: - return _decode_available - - try: - from PIL import Image - from pyzbar.pyzbar import decode as _decode_func - _pil_image = Image - _decode_available = True - except ImportError: - _decode_available = False - logger.warning("pyzbar or PIL not available, decode command will be disabled") - return _decode_available - - -class QR(commands.Cog): - def __init__(self, bot): - self.bot = bot - - async def _download_image(self, url): - """Download image from URL to temporary file""" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - content = await response.read() - with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: - await asyncio.get_event_loop().run_in_executor(None, f.write, content) - return f.name - return None - - @commands.slash_command( - name="qr_generuj", description="Generuje kod QR z podanego tekstu" - ) - async def qr( - self, - ctx, - text: str = discord.Option(str, "Tekst do zakodowania", required=True), - ): - await ctx.defer() - - from cogs.utils.temp import get_temp_dir - temp_path = os.path.join( - get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" - ) - - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._generate_qr, text, temp_path) - await ctx.respond(file=discord.File(temp_path, filename="qr.png")) - logger.info(f"Generated QR for text: {text}") - except Exception as e: - logger.error(f"QR generation error: {e}") - await ctx.respond("Error generating QR code", ephemeral=True) - finally: - try: - if os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"Error cleaning up temp file: {e}") - - def _generate_qr(self, text, output_path): - """Generate QR code (runs in executor)""" - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] - box_size=10, - border=4, - ) - qr.add_data(text) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(output_path) - - @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka") - async def decode( - self, - ctx, - image: discord.Attachment = discord.Option( - discord.Attachment, "Obrazek z kodem QR", required=True - ), - ): - if not _ensure_qr(): - await ctx.respond( - "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True - ) - return - - await ctx.defer() - - try: - temp_path = await self._download_image(image.url) - if not temp_path: - await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True) - return - - try: - loop = asyncio.get_event_loop() - decoded = await loop.run_in_executor(None, self._decode_qr, temp_path) - - if decoded: - await ctx.respond(f"```\n{decoded}\n```") - logger.info(f"Decoded QR: {decoded}") - else: - await ctx.respond( - "Nie znaleziono kodu QR na obrazku", ephemeral=True - ) - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"QR decode error: {e}") - await ctx.respond("Error dekodowania QR", ephemeral=True) - - def _decode_qr(self, image_path): - """Decode QR code from image (runs in executor)""" - if not _ensure_qr() or _decode_func is None: - return None - - try: - img = _pil_image.open(image_path) - decoded_list = _decode_func(img) - if decoded_list: - return decoded_list[0].data.decode("utf-8") - except Exception as e: - logger.error(f"Error decoding QR: {e}") - return None - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -from discord.ext import commands -import discord -import logging - -logger = logging.getLogger(__name__) - -class Say(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @discord.slash_command(name="say", description="senator, gadaj") - async def say( - self, - ctx: discord.ApplicationContext, - text: str = discord.Option(str, "Tekst do przeczytania", required=True) - ): - await ctx.defer() - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - await ctx.send(content=text) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import urllib.parse - -import discord -from discord.ext import commands -import logging -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils - -logger = logging.getLogger(__name__) - -class Search(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._cached_results = {} - - async def file_autocomplete(self, ctx: AutocompleteContext): - """Autocomplete function for file search.""" - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - @commands.slash_command( - name="search", - description="wyszukaj pliku" - ) - @discord.option( - name="filename", - description="Search for a file to send", - autocomplete=file_autocomplete - ) - async def search(self, ctx, filename: str): - """Send a file via URL from autocomplete selection.""" - if not filename: - await ctx.reply("Please select a file from the autocomplete options.") - return - - await ctx.defer() - logger.info(f"Search command called by {ctx.author} for file: {filename}") - - cached = self._cached_results.get(ctx.author.id) - if not cached: - await ctx.send_followup("Search results expired. Please search again.") - return - - matching_url = cached.get(filename) - if not matching_url: - await ctx.send_followup(f"File '{filename}' not found.") - return - - try: - await ctx.send_followup(matching_url) - logger.info(f"Successfully sent URL: {matching_url}") - del self._cached_results[ctx.author.id] - except Exception as e: - logger.error(f"Error sending URL {matching_url}: {str(e)}") - await ctx.send_followup(str(e)) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import discord -from discord.ext import commands -import random -import logging - -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -init_database() - -_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" -_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" -_SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" -_TRIGGER_NOT_FOUND_MSG = "Trigger not found." -_TRIGGER_NAME_OPTION = "the name of the trigger" - - -class Trigger(commands.Cog): - def __init__(self, bot): - self.bot = bot - - trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") - - @commands.Cog.listener() - async def on_message(self, message): - if message.author == self.bot.user: - return - - await self.trigger(message) - - @staticmethod - async def trigger(message): - content = message.content.lower() - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT id, name FROM triggers") - for trigger_id, name in c.fetchall(): - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - keywords = [row[0] for row in c.fetchall()] - if any(keyword in content for keyword in keywords): - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = [row[0] for row in c.fetchall()] - if responses: - await message.channel.send(random.choice(responses).replace('\\n', '\n')) - logger.info(f"{message.author} triggered {name}") - break - - @staticmethod - def get_type(name): - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT name FROM triggers") - return [row[0] for row in c.fetchall()] - - @trigger_group.command( - description="Create a new trigger", - ) - async def create( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) - conn.commit() - - await ctx.respond("trigger added") - - @trigger_group.command( - description="Add a trigger or response to the name", - ) - async def add( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ), - trigger = discord.Option( - str, - "the trigger to add to the name", - required=False - ), - response = discord.Option( - str, - "the response to add to the name", - required=False - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - if trigger: - for keyword in trigger.split('\\'): - keyword = keyword.strip() - if keyword: - c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword)) - if response: - for resp in response.split('\\'): - resp = resp.strip() - if resp: - c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) - conn.commit() - - await ctx.respond("trigger updated") - logger.info(f"Trigger updated: {name}") - - @trigger_group.command( - description="Remove a trigger or response from the name", - ) - async def remove( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ), - trigger = discord.Option( - str, - "the trigger to remove from the name", - required=False - ), - response = discord.Option( - str, - "the response to remove from the name", - required=False - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - if trigger: - c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger)) - if response: - c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) - conn.commit() - - await ctx.respond("trigger removed") - logger.info(f"Trigger removed: {name}") - - @trigger_group.command( - description="Delete the whole trigger", - ) - async def delete( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - return - trigger_id = row[0] - c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) - c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) - c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) - conn.commit() - - await ctx.respond("trigger deleted") - logger.info(f"Trigger deleted: {name}") - - - @trigger_group.command( - description="List all triggers", - ) - async def list( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete=discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - if name: - c.execute("SELECT id FROM triggers WHERE name=?", (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - triggers = [row[0] for row in c.fetchall()] - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = [row[0] for row in c.fetchall()] - trigger_list = "\n".join(triggers) - response_list = "\n".join(responses) - message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}" - await ctx.respond(message) - logger.info(f"Trigger listed: {name}") - else: - c.execute("SELECT id, name FROM triggers") - messages = [] - for trigger_id, name in c.fetchall(): - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - triggers = ", ".join([row[0] for row in c.fetchall()]) - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = ", ".join([row[0] for row in c.fetchall()]) - messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") - await ctx.respond("\n\n".join(messages)) - logger.info("Triggers listed") - - @trigger_group.command( - description="how to use trigger group?" - ) - async def help( - self, - ctx: discord.ApplicationContext, - ): - await ctx.respond("Trigger create - creates a group for triggers/responses\n" - "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n" - "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n" - "Trigger delete - deletes the whole group\n" - "Trigger list - lists triggers/responses of a group\n" - "Trigger help - display this message\n") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import json -import discord -import logging -import tempfile -from discord.ext import commands -from gtts import gTTS -import asyncio -from cogs.utils.settings import get_volume - -logger = logging.getLogger(__name__) - -class TTS(commands.Cog): - def __init__(self, bot): - self.bot = bot - - async def _handle_file_output(self, ctx, temp_path, text): - """Handle TTS file output""" - try: - await ctx.respond(file=discord.File(temp_path, filename='tts.mp3')) - logger.info(f"Sent TTS for text: {text}") - except Exception as e: - logger.error(f"Error sending file: {e}") - await ctx.respond("eror :c", ephemeral=True) - - def _get_voice_manager(self): - from cogs.voice import get_voice_manager - return get_voice_manager(self.bot) - - async def _get_or_connect_voice_client(self, channel): - """Get existing voice client or connect to new channel""" - voice_mgr = self._get_voice_manager() - return await voice_mgr.join_channel(channel) - - async def _handle_voice_output(self, ctx, temp_path, text, volume): - """Handle TTS voice channel output""" - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - - if not (ctx.author.voice and ctx.author.voice.channel): - logger.info("User is not in a voice channel") - return - - channel = ctx.author.voice.channel - try: - voice_mgr = self._get_voice_manager() - - if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild): - vc = await voice_mgr.get_client(channel.guild) - if vc and vc.is_playing(): - logger.info("Audio already playing, skipping TTS") - return - - audio_source = discord.FFmpegPCMAudio( - temp_path, - options=f'-filter:a "volume={volume}"' - ) - logger.info(f"Playing TTS: {text}") - await voice_mgr.play_source(channel, audio_source) - - except Exception as e: - logger.error(f"Error in voice channel handling: {e}") - await ctx.respond("eror :c", ephemeral=True) - - async def _cleanup_temp_file(self, temp_path): - """Clean up temporary file""" - try: - if temp_path and os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"Error cleaning up temp file {temp_path}: {e}") - - @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu") - async def tts(self, ctx, - text: str = discord.Option(str, "Tekst do przeczytania", required=True), - output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") - ): - await ctx.defer() - - # Create temporary file in temp folder - from cogs.utils.temp import get_temp_dir - temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") - - try: - # Generate and save TTS to temp file - tts = gTTS(text=text, lang='pl') - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, tts.save, temp_path) - - # Load settings - volume = get_volume() - - # Handle output based on user choice - if output == "file": - await self._handle_file_output(ctx, temp_path, text) - elif output == "vc": - await self._handle_voice_output(ctx, temp_path, text, volume) - - except Exception as e: - logger.error(f"TTS generation error: {e}") - await ctx.respond("Error generating TTS", ephemeral=True) - finally: - await self._cleanup_temp_file(temp_path) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import aiofiles -import requests -import urllib.parse -from typing import Optional -import discord -from discord.ext import commands -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils -from cogs.utils.settings import get_uploadurl -import logging - -logger = logging.getLogger(__name__) - - -class Uploader(commands.Cog): - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - self.bot = bot - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._uploadurl = get_uploadurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(file_path, 'rb') as f: - file_content = await f.read() - - files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - - response = requests.post(self._uploadurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - return result.get('fileurl') - except Exception as e: - logger.error(f"Upload error: {e}") - return None - - async def _upload_file(self, file_path: str) -> Optional[str]: - """Upload a file and return the URL""" - if self._uploadurl == "http://localhost:3939": - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") - return None - - return await self._upload_to_copyparty(file_path) - - @commands.message_command( - name="upload", - description="wyślij załączniki na copyparty", - ) - async def message_upload(self, ctx, message: discord.Message): - if not message.attachments: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - return - - await ctx.defer() - - uploaded_urls = [] - failed_count = 0 - - for attachment in message.attachments: - file_path = os.path.join(self.temp_dir, attachment.filename) - await attachment.save(file_path) - - url = await self._upload_file(file_path) - if url: - uploaded_urls.append(url) - else: - failed_count += 1 - - if os.path.exists(file_path): - os.remove(file_path) - - if uploaded_urls: - response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) - if failed_count > 0: - response += f"\n\nNie wysłano {failed_count} plików" - await ctx.respond(response) - else: - await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) - - @commands.slash_command( - name="upload", - description="wyślij plik na copyparty", - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=True, - type=discord.Attachment - ) - async def upload(self, ctx, zalacznik: discord.Attachment = None): - await ctx.defer() - - file_path = None - file_path = os.path.join(self.temp_dir, zalacznik.filename) - await zalacznik.save(file_path) - - if not file_path or not os.path.exists(file_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - try: - dl_url = await self._upload_file(file_path) - if dl_url: - await ctx.respond(dl_url) - else: - await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) - finally: - if file_path and os.path.exists(file_path): - os.remove(file_path) - - -def setup(bot): - 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 @@ -import os -import sqlite3 -import logging -from contextlib import contextmanager - -logger = logging.getLogger(__name__) - -DB_PATH = "data/senator.db" - -_initialized = False - - -def get_connection(): - conn = sqlite3.connect(get_db_path()) - return _ConnectionContextManager(conn) - - -def get_db_path(): - return os.getenv("SENATOR_DB_PATH", DB_PATH) - - -def set_db_path(path): - global DB_PATH - DB_PATH = path - - -class _ConnectionContextManager: - """Context manager for sqlite3 connections that auto-closes.""" - def __init__(self, conn): - self.conn = conn - - def __enter__(self): - return self.conn - - def __exit__(self, exc_type, exc_val, exc_tb): - self.conn.close() - return False - - -def init_database(): - global _initialized - if _initialized: - return - - _initialized = True - - db_path = get_db_path() - db_dir = os.path.dirname(db_path) - if db_dir and not os.path.exists(db_dir): - os.makedirs(db_dir) - - conn = sqlite3.connect(db_path) - try: - c = conn.cursor() - - c.execute("PRAGMA foreign_keys = ON") - - c.execute(""" - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS cron_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cron_expression TEXT NOT NULL, - type TEXT NOT NULL, - channel_id INTEGER NOT NULL, - active INTEGER DEFAULT 1, - audio_filename TEXT, - message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS voice_activity ( - user_id TEXT PRIMARY KEY, - join_time REAL, - total_active REAL DEFAULT 0, - total_afk REAL DEFAULT 0, - is_afk INTEGER DEFAULT 0, - afk_start REAL, - active_start REAL, - channel_name TEXT, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") - c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") - - c.execute(""" - CREATE TABLE IF NOT EXISTS triggers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS trigger_keywords ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - keyword TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, - UNIQUE(trigger_id, keyword) - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS trigger_responses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - response_text TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE - )""") - - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") - - conn.commit() - logger.info("Database initialized successfully") - finally: - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import json -import logging -import aiohttp -import os -from typing import List -from cogs.utils.settings import get_baseurl - -logger = logging.getLogger(__name__) - - -class UniversalSearcher: - """Handles searching for files via copyparty API.""" - - @staticmethod - async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None): - """Search for files using copyparty API (jPOST method). - - Args: - query: Search query string - max_results: Maximum number of results to return - file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav']) - - Returns: - List of matching file URLs - """ - if not query: - return [] - - query = query.strip() - logger.info(f"Starting copyparty search for query: '{query}'") - - tokens = query.split() - if not tokens: - return [] - - token_queries = [] - for token in tokens: - path_query = f'path like "*{token}*"' - name_query = f'name like "*{token}*"' - token_queries.append(f"({path_query} or {name_query})") - - if len(token_queries) == 1: - name_query = token_queries[0] - else: - name_query = " and ".join(token_queries) - - if file_extensions: - ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] - ext_query = " or ".join(ext_filters) - query = f"{name_query} and ({ext_query})" - else: - query = name_query - - try: - baseurl = get_baseurl() - password = os.getenv("COPYPARTY_PASSWORD") - - async with aiohttp.ClientSession() as session: - headers = {"Content-Type": "application/json"} - if password: - headers["PW"] = password - async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response: - if response.status == 200: - data = await response.json() - files = UniversalSearcher._extract_files_from_response(data) - results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) - - logger.info(f"Copyparty search complete. Found {len(results)} matches.") - return results - else: - logger.error(f"Copyparty API returned status {response.status}") - return [] - - except aiohttp.ClientError as e: - logger.error(f"HTTP error during copyparty search: {e}") - return [] - except Exception as e: - logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) - return [] - - @staticmethod - def _extract_files_from_response(data): - """Extract files list from copyparty API response.""" - if isinstance(data, dict): - if 'hits' in data: - return data['hits'] - elif 'files' in data: - return data['files'] - elif 'results' in data: - return data['results'] - else: - return data if isinstance(data, list) else [] - elif isinstance(data, list): - return data - else: - return [] - - @staticmethod - def _convert_files_to_urls(files, baseurl, max_results): - """Convert file info objects to URLs.""" - results = [] - - for file_info in files[:max_results]: - if isinstance(file_info, dict): - file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) - if file_url: - results.append(file_url) - elif isinstance(file_info, str): - if file_info.startswith('http'): - results.append(file_info) - else: - results.append(f"{baseurl}/{file_info.lstrip('/')}") - - return results - - @staticmethod - def _extract_url_from_dict(file_info, baseurl): - """Extract URL from file info dictionary.""" - if 'url' in file_info: - return file_info['url'] - elif 'rp' in file_info: - return f"{baseurl}/{file_info['rp']}" - elif 'path' in file_info: - return f"{baseurl}/{file_info['path'].lstrip('/')}" - elif 'name' in file_info: - return f"{baseurl}/{file_info['name'].lstrip('/')}" - else: - return None - - @staticmethod - async def search_copyparty_autocomplete(query: str, max_results: int = 10): - """Wrapper for autocomplete searches using copyparty (general search - no file type filtering). - - Args: - query: Search query string - max_results: Maximum number of results to return - - Returns: - List of matching file URLs - """ - if not query: - return [] - - return await UniversalSearcher.search_copyparty(query, max_results) - - @staticmethod - async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10): - """Wrapper for autocomplete searches using copyparty (audio files only). - - Args: - query: Search query string - max_results: Maximum number of results to return - - Returns: - List of matching file URLs (audio files only) - """ - if not query: - return [] - - # Audio file extensions for music commands - audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"] - return await UniversalSearcher.search_copyparty(query, max_results, audio_extensions) 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import sqlite3 -import os -from dataclasses import dataclass, field - -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -DEFAULT_BASEURL = "http://localhost:3939" - -init_database() - - -@dataclass -class Settings: - volume: float = 1.0 - baseurl: str = DEFAULT_BASEURL - _data: dict = field(default_factory=dict, init=False) - - async def load(self) -> "Settings": - default_settings = { - "volume": 1.0, - "baseurl": DEFAULT_BASEURL, - } - - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT key, value FROM settings") - rows = c.fetchall() - - if rows: - self._data = {row[0]: row[1] for row in rows} - self._sync_from_data() - logger.info("Settings loaded from database") - else: - self._data = default_settings.copy() - self._sync_from_data() - await self.save() - logger.info("Created default settings in database") - except Exception as e: - logger.error(f"Error loading settings: {e}") - self._data = default_settings.copy() - self._sync_from_data() - - return self - - def _sync_from_data(self): - try: - self.volume = float(self._data.get("volume", 1.0)) - except (ValueError, TypeError): - self.volume = 1.0 - self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) - - def _sync_to_data(self): - self._data["volume"] = str(self.volume) - self._data["baseurl"] = self.baseurl - - async def save(self): - self._sync_to_data() - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - for key, value in self._data.items(): - c.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", - (key, value), - ) - conn.commit() - - -_settings_instance: Settings | None = None - - -async def load_settings() -> Settings: - global _settings_instance - _settings_instance = Settings() - await _settings_instance.load() - return _settings_instance - - -async def save_settings(): - if _settings_instance: - await _settings_instance.save() - - -def get_settings() -> Settings | None: - return _settings_instance - - -def get_volume() -> float: - return _settings_instance.volume if _settings_instance else 1.0 - - -def get_baseurl() -> str: - url = os.getenv("COPYPARTY_URL") - if not url: - logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") - return DEFAULT_BASEURL - return url.rstrip("/") - - -def get_uploadurl() -> str: - url = os.getenv("COPYPARTY_URL_UPLOAD") - if not url: - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") - return DEFAULT_BASEURL - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import tempfile - -def get_temp_dir(subdir: str = "senator_sperma") -> str: - """Get or create a temp subdirectory. - - Uses system temp directory (/tmp on Linux, %TEMP% on Windows). - OS handles cleanup automatically. - """ - base_temp = tempfile.gettempdir() - temp_path = os.path.join(base_temp, subdir) - os.makedirs(temp_path, exist_ok=True) - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - - -def clean_url_for_ytdlp(url: str) -> str: - """Clean URL by removing everything after the first '&'. - - This prevents yt-dlp from interpreting additional query parameters - (like &list=...) as playlist URLs. - - Example: - Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& - Output: https://www.youtube.com/watch?v=lPGipwoJiOM - """ - # Find the first '&' and truncate - first_ampersand = url.find('&') - if first_ampersand != -1: - return url[:first_ampersand] - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import datetime -import logging -import os -import asyncio -import discord -from discord.ext import commands -from gtts import gTTS -from cogs.voice import get_voice_manager -from cogs.utils.settings import get_volume, save_settings -from cogs.utils.database import get_connection, init_database - -import tempfile - -logger = logging.getLogger(__name__) - -init_database() - - -def load_activity_data(): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") - rows = c.fetchall() - - return { - row[0]: { - "join_time": row[1], - "total_active": row[2], - "total_afk": row[3], - "is_afk": bool(row[4]), - "afk_start": row[5], - "active_start": row[6], - "channel_name": row[7], - } - for row in rows - } - except Exception as e: - logger.error(f"Error loading voice activity: {e}") - return {} - - -def save_activity_data(data): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM voice_activity") - - for user_id, user_data in data.items(): - c.execute( - """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ( - user_id, - user_data.get("join_time"), - user_data.get("total_active", 0), - user_data.get("total_afk", 0), - int(user_data.get("is_afk", False)), - user_data.get("afk_start"), - user_data.get("active_start"), - user_data.get("channel_name"), - ), - ) - conn.commit() - logger.info(f"Saved voice activity for {len(data)} users") - except Exception as e: - logger.error(f"Error saving voice activity: {e}") - - -def format_duration(seconds): - hours, remainder = divmod(int(seconds), 3600) - minutes, seconds = divmod(remainder, 60) - if hours > 0: - return f"{hours}h {minutes}m {seconds}s" - elif minutes > 0: - return f"{minutes}m {seconds}s" - return f"{seconds}s" - - -class VcLogs(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._voice_manager = None - - def get_voice_manager(self): - if self._voice_manager is None: - self._voice_manager = get_voice_manager(self.bot) - return self._voice_manager - - @staticmethod - async def handle_state_change(member, channel, state_type, is_enabled, log_channel): - """Helper method to handle voice state changes""" - state_messages = { - 'deaf': { - True: f"{member.display_name} jest bity przez ojca", - False: f"{member.display_name} uciek od ojca" - }, - 'mute': { - True: f"{member.display_name} idzie robić jajecznice", - False: f"{member.display_name} zrobił jajecznice" - } - } - - tts_text = state_messages[state_type][is_enabled] - return tts_text, channel - - async def play_tts(self, channel, text): - from cogs.utils.temp import get_temp_dir - - voice_mgr = self.get_voice_manager() - temp_dir = get_temp_dir() - tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") - tts = gTTS(text=text, lang='pl') - logger.info("saving file") - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, tts.save, tts_file) - - try: - voice_status = await voice_mgr.get_voice_status(channel.guild) - voice_client = voice_status[0] if voice_status else None - is_connected = voice_status[1] if voice_status else False - is_playing = voice_status[2] if voice_status else False - - if is_connected: - from cogs.voice import is_voice_client_alive - if not is_voice_client_alive(voice_client, channel.guild): - logger.warning("Stale voice client in play_tts, reconnecting") - try: - await voice_client.disconnect() - except Exception: - pass - is_connected = False - - if not is_connected: - voice_client = await voice_mgr.join_channel(channel) - voice_status = await voice_mgr.get_voice_status(channel.guild) - voice_client = voice_status[0] if voice_status else None - is_connected = voice_status[1] if voice_status else False - is_playing = voice_status[2] if voice_status else False - - if not is_connected or voice_client is None: - logger.error("Failed to join voice channel") - return - - volume = get_volume() - logger.info(f"connected: {voice_client}") - - if voice_client: - if is_playing: - logger.info("voice already playing, skipping TTS") - return - - logger.info("preparing to play audio") - audio_source = discord.FFmpegPCMAudio(tts_file) - await voice_mgr.play_source(channel, audio_source, volume=volume) - except Exception as e: - logger.error(f"TTS playback error: {e}") - finally: - if os.path.exists(tts_file): - os.remove(tts_file) - - @commands.slash_command( - name="log_volume" - ) - async def volume(self, ctx: discord.ApplicationContext, volume: int): - from cogs.utils.settings import get_settings - settings = get_settings() - settings.volume = volume - await save_settings() - await ctx.followup.send(f"Volume has been set to: {volume}") - logger.info(f"Volume changed to: {volume}") - - @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): - log_channel_id = os.getenv("STATUS_CHANNEL_ID") - if not log_channel_id: - logger.warning("STATUS_CHANNEL_ID not set in environment") - return - - log_channel = self.bot.get_channel(int(log_channel_id)) - if log_channel is None: - logger.warning("Log channel not found or not accessible") - return - - current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_ts = datetime.datetime.now().timestamp() - tts_text = None - tts_channel = None - - if member.bot: - return - - user_id = str(member.id) - activity_data = load_activity_data() - - if before.channel is None and after.channel is not None: - is_afk = after.self_mute or after.self_deaf - activity_data[user_id] = { - 'join_time': current_ts, - 'total_active': 0, - 'total_afk': 0, - 'is_afk': is_afk, - 'afk_start': current_ts if is_afk else None, - 'active_start': current_ts if not is_afk else None, - 'channel_name': after.channel.name - } - save_activity_data(activity_data) - - message = f"[{current_time}] {member.name} dołączył na {after.channel.name} " - tts_text = f"{member.display_name} sie zespawnił" - tts_channel = after.channel - await log_channel.send(message) - logger.info(f"User joined voice channel: {message}") - - elif before.channel is not None and after.channel is None: - if user_id in activity_data: - data = activity_data[user_id] - - if data.get('is_afk') and data.get('afk_start'): - afk_duration = current_ts - data['afk_start'] - data['total_afk'] += afk_duration - elif data.get('active_start'): - active_since_start = current_ts - data['active_start'] - data['total_active'] += active_since_start - - active_str = format_duration(data['total_active']) - afk_str = format_duration(data['total_afk']) - - message = ( - f"[{current_time}] {member.name} rozłączył się z {before.channel.name} \n" - f"Aktywny: {active_str} | AFK: {afk_str}" - ) - tts_text = f"{member.display_name} jest chujem" - tts_channel = before.channel - await log_channel.send(message) - logger.info(f"User left voice channel: {message}") - - del activity_data[user_id] - save_activity_data(activity_data) - else: - message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} " - tts_text = f"{member.display_name} jest chujem" - tts_channel = before.channel - await log_channel.send(message) - logger.info(f"User left voice channel: {message}") - - elif user_id in activity_data: - data = activity_data[user_id] - - if data.get('is_afk') and not (after.self_mute or after.self_deaf): - afk_duration = current_ts - data['afk_start'] - data['total_afk'] += afk_duration - data['afk_start'] = None - data['is_afk'] = False - data['active_start'] = current_ts - save_activity_data(activity_data) - - afk_str = format_duration(afk_duration) - - if after.self_mute: - activity = "odciszył" - else: - activity = "odgłuchnął" - - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" - f"AFK: {afk_str}" ) - await log_channel.send(message) - logger.info(f"User returned from AFK: {message}") - elif not data.get('is_afk') and (after.self_mute or after.self_deaf): - active_since_start = 0 - if data.get('active_start'): - active_since_start = current_ts - data['active_start'] - data['total_active'] += active_since_start - data['afk_start'] = current_ts - data['is_afk'] = True - data['active_start'] = None - save_activity_data(activity_data) - - if after.self_mute: - activity = "wyciszył" - else: - activity = "ogłuchnął" - - active_str = format_duration(active_since_start) - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" - f"Aktywny: {active_str}" ) - await log_channel.send(message) - logger.info(f"User went AFK: {message}") - - if before.self_deaf != after.self_deaf: - tts_text, tts_channel = await self.handle_state_change( - member, after.channel, 'deaf', after.self_deaf, log_channel - ) - if tts_text and tts_channel: - await self.play_tts(tts_channel, tts_text) - return - - if before.self_mute != after.self_mute: - tts_text, tts_channel = await self.handle_state_change( - member, after.channel, 'mute', after.self_mute, log_channel - ) - - if tts_text and tts_channel: - await self.play_tts(tts_channel, tts_text) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -import logging -import asyncio -import os -import urllib.parse -from dataclasses import dataclass, field -from typing import Any - -import mutagen - -logger = logging.getLogger(__name__) - - -_voice_managers: dict[int, "VoiceManager"] = {} - - -def get_voice_manager(bot) -> "VoiceManager": - """Get or create VoiceManager for a specific bot instance.""" - bot_id = bot.user.id if bot.user else id(bot) - if bot_id not in _voice_managers: - _voice_managers[bot_id] = VoiceManager(bot) - return _voice_managers[bot_id] - - -def is_voice_client_alive(voice_client, guild): - """Check if a voice client connection is healthy.""" - if voice_client is None: - return False - if not voice_client.is_connected(): - return False - if voice_client.channel is None: - return False - if guild.get_channel(voice_client.channel.id) is None: - return False - - try: - ws = voice_client._connection - s = ws.socket - if s is None or s.fileno() == -1: - return False - except Exception: - return False - - return True - - -@dataclass -class VoiceManager: - bot: Any - _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) - _connecting: set[int] = field(default_factory=set, init=False) - - # --- Connection ----------------------------------------------------------- - - async def _ensure_voice_client(self, channel): - """Unified connect/move/reconnect logic. - - Returns a healthy VoiceClient connected to *channel*, or None on failure. - """ - guild = channel.guild - guild_id = guild.id - - async with self._lock: - if guild_id in self._connecting: - logger.info(f"Connection already in progress for guild {guild_id}, skipping") - return None - - vc = discord.utils.get(self.bot.voice_clients, guild=guild) - - # --- resolve existing client -------------------------------------- - if vc is not None: - if vc.is_connected(): - if vc.channel == channel: - if is_voice_client_alive(vc, guild): - logger.info(f"Already connected to {channel}") - return vc - logger.warning(f"Stale connection to {channel}, reconnecting") - # Disconnect resets the state machine to 'disconnected' - # but keeps the VoiceClient alive. Reusing it avoids a - # race where Discord sends VOICE_SERVER_UPDATE between - # the destroy and the fresh-connect below, causing the - # new client to timeout waiting for the event (since the - # old one already consumed it). - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected to voice channel: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to reconnect stale voice client: {e}") - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - else: - try: - await vc.move_to(channel) - logger.info(f"Moved to voice channel: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to move voice client: {e}") - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected after failed move: {channel}") - return vc - except Exception: - pass - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - else: - logger.info("Voice client exists but disconnected, reconnecting") - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected disconnected client: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to reconnect disconnected client: {e}") - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - - # --- create fresh connection -------------------------------------- - self._connecting.add(guild_id) - try: - await asyncio.sleep(0.3) - try: - vc = await channel.connect(timeout=10.0, reconnect=False) - logger.info(f"Connected to voice channel: {channel}") - return vc - except discord.ClientException as e: - if "Already connected" in str(e): - logger.warning(f"Stale voice state detected, forcing reconnect: {e}") - vc = discord.utils.get(self.bot.voice_clients, guild=guild) - if vc: - try: - await vc.disconnect() - except Exception: - pass - else: - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - await asyncio.sleep(0.3) - try: - vc = await channel.connect(timeout=10.0, reconnect=False) - logger.info(f"Reconnected to voice channel: {channel}") - return vc - except Exception as e2: - logger.error(f"Failed to reconnect after stale cleanup: {e2}") - return None - logger.error(f"Failed to connect to voice channel: {e}") - return None - except Exception as e: - logger.error(f"Failed to connect to voice channel: {e}") - return None - finally: - self._connecting.discard(guild_id) - - async def connect(self, channel): - """Join a voice channel (public alias).""" - return await self._ensure_voice_client(channel) - - async def join_channel(self, channel): - """Backward-compatible alias for connect().""" - return await self.connect(channel) - - async def join_by_channel_id(self, channel_id): - """Join a voice channel by channel ID.""" - channel = self.bot.get_channel(channel_id) - if not channel: - logger.error(f"Channel {channel_id} not found") - return None - if not isinstance(channel, discord.VoiceChannel): - logger.error(f"Channel {channel_id} is not a voice channel") - return None - return await self.connect(channel) - - async def get_client(self, guild): - """Get existing voice client for a guild.""" - return discord.utils.get(self.bot.voice_clients, guild=guild) - - async def get_voice_client(self, guild): - """Backward-compatible alias for get_client().""" - return await self.get_client(guild) - - async def get_status(self, guild): - """Get voice client status. - - Returns: - tuple: (voice_client, is_connected, is_playing) or (None, False, False) - """ - vc = await self.get_client(guild) - if vc is None: - return None, False, False - return vc, vc.is_connected(), vc.is_playing() - - async def get_voice_status(self, guild): - """Backward-compatible alias for get_status().""" - return await self.get_status(guild) - - async def disconnect(self, guild): - """Stop playback and disconnect from voice.""" - vc = await self.get_client(guild) - if vc: - vc.stop() - logger.info("Disconnecting from voice") - await vc.disconnect() - logger.info(f"Disconnected from guild {guild}") - - async def stop(self, guild): - """Backward-compatible alias for disconnect().""" - await self.disconnect(guild) - - # --- Playback ------------------------------------------------------------ - - async def _play_await(self, vc, audio_source): - """Play an audio source and wait for completion using an asyncio.Future. - - Returns True on success, False on error. No polling, no magic sleeps. - """ - loop = asyncio.get_event_loop() - future = loop.create_future() - - def after(error): - if not future.done(): - if error: - future.set_exception(Exception(str(error))) - else: - future.set_result(True) - - vc.play(audio_source, after=after) - - try: - await future - return True - except Exception as e: - logger.error(f"Playback error: {e}") - return False - - async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): - """Connect to *channel* and play a pre-made audio source. - - Returns True on success, False on failure. - """ - vc = await self.connect(channel) - if not vc: - logger.error("play_source: could not connect to voice channel") - return False - - if interrupt and (vc.is_playing() or vc.is_paused()): - vc.stop() - - if abs(volume - 1.0) > 1e-9: - audio_source = discord.PCMVolumeTransformer(audio_source) - audio_source.volume = volume - - logger.info("Playing via play_source") - return await self._play_await(vc, audio_source) - - # --- Source preparation (folded from AudioPlaybackManager) --------------- - - @staticmethod - async def validate_voice_channel(ctx): - """Validate user is in a voice channel. Returns the channel or None.""" - if not ctx.author.voice or not ctx.author.voice.channel: - await ctx.respond( - "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True - ) - return None - return ctx.author.voice.channel - - @staticmethod - async def prepare_file_source(source): - """Validate a local file and return (display_name, absolute_path) or (None, None).""" - logger.info(f"Attempting to play local file: {source}") - - if not os.path.isabs(source): - source = os.path.abspath(source) - logger.debug(f"Converted to absolute path: {source}") - - if not os.path.exists(source): - logger.error(f"File not found: {source}") - return None, None - - filename = os.path.basename(source) - display_name = os.path.splitext(filename)[0] - return display_name, source - - @staticmethod - async def prepare_audio_source(source, is_url, display_name): - """Prepare FFmpegPCMAudio from a URL or local file. - - Returns (audio_source, final_display_name). - """ - if is_url: - before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" - audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) - filename = os.path.basename(source.split("?")[0]) - filename = urllib.parse.unquote(filename) - clean_filename = os.path.splitext(filename)[0] - return audio_source, clean_filename - else: - audio_source = discord.FFmpegPCMAudio(source=source) - try: - metadata = mutagen.open(source) - if metadata: - title = ( - metadata.get("title", [None])[0] - if hasattr(metadata, "get") - else None - ) - if title: - display_name = title - logger.debug(f"Read metadata - title: {title}") - else: - display_name = os.path.splitext(os.path.basename(source))[0] - except Exception as e: - logger.warning(f"Could not read metadata from {source}: {str(e)}") - display_name = os.path.splitext(os.path.basename(source))[0] - - return audio_source, display_name - - @staticmethod - async def prepare_source(source): - """Determine whether *source* is a URL or local file. - - Returns (source_path, display_name, is_url) or (None, None, None) on failure. - """ - is_url = source.startswith(("http://", "https://")) - - if is_url: - logger.info(f"Attempting to play stream from URL: {source}") - return source, source, is_url - - display_name, absolute_source = await VoiceManager.prepare_file_source(source) - if not display_name: - return None, None, None - - return absolute_source, display_name, is_url - - @staticmethod - async def cleanup_temp_file(source): - """Remove a temporary file if it exists.""" - if source and os.path.exists(source): - try: - os.remove(source) - logger.info(f"Cleaned up temporary file: {source}") - except Exception as e: - logger.error(f"Error cleaning up file {source}: {str(e)}") - - @staticmethod - async def cleanup_if_needed(source, delete_after_play, is_url): - """Conditionally clean up a file after playback.""" - if delete_after_play and not is_url: - await VoiceManager.cleanup_temp_file(source) - - # --- High-level play (full flow) ----------------------------------------- - - async def play(self, ctx, source, delete_after_play=False, interrupt=True): - """High-level playback: validate context, prepare source, connect, play, cleanup. - - Sends ``grom: {name}`` immediately on start, and an error follow-up - if playback fails. - - Args: - ctx: Discord application context (used for channel resolution & error messages). - source: Local file path or URL to stream. - delete_after_play: Remove local file after playback (no-op for URLs). - interrupt: Stop currently playing audio before starting. - - Returns: - bool: True if playback completed successfully, False otherwise. - """ - prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) - if not prepared_source: - await VoiceManager.cleanup_if_needed(source, delete_after_play, False) - return False - - target_channel = await VoiceManager.validate_voice_channel(ctx) - if not target_channel: - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - return False - - vc = await self.connect(target_channel) - if not vc: - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - return False - - if interrupt and (vc.is_playing() or vc.is_paused()): - vc.stop() - - audio_source, final_display_name = await VoiceManager.prepare_audio_source( - prepared_source, is_url, display_name - ) - - try: - await ctx.followup.send(f"grom: {final_display_name}") - except Exception as e: - logger.error(e) - - success = await self._play_await(vc, audio_source) - - if success: - logger.info(f"Playback completed: {final_display_name}") - else: - logger.error(f"Playback failed: {prepared_source}") - try: - await ctx.followup.send( - "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True - ) - except Exception: - pass - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -from discord import Embed -from discord.ext import commands -import requests -from bs4 import BeautifulSoup -import urllib.parse -import yt_dlp -import logging -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class Web(commands.Cog): - def __init__(self, bot): - self.bot = bot - - def _extract_youtube_title(self, url): - """Extract YouTube video title if URL is a YouTube link.""" - if "youtube.com/watch" not in url: - return None - - url = clean_url_for_ytdlp(url) - logger.info("youtube link") - try: - ydl_opts = {"quiet": True, "nowarnings": True} - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info.get("title") if info else None - except Exception: - pass - return None - - def _process_search_results(self, links, query): - """Process search results and create embed.""" - embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) - view = discord.ui.View(disable_on_timeout=True) - - for i, link in enumerate(links[:5], start=1): - title = link.text - youtube_title = self._extract_youtube_title(link.get("href")) - if youtube_title: - title = youtube_title - - embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) - - return embed, view - - @commands.slash_command( - name="web", - description="Search the web using DuckDuckGo" - ) - async def web( - self, - ctx: discord.ApplicationContext, - query: str - ): - await ctx.defer() - 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"} - encoded_query = urllib.parse.quote_plus(query) - url = f"https://duckduckgo.com/html/?q={encoded_query}" - response = requests.get(url, headers=headers) - - logger.info(f"Query: {query}") - - if response.status_code != 200: - await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True) - logger.error(f"Error: {response.status_code}") - return - - soup = BeautifulSoup(response.text, "html.parser") - links = soup.find_all("a", class_="result__a") - - if not links: - await ctx.respond(content="No results found.", ephemeral=True) - logger.warning("No results found") - return - - embed, view = self._process_search_results(links, query) - await ctx.respond(embed=embed, view=view) - logger.info("Search results sent") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import os -import sys -import argparse - -import discord -from discord.ext import commands -from dotenv import load_dotenv - -from cogs.utils import settings - - -def setup_logging(debug: bool = False): - """Configure logging based on debug flag.""" - level = logging.DEBUG if debug else logging.INFO - - class DecryptionFilter(logging.Filter): - def filter(self, record): - return "Decryption failed" not in record.getMessage() - - logging.basicConfig( - level=level, - datefmt="%m-%d %H:%M:%S", - format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], - ) - logging.getLogger().addFilter(DecryptionFilter()) - - -# Create a new event loop and set it as the current loop -loop = asyncio.new_event_loop() -asyncio.set_event_loop(loop) - -intents = discord.Intents.all() -bot = commands.Bot(intents=intents, loop=loop) - - -@bot.event -async def on_ready(): - logger = logging.getLogger(__name__) - logger.info("ŁUKASZ SPERMA AKTYWACJA") - - await settings.load_settings() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Run Senator Sperma bot") - parser.add_argument("--debug", action="store_true", help="Enable debug logging") - args, unknown = parser.parse_known_args() - - load_dotenv() - setup_logging(args.debug) - - extensions = [ - "cogs.vclogs", - "cogs.compression", - "cogs.tts", - "cogs.web", - "cogs.ai", - "cogs.conversion", - "cogs.math_commands", - "cogs.downloader", - "cogs.say", - "cogs.assistant", - "cogs.music.play_commands", - "cogs.music.stop", - "cogs.music.join", - "cogs.search", - "cogs.trigger", - "cogs.cron", - "cogs.qr", - "cogs.uploader", - ] - - try: - for i, extension in enumerate(extensions, 1): - bot.load_extension(extension) - logging.getLogger(__name__).info(f"Loaded {i}/{len(extensions)} extensions") - - token = os.getenv("TOKEN") - if not token: - raise ValueError( - "No token provided. Please set the TOKEN environment variable." - ) - - bot.run(token) - - except Exception as e: - logging.getLogger(__name__).error(f"Error starting the bot: {e}") - 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 @@ -{ - "include": ["cogs", "main.py", "cogs/utils", "tests"], - "exclude": ["pycord", ".venv", "temp", "data"], - "typeCheckingMode": "basic", - "reportMissingTypeStubs": false, - "reportUnknownMemberType": "none", - "reportUnknownParameterType": "none", - "reportUnknownArgumentType": "none", - "reportUnknownVariableType": "none", - "reportMissingParameterType": false, - "reportAny": false, - "reportOptionalMemberAccess": false, - "reportOptionalSubscript": false, - "reportCallIssue": false, - "reportAttributeAccessIssue": false, - "reportPossiblyUnboundVariable": false, - "reportArgumentType": false, - "reportUnreachable": false, -} diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 23e8e9c..0000000 --- a/pytest.ini +++ /dev/null @@ -1,9 +0,0 @@ -[pytest] -markers = - slow: marks tests as slow (deselect with '-m "not slow"') -asyncio_mode = strict -asyncio_default_fixture_loop_scope = function -filterwarnings = - ignore::RuntimeWarning - ignore::pytest.PytestUnraisableExceptionWarning - 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 @@ -pytest==9.0.3 -pytest-asyncio==1.3.0 -basedpyright==1.39.5 -aiofiles==25.1.0 -aiohttp==3.13.5 -beautifulsoup4==4.14.3 -croniter==6.2.2 -gTTS==2.5.4 -httpx==0.28.1 -mutagen==1.47.0 -pillow==12.2.0 -py-cord==2.8.0 -python-dotenv==1.2.2 -pyzbar==0.1.9 -qrcode==8.2 -requests==2.34.2 -yt-dlp==2026.3.17 -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 @@ -aiofiles==25.1.0 -aiohttp==3.13.5 -beautifulsoup4==4.14.3 -croniter==6.2.2 -gTTS==2.5.4 -httpx==0.28.1 -mutagen==1.47.0 -pillow==12.2.0 -py-cord==2.8.0 -python-dotenv==1.2.2 -pyzbar==0.1.9 -qrcode==8.2 -requests==2.34.2 -yt-dlp==2026.3.17 -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 @@ -#!/usr/bin/env python3 -"""Bump all pinned versions in requirements*.txt to latest from PyPI.""" - -import json -import re -import urllib.request -from pathlib import Path - -REQS = sorted(Path().glob("requirements*.txt")) -PYPI = "https://pypi.org/pypi/{}/json" - - -def latest_version(package: str) -> str | None: - url = PYPI.format(package) - try: - with urllib.request.urlopen(url, timeout=10) as resp: - data = json.load(resp) - return data["info"]["version"] - except Exception: - return None - - -def bump_file(path: Path): - lines = path.read_text().splitlines() - changed = [] - for line in lines: - m = re.match(r"^([\w-]+(?:\[[\w-]+])?)==[\w.]+$", line) - if m: - pkg = m.group(1) - latest = latest_version(pkg) - if latest: - changed.append(f"{pkg}=={latest}") - if f"{pkg}=={latest}" != line: - print(f" {pkg}: {line.split('==')[1]} -> {latest}") - else: - changed.append(line) - print(f" {pkg}: failed to fetch, keeping current") - else: - changed.append(line) - path.write_text("\n".join(changed) + "\n") - - -def main(): - for req in REQS: - print(f"\n{req}:") - bump_file(req) - - -if __name__ == "__main__": - 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 @@ +use crate::Data; +use crate::error::Error; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Query the AI senator via OpenRouter +#[poise::command(slash_command)] +pub async fn senator( + ctx: Context<'_>, + #[description = "Wiadomość do wysłania"] text: String, + #[description = "Przemówić w kanale głosowym?"] tts_vc: Option, +) -> crate::error::Result<()> { + ctx.defer().await?; + + let api_key = ctx + .data() + .config + .openrouter_api_key + .as_ref() + .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?; + let model = ctx + .data() + .config + .openrouter_model + .as_deref() + .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"); + + let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string()); + let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; + + if tts_vc.unwrap_or(false) { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + crate::commands::tts::urlencoding(&response) + ); + let audio_data = ctx + .data() + .http_client + .get(&tts_url) + .send() + .await? + .bytes() + .await?; + + let call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + let input = crate::voice::decode_audio(&audio_data)?; + let mut call_lock = call.lock().await; + if call_lock.current_channel().is_some() { + call_lock.play_input(input); + } + } + + ctx.say(response).await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![senator()] +} 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 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::Error; + +type Context<'a> = poise::Context<'a, Data, Error>; + +const MAX_CHOICE_LEN: usize = 99; + +fn filename_label(raw_path: &str) -> Option { + let raw_name = raw_path.rsplit('/').next()?; + if raw_name.is_empty() { + return None; + } + let decoded = html_decode(&percent_decode(raw_name)); + if decoded.len() > MAX_CHOICE_LEN { + let mut s: String = decoded.chars().take(MAX_CHOICE_LEN - 1).collect(); + s.push('\u{2026}'); + Some(s) + } else { + Some(decoded) + } +} + +pub async fn audio_file<'a>( + ctx: Context<'_>, + partial: &'a str, +) -> Vec { + eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); + let results = ctx + .data() + .copyparty + .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) + .await; + match results { + Ok(hits) => { + eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); + let mut choices = Vec::new(); + for (i, hit) in hits.into_iter().enumerate() { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + eprintln!( + "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", + hit.url, hit.name, hit.path + ); + let Some(label) = filename_label(&rp) else { + continue; + }; + ctx.data() + .ac_stash(ctx.author().id, label.clone(), hit) + .await; + eprintln!("[DEBUG] -> cached as {label:?}"); + choices.push(serenity::AutocompleteChoice::new(label.clone(), rp)); + if choices.len() >= 25 { + break; + } + } + eprintln!("[DEBUG] audio_file returning {} choices", choices.len()); + choices + } + Err(e) => { + eprintln!("[DEBUG] audio_file search failed: {e}"); + vec![] + } + } +} + +pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { + eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); + let results = ctx.data().copyparty.search(partial, 25, &[]).await; + match results { + Ok(hits) => { + eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); + let mut choices = Vec::new(); + for (i, hit) in hits.into_iter().enumerate() { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + eprintln!( + "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", + hit.url, hit.name, hit.path + ); + let Some(label) = filename_label(&rp) else { + continue; + }; + ctx.data() + .ac_stash(ctx.author().id, label.clone(), hit) + .await; + eprintln!("[DEBUG] -> cached as {label:?}"); + choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); + if choices.len() >= 25 { + break; + } + } + eprintln!("[DEBUG] any_file returning {} choices", choices.len()); + choices + } + Err(e) => { + eprintln!("[DEBUG] any_file search failed: {e}"); + vec![] + } + } +} + +pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { + eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); + let db_pool = &ctx.data().db_pool; + match crate::database::list_triggers(db_pool).await { + Ok(triggers) => { + let names: Vec<_> = triggers + .into_iter() + .map(|t| t.name) + .filter(|name| partial.is_empty() || name.starts_with(partial)) + .take(25) + .collect(); + eprintln!("[DEBUG] trigger_name returning {} names", names.len()); + names + } + Err(e) => { + eprintln!("[DEBUG] trigger_name query failed: {e}"); + vec![] + } + } +} 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 @@ +use poise::serenity_prelude as serenity; + +use super::autocomplete::audio_file; +use crate::Data; +use crate::database; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Cron job management +#[poise::command( + slash_command, + subcommands("dodaj_audio", "dodaj_wiadomość", "pokaż", "usuń", "pauza", "wznów",) +)] +pub async fn cron(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Schedule audio playback +#[poise::command(slash_command)] +pub async fn dodaj_audio( + ctx: Context<'_>, + #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, + #[description = "Target voice channel"] channel: serenity::Channel, + #[description = "Copyparty URL of audio file"] + #[autocomplete = "audio_file"] + audio_filename: String, +) -> Result<()> { + let channel_id = match channel { + serenity::Channel::Guild(ch) => ch.id.get() as i64, + _ => return Err(lang::err_bad_channel_type()), + }; + + database::add_cron_job( + &ctx.data().db_pool, + &cron_expression, + "audio", + channel_id, + Some(&audio_filename), + None, + ) + .await?; + + ctx.say(lang::fmt_cron_audio_queued(&cron_expression)) + .await?; + Ok(()) +} + +/// Schedule a periodic message +#[poise::command(slash_command)] +pub async fn dodaj_wiadomość( + ctx: Context<'_>, + #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, + #[description = "Docelowy kanał tekstowy"] channel: serenity::Channel, + #[description = "Zawartość wiadomości"] message: String, +) -> Result<()> { + let channel_id = match channel { + serenity::Channel::Guild(ch) => ch.id.get() as i64, + _ => return Err(lang::err_bad_channel_type()), + }; + + database::add_cron_job( + &ctx.data().db_pool, + &cron_expression, + "wiadomość", + channel_id, + None, + Some(&message), + ) + .await?; + + ctx.say(lang::L_CRON_QUEUED).await?; + Ok(()) +} + +/// List all cron jobs +#[poise::command(slash_command)] +pub async fn pokaż(ctx: Context<'_>) -> Result<()> { + let jobs = database::list_cron_jobs(&ctx.data().db_pool).await?; + if jobs.is_empty() { + ctx.say(lang::L_CRON_NONE).await?; + return Ok(()); + } + + let mut embed = serenity::CreateEmbed::default() + .title(lang::L_CRON_TITLE) + .color(0x00aaff); + + for job in &jobs { + let status = if job.active == 1 { + lang::L_CRON_ACTIVE + } else { + lang::L_CRON_PAUSED + }; + let desc = match job.r#type.as_str() { + "audio" => lang::fmt_cron_label_audio(job.audio_filename.as_deref().unwrap_or("?")), + _ => lang::fmt_cron_label_msg(job.message.as_deref().unwrap_or("?")), + }; + embed = embed.field( + lang::fmt_cron_job_status(job.id, status), + lang::fmt_cron_field(&job.cron_expression, job.channel_id, &desc), + false, + ); + } + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +/// Remove a cron job +#[poise::command(slash_command)] +pub async fn usuń( + ctx: Context<'_>, + #[description = "ID pracy do usunięcia"] job_id: i64, +) -> Result<()> { + if database::delete_cron_job(&ctx.data().db_pool, job_id).await? { + ctx.say(lang::fmt_cron_deleted(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +/// Pause a cron job +#[poise::command(slash_command)] +pub async fn pauza( + ctx: Context<'_>, + #[description = "ID pracy do zapauzowania"] job_id: i64, +) -> Result<()> { + if database::set_cron_job_active(&ctx.data().db_pool, job_id, false).await? { + ctx.say(lang::fmt_cron_paused(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +/// Resume a paused cron job +#[poise::command(slash_command)] +pub async fn wznów( + ctx: Context<'_>, + #[description = "ID pracy cron do wznowienia"] job_id: i64, +) -> Result<()> { + if database::set_cron_job_active(&ctx.data().db_pool, job_id, true).await? { + ctx.say(lang::fmt_cron_resumed(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![cron()] +} 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 @@ +use crate::lang; +use crate::Data; +use crate::error::{Error, Result}; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Evaluate a math expression via qalc +#[poise::command(slash_command)] +pub async fn math( + ctx: Context<'_>, + #[description = "Ekspresja matematyczn"] equation: String, +) -> Result<()> { + if equation.len() > 1000 { + ctx.say(lang::L_MATH_TOO_LONG).await?; + return Ok(()); + } + + if !equation.is_ascii() { + ctx.say(lang::L_MATH_NOT_ASCII).await?; + return Ok(()); + } + + let mut depth = 0i32; + for c in equation.chars() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + _ => {} + } + if depth < 0 { + ctx.say(lang::L_MATH_UNBALANCED).await?; + return Ok(()); + } + } + if depth != 0 { + ctx.say(lang::L_MATH_UNBALANCED).await?; + return Ok(()); + } + + let dangerous = [ + "eval", + "exec", + "os.", + "subprocess", + "import", + "__", + "system", + "shutil", + "typecast", + "shell", + "fork", + "execve", + ]; + let lower = equation.to_lowercase(); + if dangerous.iter().any(|d| lower.contains(d)) { + ctx.say(lang::L_MATH_DANGEROUS).await?; + return Ok(()); + } + + let output = tokio::process::Command::new("qalc") + .arg(&equation) + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_math_qalc_err(&e)))?; + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if result.is_empty() { + ctx.say(lang::L_MATH_NO_RESULT).await?; + } else { + ctx.say(lang::fmt_math_result(&result)).await?; + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![math()] +} 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 @@ +pub mod music; +pub mod tts; +pub mod ai; +pub mod web; +pub mod math; +pub mod qr; +pub mod cron; +pub mod trigger; +pub mod utilities; +pub mod autocomplete; + +use crate::Data; + +pub fn all_commands() -> Vec> { + let mut cmds = Vec::new(); + cmds.extend(music::commands()); + cmds.extend(tts::commands()); + cmds.extend(ai::commands()); + cmds.extend(web::commands()); + cmds.extend(math::commands()); + cmds.extend(qr::commands()); + cmds.extend(cron::commands()); + cmds.extend(trigger::commands()); + cmds.extend(utilities::commands()); + cmds +} 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 @@ +use poise::serenity_prelude as serenity; +use tracing::{error, info}; + +use super::autocomplete::audio_file; +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +// ── Source classification ────────────────────────────────────────── + +/// What kind of audio source are we dealing with? +/// +/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` +/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while +/// *every other* URL is streamed directly via ffmpeg (radio, HLS, +/// playlists, direct media files). +#[derive(Debug, Clone, PartialEq)] +enum SourceKind { + /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve + /// raw audio at a direct URL. Needs yt-dlp to extract + download the + /// media to memory before decoding. + YtDlp, + /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`, + /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or + /// directly-accessible media files. Streamed via an ffmpeg subprocess + /// — NO full download. ffmpeg handles playlist parsing, Icecast + /// metadata, reconnection, and codec/container detection universally. + Stream, +} + +/// Play music from Copyparty, URL, or file upload +#[poise::command(slash_command)] +pub async fn grajcuj( + ctx: Context<'_>, + #[description = "Plik do odtworzenia"] + #[autocomplete = "audio_file"] + melodia: Option, + #[description = "Bezpośredni plik z twojego systemu"] file: Option, + #[description = "Chujowa kompresja!?"] compressed: Option, +) -> Result<()> { + let compressed = compressed.unwrap_or(false); + eprintln!( + "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", + melodia, + file.as_ref().map(|f| &f.filename) + ); + let result = play_impl(ctx, melodia, file, compressed).await; + if let Err(e) = &result { + eprintln!("[DEBUG] play() error: {e}"); + error!("play command failed: {e}"); + } + result +} + +struct ResolvedSource { + url: String, + display_name: String, + kind: SourceKind, +} + +fn clean_name(raw: &str) -> String { + html_decode(&percent_decode(raw)) +} + +// ── URL classification helpers ────────────────────────────────────── + +/// URLs that require yt-dlp extraction (platforms that don't serve +/// raw audio at a direct URL). These MUST be downloaded first. +/// +/// Matches the Python original's `EXTERNAL_URL_PATTERN`: +/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch). +fn needs_ytdlp(url: &str) -> bool { + let lower = url.to_lowercase(); + lower.contains("youtube.com/") + || lower.contains("youtu.be/") + || lower.contains("twitch.tv/") + || lower.contains("soundcloud.com/") + || lower.contains("bandcamp.com/") +} + +/// Heuristic: does this URL *look* like a live internet radio stream? +/// (Used for logging / display only — dispatch is controlled by `SourceKind`.) +fn is_live_stream_hint(url: &str) -> bool { + let lower = url.to_lowercase(); + // Common Icecast/Shoutcast mount patterns + lower.contains("/stream") + || lower.contains("/live") + || lower.contains("/radio") + || lower.contains(";stream") + || lower.contains("icecast") + || lower.contains("shoutcast") + || lower.ends_with(".m3u8") + || lower.ends_with(".m3u") + || lower.ends_with(".pls") + || lower.contains(":8000/") // common Icecast port + || lower.contains(":8080/") // common alt port +} + +async fn resolve_source( + ctx: Context<'_>, + melodia: Option, + file: Option, +) -> Result { + // ── File attachment ────────────────────────────────────────── + // Discord attachment URLs are direct media file URLs — ffmpeg streams + // them without a full download. + if let Some(file) = file { + return Ok(ResolvedSource { + url: file.url.clone(), + display_name: file.filename.clone(), + kind: SourceKind::Stream, + }); + } + + let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; + + // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ + if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = ctx.data().copyparty.build_url(&path); + let display_name = clean_name(hit.filename().unwrap_or(&name)); + return Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }); + } + + // ── Direct Copyparty path (starts with '/') ────────────────── + if name.starts_with('/') { + let url = ctx.data().copyparty.build_url(&name); + let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name)); + return Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }); + } + + // ── Direct HTTP(S) URL ─────────────────────────────────────── + if name.starts_with("http://") || name.starts_with("https://") { + let url = name.clone(); + let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); + + // yt-dlp platforms → download; everything else (radio, HLS, + // playlists, Copyparty, direct media) → ffmpeg stream. + let kind = if needs_ytdlp(&url) { + SourceKind::YtDlp + } else { + SourceKind::Stream + }; + return Ok(ResolvedSource { + url, + display_name, + kind, + }); + } + + // ── Fallback: search Copyparty ─────────────────────────────── + info!(query = %name, "searching copyparty for audio"); + let results = ctx + .data() + .copyparty + .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) + .await + .map_err(|e| { + error!(query = %name, error = %e, "copyparty search failed"); + Error::Custom(lang::fmt_music_search_failed(&e)) + })?; + let hit = results + .first() + .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?; + let path = hit + .url_path() + .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?; + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = ctx.data().copyparty.build_url(&path); + let display_name = clean_name(hit.filename().unwrap_or("unknown")); + Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }) +} + +/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging. +fn redact_url(url: &str) -> &str { + let Some(q) = url.find('?') else { + return url; + }; + let before = &url[..q]; + let after = &url[q..]; + // Strip `?pw=...` or `&pw=...` + if after.strip_prefix("?pw=").is_some() { + return before; + } + if let Some(idx) = after.find("&pw=") { + return &url[..q + idx]; + } + url +} + +/// Strip tracking/playlist params (`&...`) from YouTube URLs. +/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. +pub(crate) fn sanitize_yt_url(url: &str) -> String { + let lower = url.to_lowercase(); + if lower.contains("youtube.com/") || lower.contains("youtu.be/") { + if let Some(pos) = url.find('&') { + return url[..pos].to_string(); + } + } + url.to_string() +} + +// ── Playlist resolution is handled by ffmpeg natively ────────────── +// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment +// fetching, PLS/M3U entry parsing) — no manual resolution needed. +// This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach. + +async fn play_impl( + ctx: Context<'_>, + melodia: Option, + file: Option, + compressed: bool, +) -> Result<()> { + ctx.defer().await?; + + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let src = resolve_source(ctx, melodia, file).await?; + + let call; + let input; + + if compressed { + // ── Compressed path: download → compress → decode ────────── + // Defer stopping current track until compression is done. + let audio_bytes = match src.kind { + SourceKind::YtDlp => { + let clean_url = sanitize_yt_url(&src.url); + info!(url = %clean_url, "downloading via yt-dlp for compression"); + crate::voice::ytdlp_download(&clean_url).await? + } + SourceKind::Stream => { + if is_live_stream_hint(&src.url) { + ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?; + // Fall through to normal streaming below + let live = true; + info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg"); + // Stop current, connect, stream + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + crate::voice::ffmpeg_stream_input(&src.url) + ); + call = call_res?; + input = input_res?; + // skip the rest of the compressed path + let handle = { + let mut call_lock = call.lock().await; + call_lock.play_input(input) + }; + ctx.data() + .music_tracks + .lock() + .await + .insert(guild_id.get(), handle); + ctx.say(lang::fmt_music_playing(&src.display_name)).await?; + return Ok(()); + } + info!(url = %redact_url(&src.url), "downloading for compression"); + crate::voice::download_url(&ctx.data().http_client, &src.url).await? + } + }; + + info!("degrading audio ({} bytes)", audio_bytes.len()); + let degraded = crate::voice::degrade_audio(&audio_bytes).await?; + + // Deferred stop: only interrupt current track now that we're ready. + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + input = degraded; + } else { + // ── Normal path: stop first, then stream/download ────────── + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + + match src.kind { + SourceKind::YtDlp => { + let clean_url = sanitize_yt_url(&src.url); + info!(url = %clean_url, "downloading via yt-dlp"); + let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) + ); + call = call_res?; + input = input_res??; + } + SourceKind::Stream => { + let live = is_live_stream_hint(&src.url); + info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + crate::voice::ffmpeg_stream_input(&src.url) + ); + call = call_res?; + input = input_res?; + } + } + } + + let handle = { + let mut call_lock = call.lock().await; + call_lock.play_input(input) + }; + + ctx.data() + .music_tracks + .lock() + .await + .insert(guild_id.get(), handle); + + ctx.say(lang::fmt_music_playing(&src.display_name)).await?; + Ok(()) +} + +/// Stop playback and disconnect from voice +#[poise::command(slash_command)] +pub async fn stop(ctx: Context<'_>) -> Result<()> { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + ctx.data().voice_manager.disconnect(guild_id).await?; + ctx.data().music_tracks.lock().await.remove(&guild_id.get()); + ctx.send( + poise::CreateReply::default() + .content(lang::L_MUSIC_STOPPED) + .ephemeral(true), + ) + .await?; + Ok(()) +} + +/// Join your voice channel +#[poise::command(slash_command)] +pub async fn join(ctx: Context<'_>) -> Result<()> { + ctx.defer().await?; + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + + let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }); + + match channel_id { + Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await { + Ok(_) => { + ctx.say(lang::fmt_voice_joined(&cid)).await?; + } + Err(e) => { + ctx.say(lang::fmt_voice_join_failed(&e)).await?; + } + }, + None => { + ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?; + } + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![grajcuj(), stop(), join()] +} 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 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// QR group +#[poise::command(slash_command, subcommands("dekodowanie", "generowanie",))] +pub async fn qr(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Generate a QR code from text +#[poise::command(slash_command)] +pub async fn generowanie( + ctx: Context<'_>, + #[description = "Zawartość kodu QR"] text: String, +) -> Result<()> { + ctx.defer().await?; + + let code = qrcode::QrCode::new(text.as_bytes())?; + let image = code.render::>().build(); + + let scaled = image::imageops::resize( + &image, + image.width() * 10, + image.height() * 10, + image::imageops::Nearest, + ); + + let mut buf = std::io::Cursor::new(Vec::new()); + scaled.write_to(&mut buf, image::ImageFormat::Png)?; + + ctx.send( + poise::CreateReply::default() + .content(lang::L_QR_GENERATED) + .attachment(serenity::CreateAttachment::bytes( + buf.into_inner(), + "qrcode.png", + )), + ) + .await?; + Ok(()) +} + +/// Decode a QR code from an uploaded image +#[poise::command(slash_command)] +pub async fn dekodowanie( + ctx: Context<'_>, + #[description = "Obraz zawierający kod QR"] image: serenity::Attachment, +) -> Result<()> { + ctx.defer().await?; + + let img_data = ctx + .data() + .http_client + .get(&image.url) + .send() + .await? + .bytes() + .await?; + let img = image::load_from_memory(&img_data)?; + let gray = img.to_luma8(); + + let decoded = decode_qr_simple(&gray); + match decoded { + Some(text) => { + ctx.say(lang::fmt_qr_decoded(&text)).await?; + } + None => { + ctx.say(lang::L_QR_DECODE_FAILED).await?; + } + } + Ok(()) +} + +fn decode_qr_simple(img: &image::ImageBuffer, Vec>) -> Option { + rxing::helpers::detect_in_luma(img.as_raw().to_vec(), img.width(), img.height(), None) + .ok() + .map(|r| r.getText().to_owned()) +} + +pub fn commands() -> Vec> { + vec![qr()] +} 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 @@ +use poise::serenity_prelude as serenity; + +use super::autocomplete::trigger_name; +use crate::Data; +use crate::database; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Trigger keyword management +#[poise::command( + slash_command, + subcommands("create", "add", "remove", "delete", "list", "help",) +)] +pub async fn trigger(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Create a new trigger group +#[poise::command(slash_command)] +pub async fn create( + ctx: Context<'_>, + #[description = "Logical group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + match database::create_trigger(&ctx.data().db_pool, &name).await { + Ok(_) => { + ctx.data().reload_triggers().await; + ctx.say(lang::fmt_trigger_created(&name)).await?; + } + Err(e) => { + ctx.say(lang::fmt_trigger_failed(&e)).await?; + } + } + Ok(()) +} + +/// Add keywords and/or responses to a trigger group +#[poise::command(slash_command)] +pub async fn add( + ctx: Context<'_>, + #[description = "Existing group name"] + #[autocomplete = "trigger_name"] + name: String, + #[description = "Keywords separated by \\"] trigger_keywords: Option, + #[description = "Responses separated by \\"] response: Option, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let mut modified = false; + if let Some(kw) = trigger_keywords { + for keyword in kw.split('\\') { + let k = keyword.trim(); + if !k.is_empty() { + database::add_keyword(&ctx.data().db_pool, t.id, k).await?; + modified = true; + } + } + } + if let Some(r) = response { + for resp in r.split('\\') { + let r = resp.trim(); + if !r.is_empty() { + database::add_response(&ctx.data().db_pool, t.id, r).await?; + modified = true; + } + } + } + + if modified { + ctx.data().reload_triggers().await; + } + ctx.say(lang::L_TRIGGER_UPDATED).await?; + Ok(()) +} + +/// Remove keyword or response from a group +#[poise::command(slash_command)] +pub async fn remove( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, + #[description = "Keyword to remove"] trigger_keyword: Option, + #[description = "Response to remove"] response: Option, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let mut modified = false; + if let Some(kw) = trigger_keyword { + database::remove_keyword(&ctx.data().db_pool, t.id, &kw).await?; + modified = true; + } + if let Some(r) = response { + database::remove_response(&ctx.data().db_pool, t.id, &r).await?; + modified = true; + } + + if modified { + ctx.data().reload_triggers().await; + } + ctx.say(lang::L_TRIGGER_UPDATED).await?; + Ok(()) +} + +/// Delete an entire trigger group +#[poise::command(slash_command)] +pub async fn delete( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + database::delete_trigger(&ctx.data().db_pool, t.id).await?; + ctx.data().reload_triggers().await; + ctx.say(lang::fmt_trigger_deleted(&name)).await?; + Ok(()) +} + +/// List keywords and responses for a group +#[poise::command(slash_command)] +pub async fn list( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?; + let responses = database::get_responses(&ctx.data().db_pool, t.id).await?; + + let mut embed = serenity::CreateEmbed::default() + .title(lang::fmt_trigger_title(&name)) + .color(0x00aaff); + + embed = embed.field( + "Keywords", + if keywords.is_empty() { + lang::L_TRIGGER_KW_NONE.into() + } else { + keywords + .iter() + .map(|r| format!("{r}")) + .collect::>() + .join("\n") + }, + false, + ); + embed = embed.field( + "Responses", + if responses.is_empty() { + lang::L_TRIGGER_KW_NONE.into() + } else { + responses + .iter() + .map(|r| format!("{r}")) + .collect::>() + .join("\n") + }, + false, + ); + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +/// Display trigger command usage info +#[poise::command(slash_command)] +pub async fn help(ctx: Context<'_>) -> Result<()> { + let help = [ + "**Trigger Commands**", + "`/trigger create ` — Create new trigger group", + "`/trigger add ` — Add keywords/responses (separate with \\)", + "`/trigger remove ` — Remove keywords/responses", + "`/trigger delete ` — Delete entire group", + "`/trigger list ` — View group details", + ] + .join("\n"); + + ctx.say(help).await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![trigger()] +} 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 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +#[derive(poise::ChoiceParameter)] +pub enum OutputMode { + #[name = "File"] + File, + #[name = "Voice Channel"] + Vc, +} + +/// Generate Google TTS (Polish) and output to file or voice channel +#[poise::command(slash_command)] +pub async fn tts( + ctx: Context<'_>, + #[description = "Text to synthesize"] text: String, + #[description = "Output destination (file or vc)"] output: Option, +) -> Result<()> { + ctx.defer().await?; + let lang = "pl"; + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob", + urlencoding(&text), + lang + ); + + let output = output.unwrap_or(OutputMode::Vc); + + let audio_data = ctx + .data() + .http_client + .get(&tts_url) + .send() + .await? + .bytes() + .await?; + + match output { + OutputMode::File => { + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + let path = format!( + "{}/tts_{}.mp3", + crate::config::TEMP_PATH, + uuid::Uuid::new_v4() + ); + tokio::fs::write(&path, &audio_data).await?; + ctx.send( + poise::CreateReply::default() + .content(lang::L_TTS_SAVED) + .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), + ) + .await?; + } + OutputMode::Vc => { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + let input = crate::voice::decode_audio(&audio_data)?; + let mut call_lock = call.lock().await; + if call_lock.current_channel().is_some() { + call_lock.play_input(input); + } + ctx.say(lang::L_TTS_PLAYING).await?; + } + } + Ok(()) +} + +pub fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &byte in s.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { + out.push(byte as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} + +pub fn commands() -> Vec> { + vec![tts()] +} 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 @@ +use poise::serenity_prelude as serenity; +use tracing::warn; + +use super::autocomplete::any_file; +use super::music::sanitize_yt_url; +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +// Above this size (or when `serwer` is forced) the result is uploaded to +// copyparty instead of being attached to the Discord reply. Discord's own +// attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python +// original and keeps replies snappy. +const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FileType { + Video, + Audio, + Image, + Unknown, +} + +/// Classify a file by its extension. +/// +/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without +/// pulling in a mime crate: the set of extensions below covers everything the +/// original bot handled. +fn file_type(filename: &str) -> FileType { + let Some((_, ext)) = filename.rsplit_once('.') else { + return FileType::Unknown; + }; + if ext.is_empty() || ext.contains('/') { + return FileType::Unknown; + } + match ext.to_ascii_lowercase().as_str() { + "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp" + | "ts" => FileType::Video, + "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio, + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => { + FileType::Image + } + _ => FileType::Unknown, + } +} + +/// Lowercased extension without the leading dot, or `None` if there is none. +fn ext_of(filename: &str) -> Option { + let (_, ext) = filename.rsplit_once('.')?; + if ext.is_empty() || ext.contains('/') { + None + } else { + Some(ext.to_ascii_lowercase()) + } +} + +/// Filename stem: everything before the final dot of the basename. +fn stem_of(filename: &str) -> &str { + let base = filename.rsplit('/').next().unwrap_or(filename); + match base.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem, + _ => base, + } +} + +fn temp_path_for(name: &str) -> String { + format!("{}/{}", crate::config::TEMP_PATH, name) +} + +/// Best-effort removal of temp files; ignores missing files. +async fn cleanup(paths: &[&str]) { + for p in paths { + let _ = tokio::fs::remove_file(p).await; + } +} + +/// Resolve the input source — either an autocomplete-picked copyparty file or +/// a Discord attachment — downloading the bytes and returning them alongside +/// the original filename. +/// +/// Replies with an appropriate user-facing message and returns `Ok(None)` when +/// no valid source was supplied, so callers can early-return without sending a +/// second message. +async fn resolve_input( + ctx: &Context<'_>, + plik: Option<&str>, + zalacznik: Option<&serenity::Attachment>, +) -> Result, String)>> { + if let Some(name) = plik { + // Autocomplete-selected copyparty file: the choice value is the decoded + // filename, stashed into the per-user ac_cache by `any_file`. + let hit = match ctx.data().ac_lookup(ctx.author().id, name).await { + Some(h) => h, + None => { + ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?; + return Ok(None); + } + }; + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = ctx.data().copyparty.build_url(&path); + let resp = ctx.data().http_client.get(&full_url).send().await?; + if !resp.status().is_success() { + ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; + return Ok(None); + } + let bytes = resp.bytes().await?.to_vec(); + return Ok(Some((bytes, name.to_string()))); + } + + if let Some(att) = zalacznik { + let resp = ctx.data().http_client.get(&att.url).send().await?; + if !resp.status().is_success() { + ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; + return Ok(None); + } + let bytes = resp.bytes().await?.to_vec(); + return Ok(Some((bytes, att.filename.clone()))); + } + + ctx.say(lang::L_UTIL_NEED_INPUT).await?; + Ok(None) +} + +/// Output extension the compressor produces for a given input. +/// +/// - Video keeps its container extension (ffmpeg re-muxes in place). +/// - Audio is always re-encoded to Opus in an `.ogg` container. +/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. +fn compress_output_ext(ft: FileType, original: &str) -> String { + let orig = ext_of(original); + match ft { + FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), + FileType::Audio => "ogg".to_string(), + FileType::Image => match orig.as_deref() { + Some("jpg" | "jpeg" | "gif") => orig.unwrap(), + _ => "jpg".to_string(), + }, + FileType::Unknown => String::new(), + } +} + +/// Build the ffmpeg/magick command for extreme-low-quality compression. +/// Returns `None` for unsupported (`Unknown`) file types. +fn build_compress_command( + ft: FileType, + input: &str, + output: &str, +) -> Option { + match ft { + FileType::Video => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y") + .arg("-i") + .arg(input) + .arg("-c:v") + .arg("libx264") + .arg("-crf") + .arg("51") + .arg("-b:v") + .arg("100") + .arg("-vf") + .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2") + .arg("-preset") + .arg("ultrafast") + .arg("-c:a") + .arg("libopus") + .arg("-b:a") + .arg("500") + .arg(output); + Some(c) + } + FileType::Audio => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y") + .arg("-i") + .arg(input) + .arg("-c:a") + .arg("libopus") + .arg("-b:a") + .arg("500") + .arg("-ar") + .arg("12000") + .arg("-ac") + .arg("1") + .arg(output); + Some(c) + } + FileType::Image => { + let mut c = tokio::process::Command::new("magick"); + c.arg(input); + match ext_of(input).as_deref() { + Some("gif") => { + c.arg("-coalesce") + .arg("-resize") + .arg("30%") + .arg("-colors") + .arg("32") + .arg("-strip") + .arg("-layers") + .arg("Optimize"); + } + // jpg/jpeg stay jpg; other images are rewritten to .jpg by + // `compress_output_ext` and magick infers the encoder from the + // output extension. + _ => { + c.arg("-strip") + .arg("-resize") + .arg("30%") + .arg("-quality") + .arg("5"); + } + } + c.arg(output); + Some(c) + } + FileType::Unknown => None, + } +} + +enum DeliverKind { + Compress, + Convert, +} + +/// Compress a single in-memory blob (an attachment body) into a compressed +/// file, returning the encoded bytes and the output filename. +/// +/// Mirrors the per-attachment path of Python's `Compression` message command, +/// including sequential `TEMP_PATH` reuse so identical attachment stems don't +/// collide (each iteration writes, encodes, then `cleanup`s before the next). +async fn compress_attachment_bytes( + ft: FileType, + original: &str, + input_bytes: &[u8], +) -> Option<(Vec, String)> { + let stem = stem_of(original).to_string(); + let out_ext = compress_output_ext(ft, original); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(original); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if tokio::fs::write(&input_path, input_bytes).await.is_err() { + return None; + } + let mut cmd = match build_compress_command(ft, &input_path, &output_path) { + Some(c) => c, + None => { + cleanup(&cleanup_paths).await; + return None; + } + }; + let res = match cmd.output().await { + Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), + _ => None, + }; + cleanup(&cleanup_paths).await; + res.map(|b| (b, out_filename)) +} + +/// Strip characters that Discord/coping filenames can't safely carry. Mirrors +/// Python's `Downloader._sanitize_filename`. +fn sanitize_filename(name: &str) -> String { + const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + name.chars() + .filter(|c| !BAD.contains(c)) + .collect::() + .trim() + .to_string() +} + +/// Extract `https://` URLs containing a path (host + `/...`) from a message. +/// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the +/// message-context `download` command — only URLs with at least one `/` after +/// the host are matched, and `http://` links are intentionally ignored. +fn extract_urls(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut urls = Vec::new(); + let mut i = 0; + while let Some(pos) = content[i..].find("https://") { + let start = i + pos; + let after = start + "https://".len(); + + // Consume the host (until `/` or whitespace). + let mut j = after; + while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' { + j += 1; + } + + if j < bytes.len() && bytes[j] == b'/' { + // Consume the rest of the URL (until whitespace). + let mut k = j; + while k < bytes.len() && !bytes[k].is_ascii_whitespace() { + k += 1; + } + urls.push(content[start..k].to_string()); + i = k; + } else { + // No path component — not matched by the regex; advance past it. + i = after; + } + } + urls +} + +/// Locate the single file yt-dlp produced in `dir`, picking the most recently +/// modified one (matches Python's `os.listdir`-first fallback behaviour while +/// being deterministic enough for the per-call unique subdir). +async fn find_downloaded_file(dir: &str) -> Option<(String, String)> { + let mut rd = tokio::fs::read_dir(dir).await.ok()?; + let mut newest: Option<(std::time::SystemTime, String)> = None; + while let Ok(Some(entry)) = rd.next_entry().await { + let meta = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + let mtime = match meta.modified() { + Ok(t) => t, + Err(_) => continue, + }; + if let Some((t, _)) = &newest { + if *t >= mtime { + continue; + } + } + newest = Some((mtime, entry.path().to_string_lossy().into_owned())); + } + newest.map(|(_, p)| { + let fname = std::path::Path::new(&p) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + (p, fname) + }) +} + +/// Hand the produced download back to the user: attach when below the size +/// threshold (and upload isn't forced), otherwise push to copyparty and reply +/// with the download URL. Mirrors Python's `Downloader` slash `/download`. +async fn deliver_download( + ctx: &Context<'_>, + bytes: Vec, + filename: String, + catbox: bool, +) -> Result<()> { + if bytes.len() > LARGE_FILE_THRESHOLD || catbox { + match ctx.data().copyparty.upload(&filename, bytes).await { + Ok(url) => { + ctx.say(lang::fmt_download_uploaded(&url)).await?; + } + Err(e) => { + tracing::warn!("copyparty upload failed: {e}"); + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + } + } + } else { + ctx.send( + poise::CreateReply::default() + .attachment(serenity::CreateAttachment::bytes(bytes, filename)), + ) + .await?; + } + Ok(()) +} + +/// Run yt-dlp on `url` and deliver the resulting file. The caller is expected +/// to have already called `ctx.defer()`. Each invocation uses a fresh unique +/// subdirectory under `TEMP_PATH` (matching Python's per-call +/// `tempfile.TemporaryDirectory`) so concurrent downloads never collide. +async fn run_download( + ctx: &Context<'_>, + url: &str, + catbox: bool, + fformat: Option<&str>, + filename_user: Option<&str>, +) -> Result<()> { + let fmt = fformat.unwrap_or("mp4"); + let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); + let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4()); + tokio::fs::create_dir_all(&dl_dir).await.ok(); + + let out_template = match filename_user { + Some(name) => { + // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext. + let clean: String = sanitize_filename(name).chars().take(100).collect(); + format!("{dl_dir}/{clean}.%(ext)s") + } + None => format!("{dl_dir}/%(title).100s.%(ext)s"), + }; + + let mut cmd = tokio::process::Command::new("yt-dlp"); + cmd.arg("-f") + .arg("bestaudio/bestvideo+bestaudio") + .arg("--merge-output-format") + .arg("mp4") + .arg("-o") + .arg(&out_template); + if is_audio { + cmd.arg("--extract-audio").arg("--audio-format").arg(fmt); + } + cmd.arg(sanitize_yt_url(url)); + + let output = cmd + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Keep the full yt-dlp trace in the logs; the user only sees the + // short `fmt_download_failed` (translated) message. + warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed(&stderr)).await?; + return Ok(()); + } + + let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else { + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed("no file produced")) + .await?; + return Ok(()); + }; + + // Determine the display filename for the upload/attachment. + let final_name = match filename_user { + Some(name) => { + let clean: String = sanitize_filename(name).chars().take(100).collect(); + let ext = std::path::Path::new(&basename) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + format!("{clean}{ext}") + } + None => basename, + }; + + let bytes = match tokio::fs::read(&actual_path).await { + Ok(b) => b, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed(&e.to_string())).await?; + return Ok(()); + } + }; + + let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await; + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + deliver_result +} + +/// Hand the produced bytes back to the user. +/// +/// Below the size threshold (and when upload isn't forced) the result is +/// attached directly to the reply. Otherwise it's pushed to copyparty and the +/// reply contains the download URL. Upload failures are reported gracefully +/// rather than propagated, matching the Python original. +async fn deliver( + ctx: &Context<'_>, + bytes: Vec, + filename: String, + force_upload: bool, + kind: DeliverKind, +) -> Result<()> { + if bytes.len() > LARGE_FILE_THRESHOLD || force_upload { + match ctx.data().copyparty.upload(&filename, bytes).await { + Ok(url) => { + let msg = match kind { + DeliverKind::Compress => lang::fmt_util_compressed(&url), + DeliverKind::Convert => lang::fmt_util_converted(&url), + }; + ctx.say(msg).await?; + } + Err(e) => { + tracing::warn!("copyparty upload failed: {e}"); + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + } + } + } else { + ctx.send( + poise::CreateReply::default() + .attachment(serenity::CreateAttachment::bytes(bytes, filename)), + ) + .await?; + } + Ok(()) +} + +/// Compress media to extreme low quality +#[poise::command(slash_command, aliases("kompresja"))] +pub async fn compress( + ctx: Context<'_>, + #[description = "File from Copyparty"] + #[autocomplete = "any_file"] + plik: Option, + #[description = "File from Discord"] zalacznik: Option, + #[description = "Force upload to Copyparty"] serwer: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let (input_bytes, original_filename) = + match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { + Some(v) => v, + None => return Ok(()), + }; + + let ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_ext = compress_output_ext(ft, &original_filename); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("compress: failed to write input: {e}"); + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + return Ok(()); + } + + let result = match build_compress_command(ft, &input_path, &output_path) { + Some(mut cmd) => cmd.output().await, + None => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + }; + + match result { + Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { + Ok(out_bytes) => { + cleanup(&cleanup_paths).await; + deliver( + &ctx, + out_bytes, + out_filename, + serwer.unwrap_or(false), + DeliverKind::Compress, + ) + .await?; + } + Err(e) => { + tracing::warn!("compress: output missing after encode: {e}"); + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + } + }, + _ => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + } + } + Ok(()) +} + +/// Convert media between formats +#[poise::command(slash_command, aliases("konwersja"))] +pub async fn convert( + ctx: Context<'_>, + #[description = "File from Copyparty"] + #[autocomplete = "any_file"] + plik: Option, + #[description = "File from Discord"] zalacznik: Option, + #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String, + #[description = "Force upload to Copyparty"] serwer: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let (input_bytes, original_filename) = + match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { + Some(v) => v, + None => return Ok(()), + }; + + // Normalise the requested extension: lowercase, no leading dot, and reject + // anything containing path separators or extra dots — this keeps it a + // single clean extension component and rules out escaping TEMP_PATH. + let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase(); + if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') { + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + + let ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_filename = format!("{stem}.{fmt}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("convert: failed to write input: {e}"); + ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; + return Ok(()); + } + + let mut cmd = match ft { + FileType::Video | FileType::Audio => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y").arg("-i").arg(&input_path).arg(&output_path); + c + } + FileType::Image => { + let mut c = tokio::process::Command::new("magick"); + c.arg(&input_path).arg(&output_path); + c + } + FileType::Unknown => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + }; + + match cmd.output().await { + Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { + Ok(out_bytes) => { + cleanup(&cleanup_paths).await; + deliver( + &ctx, + out_bytes, + out_filename, + serwer.unwrap_or(false), + DeliverKind::Convert, + ) + .await?; + } + Err(e) => { + tracing::warn!("convert: output missing after encode: {e}"); + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; + } + }, + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + cleanup(&cleanup_paths).await; + ctx.say(lang::fmt_util_convert_err(&stderr)).await?; + } + Err(e) => { + cleanup(&cleanup_paths).await; + ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?; + } + } + Ok(()) +} + +/// Upload files from Discord to Copyparty +#[poise::command(slash_command)] +pub async fn upload( + ctx: Context<'_>, + #[description = "File to upload"] zalacznik: serenity::Attachment, +) -> Result<()> { + ctx.defer().await?; + + let data = ctx + .data() + .http_client + .get(&zalacznik.url) + .send() + .await? + .bytes() + .await?; + let file_url = ctx + .data() + .copyparty + .upload(&zalacznik.filename, data.to_vec()) + .await?; + ctx.say(lang::fmt_uploaded(&file_url)).await?; + Ok(()) +} + +/// Search Copyparty file server and return a direct URL +#[poise::command(slash_command)] +pub async fn search( + ctx: Context<'_>, + #[description = "Partial filename to search"] + #[autocomplete = "any_file"] + filename: String, +) -> Result<()> { + // Autocomplete cache: per-user (UserId, filename) → SearchHit. + if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await { + let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename))); + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); + ctx.say(lang::fmt_search_result(&name, &full_url)).await?; + return Ok(()); + } + + // Direct copyparty path from manual input (starts with /) + if filename.starts_with('/') { + let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename); + ctx.say(lang::fmt_search_result(&filename, &full_url)) + .await?; + return Ok(()); + } + + let results = ctx.data().copyparty.search(&filename, 10, &[]).await?; + + if results.is_empty() { + ctx.say(lang::L_SEARCH_NO_RESULTS).await?; + return Ok(()); + } + + let hit = &results[0]; + let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown"))); + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); + + ctx.say(lang::fmt_search_result(&name, &full_url)).await?; + Ok(()) +} + +/// Download media from any yt-dlp-supported URL +#[poise::command(slash_command)] +pub async fn download( + ctx: Context<'_>, + #[description = "Media URL"] url: String, + #[description = "Force upload to Copyparty"] catbox: Option, + #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option, + #[description = "Custom output filename"] filename_user: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + run_download( + &ctx, + &url, + catbox.unwrap_or(false), + fformat.as_deref(), + filename_user.as_deref(), + ) + .await +} + +/// Right-click a message → upload its attachments to Copyparty +/// (mirrors Python's `@commands.message_command(name="upload")`). +/// +/// Display name is not `"upload"` because poise's dispatcher matches commands +/// by name alone without considering `CommandType`, so a context-menu named +/// `"upload"` would shadow — and be shadowed by — the slash `/upload` command +/// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"` +/// avoids the collision while keeping the Polish user-facing label. +#[poise::command(context_menu_command = "Wyślij załączniki")] +pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + if message.attachments.is_empty() { + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + + let mut urls: Vec = Vec::new(); + let mut failed = 0usize; + for att in &message.attachments { + let resp = match ctx.data().http_client.get(&att.url).send().await { + Ok(r) if r.status().is_success() => r, + _ => { + failed += 1; + continue; + } + }; + let bytes = resp.bytes().await?.to_vec(); + match ctx.data().copyparty.upload(&att.filename, bytes).await { + Ok(url) => urls.push(url), + Err(e) => { + tracing::warn!("upload: copyparty failed for {}: {e}", att.filename); + failed += 1; + } + } + } + + if urls.is_empty() { + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + return Ok(()); + } + + let mut resp = lang::fmt_upload_summary(urls.len()); + for u in &urls { + resp.push_str(u); + resp.push('\n'); + } + if failed > 0 { + resp.push_str(&lang::fmt_upload_partial_failed(failed)); + } + ctx.say(resp).await?; + Ok(()) +} + +/// Right-click a message → compress all of its attachments +/// (mirrors Python's `@commands.message_command(name="kompresja")`). +#[poise::command(context_menu_command = "kompresja")] +pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + if message.attachments.is_empty() { + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let total = message.attachments.len(); + let mut attachments_out: Vec = Vec::new(); + let mut made = 0usize; + + for att in &message.attachments { + let resp = match ctx.data().http_client.get(&att.url).send().await { + Ok(r) if r.status().is_success() => r, + _ => continue, + }; + let bytes = match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(_) => continue, + }; + let ft = file_type(&att.filename); + if let Some((out_bytes, out_name)) = + compress_attachment_bytes(ft, &att.filename, &bytes).await + { + attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name)); + made += 1; + } + } + + if attachments_out.is_empty() { + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + return Ok(()); + } + + let mut reply = + poise::CreateReply::default().content(lang::fmt_message_compressed(made, total)); + for a in attachments_out { + reply = reply.attachment(a); + } + ctx.send(reply).await?; + Ok(()) +} + +/// Right-click a message → download every `https://` URL it contains +/// (mirrors Python's `@commands.message_command(name="download")`). +/// +/// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with +/// the slash `/download` command in poise's name-only dispatcher (see the note +/// on `message_upload`). +#[poise::command(context_menu_command = "Pobierz z linku")] +pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + let urls = extract_urls(&message.content); + if urls.is_empty() { + // Faithful to the Python original, which reuses the attachments-missing + // string for the no-URL case. + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + for url in urls { + if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await { + tracing::warn!("download runner error for {url}: {e}"); + } + } + Ok(()) +} + +/// Echo text to channel +#[poise::command(slash_command)] +pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> { + ctx.say(&text).await?; + ctx.send( + poise::CreateReply::default() + .content("\u{200b}") + .ephemeral(true), + ) + .await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![ + compress(), + convert(), + upload(), + search(), + download(), + say(), + message_upload(), + message_compression(), + message_download(), + ] +} 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 @@ +use poise::serenity_prelude as serenity; +use scraper::{Html, Selector}; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Search the web via DuckDuckGo +#[poise::command(slash_command)] +pub async fn web(ctx: Context<'_>, #[description = "Search query"] query: String) -> Result<()> { + ctx.defer().await?; + + let url = format!( + "https://html.duckduckgo.com/html/?q={}", + urlencoding(&query) + ); + let html = ctx + .data() + .http_client + .get(&url) + .send() + .await? + .text() + .await?; + + let results = { + let document = Html::parse_document(&html); + + let result_selector = Selector::parse(".result").unwrap(); + let title_selector = Selector::parse(".result__title a").unwrap(); + let snippet_selector = Selector::parse(".result__snippet").unwrap(); + + let mut results = Vec::new(); + for result in document.select(&result_selector).take(5) { + let title = result + .select(&title_selector) + .next() + .map(|a| a.text().collect::()) + .unwrap_or_default(); + + let url = result + .select(&title_selector) + .next() + .and_then(|a| a.attr("href")) + .unwrap_or("") + .to_string(); + + let snippet = result + .select(&snippet_selector) + .next() + .map(|s| s.text().collect::()) + .unwrap_or_default(); + + let clean_url = if let Some(encoded) = url.split("uddg=").nth(1) { + let encoded = encoded.split('&').next().unwrap_or(encoded); + url_decode(encoded) + } else { + url.to_string() + }; + + results.push((title, clean_url, snippet)); + } + results + }; + + if results.is_empty() { + ctx.say(lang::L_WEB_NO_RESULTS).await?; + return Ok(()); + } + + let mut embed = serenity::CreateEmbed::default() + .title(lang::fmt_web_title(&query)) + .color(0x00aaff); + + for (i, (title, url, snippet)) in results.iter().enumerate() { + embed = embed.field( + format!("{}. {title}", i + 1), + format!("{url}\n{snippet}"), + false, + ); + } + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +fn url_decode(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '+' => result.push(' '), + '%' => { + let hi = chars.next().and_then(|c| c.to_digit(16)); + let lo = chars.next().and_then(|c| c.to_digit(16)); + if let (Some(hi), Some(lo)) = (hi, lo) { + result.push(char::from_u32((hi << 4) | lo).unwrap_or('�')); + } else { + result.push('%'); + } + } + _ => result.push(c), + } + } + result +} + +fn urlencoding(s: &str) -> String { + s.chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '~' => c.to_string(), + ' ' => "+".to_string(), + _ => format!("%{:02X}", c as u8), + }) + .collect() +} + +pub fn commands() -> Vec> { + vec![web()] +} 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 @@ +use std::env; + +pub const TEMP_PATH: &str = "/tmp/senator_sperma"; + +#[derive(Debug, Clone)] +pub struct Config { + pub token: String, + pub status_channel_id: Option, + pub assistant_keyword: String, + pub copyparty_url: String, + pub copyparty_url_upload: String, + pub copyparty_password: Option, + pub openrouter_api_key: Option, + pub openrouter_model: Option, + pub database_path: String, +} + +struct EnvVar { + name: &'static str, + required: bool, + default: Option<&'static str>, + description: &'static str, +} + +const ENV_VARS: &[EnvVar] = &[ + EnvVar { + name: "TOKEN", + required: true, + default: None, + description: "Discord bot token", + }, + EnvVar { + name: "STATUS_CHANNEL_ID", + required: false, + default: None, + description: "status channel ID", + }, + EnvVar { + name: "ASSISTANT_KEYWORD", + required: false, + default: Some("jarvis"), + description: "keyword for AI assistant", + }, + EnvVar { + name: "COPYPARTY_URL", + required: false, + default: Some("http://localhost:3939"), + description: "Copyparty base URL", + }, + EnvVar { + name: "COPYPARTY_URL_UPLOAD", + required: false, + default: Some("http://localhost:3939"), + description: "Copyparty upload URL", + }, + EnvVar { + name: "COPYPARTY_PASSWORD", + required: false, + default: None, + description: "Copyparty auth password", + }, + EnvVar { + name: "OPENROUTER_API_KEY", + required: false, + default: None, + description: "OpenRouter API key (disables AI if missing)", + }, + EnvVar { + name: "OPENROUTER_MODEL", + required: false, + default: Some("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"), + description: "OpenRouter model", + }, + EnvVar { + name: "SENATOR_DB_PATH", + required: false, + default: Some("data/senator.db"), + description: "SQLite database path", + }, + EnvVar { + name: "RUST_LOG", + required: false, + default: Some("info"), + description: "tracing/logging level filter", + }, +]; + +pub fn check_env_vars() { + let mut missing_required = Vec::new(); + let mut missing_optional = Vec::new(); + + for var in ENV_VARS { + match env::var(var.name) { + Ok(_) => continue, + Err(_) => { + if var.required { + tracing::error!("MISSING REQUIRED: {} ({})", var.name, var.description); + missing_required.push(var.name); + } else { + match var.default { + Some(default) => { + tracing::warn!("{} not set, using default: {}", var.name, default) + } + None => tracing::warn!("{} not set ({})", var.name, var.description), + } + missing_optional.push(var.name); + } + } + } + } + + if !missing_required.is_empty() { + tracing::error!( + "Missing required environment variables: {}. The application will not function correctly.", + missing_required.join(", ") + ); + } + + if !missing_optional.is_empty() { + tracing::warn!( + "Missing optional environment variables: {}. Some features will be disabled or use defaults.", + missing_optional.join(", ") + ); + } +} + +impl Config { + pub fn from_env() -> Self { + Self { + token: env::var("TOKEN").expect("Missing TOKEN"), + status_channel_id: env::var("STATUS_CHANNEL_ID") + .ok() + .and_then(|v| v.parse().ok()), + assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()), + copyparty_url: env::var("COPYPARTY_URL") + .unwrap_or_else(|_| "http://localhost:3939".into()), + copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD") + .unwrap_or_else(|_| "http://localhost:3939".into()), + copyparty_password: env::var("COPYPARTY_PASSWORD").ok(), + openrouter_api_key: env::var("OPENROUTER_API_KEY").ok(), + openrouter_model: env::var("OPENROUTER_MODEL").ok(), + database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), + } + } +} 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 @@ +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; + +use crate::error::Result; +use crate::lang; + +#[derive(Clone)] +pub struct CopypartyClient { + pub base_url: String, + pub upload_url: String, + pub password: Option, + client: Client, +} + +/// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`). +pub fn percent_decode(s: &str) -> String { + let mut buf: Vec = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { + buf.push(hi << 4 | lo); + i += 3; + continue; + } + } + if b == b'+' { + buf.push(b' '); + } else { + buf.push(b); + } + i += 1; + } + String::from_utf8_lossy(&buf).into_owned() +} + +/// Decode common HTML entities in a display string. +/// Handles named (`&`, `<`) and numeric (`{`, `{`) forms. +/// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8. +pub fn html_decode(s: &str) -> String { + let mut buf: Vec = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' { + let rest = &s[i..]; + if let Some(end) = rest.find(';') { + let entity = &rest[..=end]; + match entity { + "&" => buf.extend_from_slice(b"&"), + "<" => buf.extend_from_slice(b"<"), + ">" => buf.extend_from_slice(b">"), + """ => buf.extend_from_slice(b"\""), + "'" => buf.extend_from_slice(b"'"), + " " => buf.extend_from_slice(b" "), + _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => { + let (num_str, radix) = + if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' { + (&entity[3..entity.len() - 1], 16u32) + } else { + (&entity[2..entity.len() - 1], 10u32) + }; + let decoded = u32::from_str_radix(num_str, radix) + .ok() + .and_then(std::char::from_u32); + if let Some(c) = decoded { + let mut tmp = [0u8; 4]; + buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes()); + i += entity.len(); + continue; + } + buf.extend_from_slice(entity.as_bytes()); + } + _ => buf.extend_from_slice(entity.as_bytes()), + } + i += entity.len(); + continue; + } + } + buf.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&buf).into_owned() +} + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'A'..=b'F' => Some(b - b'A' + 10), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} + +/// A search result from copyparty's `/?srch` API. +/// +/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). +/// The `name` / `url` / `path` fields are typically only present in directory +/// listing (`?ls`) responses, not in search. Use the convenience accessors +/// below instead of reading fields directly. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchHit { + /// Relative path including filename (e.g. `/music/foo.mp3`). + /// This is the primary field populated by `/?srch`. + #[serde(default)] + pub rp: Option, + /// Path component (directory listing only) + #[serde(default)] + pub path: Option, + /// URL fragment for building a download URL + #[serde(default)] + pub url: Option, + /// Bare filename (directory listing only) + #[serde(default)] + pub name: Option, +} + +impl SearchHit { + /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail. + pub fn filename(&self) -> Option<&str> { + self.name + .as_deref() + .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next())) + .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next())) + } + + /// Best-effort URL path for building a full download URL. + /// Tries `url`, then `rp`. + pub fn url_path(&self) -> Option<&str> { + self.url.as_deref().or_else(|| self.rp.as_deref()) + } +} + +#[derive(Debug, Deserialize)] +pub struct UploadResponse { + pub fileurl: Option, +} + +impl CopypartyClient { + pub fn new(base_url: String, upload_url: String, password: Option) -> Self { + Self { + base_url, + upload_url, + password, + client: Client::new(), + } + } + + /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD` + /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) + /// or relative (`music/foo.mp3`). + pub fn build_url(&self, path: &str) -> String { + let prefixed = if path.starts_with('/') { + format!("{}{}", self.base_url, path) + } else { + format!("{}/{}", self.base_url, path) + }; + if let Some(pw) = &self.password { + format!("{prefixed}?pw={pw}") + } else { + prefixed + } + } + + /// Search copyparty via the jPOST `/?srch` endpoint. + /// + /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python + /// `_extract_files_from_response` helper), because the exact JSON shape can vary + /// across copyparty versions. + pub async fn search( + &self, + query: &str, + max_results: u32, + ext_filter: &[&str], + ) -> Result> { + let tokens: Vec<&str> = query.split_whitespace().collect(); + let conditions: Vec = tokens + .iter() + .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")")) + .collect(); + + let mut q = conditions.join(" and "); + if !ext_filter.is_empty() { + let ext_conditions: Vec = ext_filter + .iter() + .map(|e| format!("name like \"*.{e}\"")) + .collect(); + let ext_q = ext_conditions.join(" or "); + // When query has no tokens (e.g. empty autocomplete partial), + // avoid wrapping an empty string in parens → "() and ..." is invalid. + if q.is_empty() { + q = ext_q; + } else { + q = format!("({q}) and ({ext_q})"); + } + } + + let payload = serde_json::json!({"n": max_results, "q": q}); + + let mut req = self + .client + .post(format!("{}/?srch", self.base_url)) + .json(&payload); + if let Some(pw) = &self.password { + req = req.header("PW", pw); + } + + let resp = req.send().await?; + let status = resp.status(); + let body_text = resp.text().await?; + + if !status.is_success() { + let snippet: String = body_text.chars().take(200).collect(); + return Err(crate::error::Error::Custom(lang::fmt_cp_search_http( + &status, &snippet, + ))); + } + + let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { + let snippet: String = body_text.chars().take(200).collect(); + crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet)) + })?; + + // Extract hits array – copyparty wraps results in {"hits": [...]} + let hits_val = match &root { + serde_json::Value::Object(map) => map.get("hits"), + _ => None, + }; + + let hits: Vec = match hits_val { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| SearchHit::deserialize(v).ok()) + .collect(), + _ => { + warn!( + "copyparty search response missing 'hits' array; body starts: {:?}", + body_text.chars().take(100).collect::() + ); + Vec::new() + } + }; + + Ok(hits) + } + + pub async fn upload(&self, file_name: &str, data: Vec) -> Result { + let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string()); + let form = reqwest::multipart::Form::new().part("f", part); + + let mut req = self + .client + .post(format!("{}/?j=1&ck=b2", self.upload_url)) + .multipart(form); + + if let Some(pw) = &self.password { + req = req.header("Cookie", format!("cppwd={pw}")); + } + + let resp: UploadResponse = req.send().await?.json().await?; + resp.fileurl + .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into())) + } +} 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 @@ +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use sqlx::SqlitePool; + +use crate::error::{Error, Result}; + +pub type DbPool = SqlitePool; + +pub async fn init_pool(path: &str) -> Result { + if let Some(parent) = std::path::Path::new(path).parent() { + tokio::fs::create_dir_all(parent).await.map_err(Error::Io)?; + } + let pool = SqlitePool::connect(&format!("sqlite:{path}?mode=rwc")).await?; + run_migrations(&pool).await?; + Ok(pool) +} + +async fn run_migrations(pool: &DbPool) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS voice_activity ( + user_id TEXT PRIMARY KEY, + join_time REAL NOT NULL DEFAULT 0, + total_active REAL NOT NULL DEFAULT 0, + total_afk REAL NOT NULL DEFAULT 0, + is_afk INTEGER NOT NULL DEFAULT 0, + afk_start REAL NOT NULL DEFAULT 0, + active_start REAL NOT NULL DEFAULT 0, + channel_name TEXT NOT NULL DEFAULT '', + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_expression TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')), + channel_id INTEGER NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + audio_filename TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS trigger_keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE, + keyword TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trigger_id, keyword) + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS trigger_responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE, + response_text TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(pool) + .await?; + + Ok(()) +} + +// -- Voice Activity -- +#[derive(Debug, Clone, FromRow)] +pub struct VoiceActivity { + pub user_id: String, + pub join_time: f64, + pub total_active: f64, + pub total_afk: f64, + pub is_afk: i32, + pub afk_start: f64, + pub active_start: f64, + pub channel_name: String, + #[allow(dead_code)] + pub last_updated: Option>, +} + +#[allow(dead_code)] +pub async fn get_voice_activity(pool: &DbPool, user_id: &str) -> Result> { + Ok( + sqlx::query_as("SELECT * FROM voice_activity WHERE user_id = ?") + .bind(user_id) + .fetch_optional(pool) + .await?, + ) +} + +pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result<()> { + sqlx::query( + "INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + join_time = excluded.join_time, + total_active = excluded.total_active, + total_afk = excluded.total_afk, + is_afk = excluded.is_afk, + afk_start = excluded.afk_start, + active_start = excluded.active_start, + channel_name = excluded.channel_name, + last_updated = CURRENT_TIMESTAMP", + ) + .bind(&va.user_id) + .bind(va.join_time) + .bind(va.total_active) + .bind(va.total_afk) + .bind(va.is_afk) + .bind(va.afk_start) + .bind(va.active_start) + .bind(&va.channel_name) + .execute(pool) + .await?; + Ok(()) +} + +// -- Cron Jobs -- +#[derive(Debug, Clone, FromRow)] +pub struct CronJob { + pub id: i64, + pub cron_expression: String, + pub r#type: String, + pub channel_id: i64, + pub active: i32, + pub audio_filename: Option, + pub message: Option, + #[allow(dead_code)] + pub created_at: Option>, +} + +#[allow(dead_code)] +pub async fn list_cron_jobs(pool: &DbPool) -> Result> { + Ok(sqlx::query_as("SELECT * FROM cron_jobs ORDER BY id") + .fetch_all(pool) + .await?) +} + +#[allow(dead_code)] +pub async fn list_active_cron_jobs(pool: &DbPool) -> Result> { + Ok(sqlx::query_as("SELECT * FROM cron_jobs WHERE active = 1") + .fetch_all(pool) + .await?) +} + +pub async fn add_cron_job( + pool: &DbPool, + cron_expression: &str, + r#type: &str, + channel_id: i64, + audio_filename: Option<&str>, + message: Option<&str>, +) -> Result { + let result = sqlx::query( + "INSERT INTO cron_jobs (cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?)", + ) + .bind(cron_expression) + .bind(r#type) + .bind(channel_id) + .bind(audio_filename) + .bind(message) + .execute(pool) + .await?; + Ok(result.last_insert_rowid()) +} + +pub async fn delete_cron_job(pool: &DbPool, id: i64) -> Result { + let result = sqlx::query("DELETE FROM cron_jobs WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn set_cron_job_active(pool: &DbPool, id: i64, active: bool) -> Result { + let result = sqlx::query("UPDATE cron_jobs SET active = ? WHERE id = ?") + .bind(if active { 1 } else { 0 }) + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +// -- Triggers -- +#[derive(Debug, Clone, FromRow)] +pub struct Trigger { + pub id: i64, + pub name: String, + pub created_at: Option>, + pub last_updated: Option>, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, FromRow)] +pub struct TriggerKeyword { + pub id: i64, + pub trigger_id: i64, + pub keyword: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, FromRow)] +pub struct TriggerResponse { + pub id: i64, + pub trigger_id: i64, + pub response_text: String, +} + +pub async fn create_trigger(pool: &DbPool, name: &str) -> Result { + let result = sqlx::query("INSERT INTO triggers (name) VALUES (?)") + .bind(name) + .execute(pool) + .await?; + Ok(result.last_insert_rowid()) +} + +pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result { + let result = sqlx::query("DELETE FROM triggers WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result> { + Ok(sqlx::query_as("SELECT * FROM triggers WHERE name = ?") + .bind(name) + .fetch_optional(pool) + .await?) +} + +pub async fn list_triggers(pool: &DbPool) -> Result> { + Ok(sqlx::query_as("SELECT * FROM triggers ORDER BY name") + .fetch_all(pool) + .await?) +} + +pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> { + sqlx::query("INSERT OR IGNORE INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)") + .bind(trigger_id) + .bind(keyword) + .execute(pool) + .await?; + update_trigger_timestamp(pool, trigger_id).await +} + +pub async fn remove_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result { + let result = sqlx::query("DELETE FROM trigger_keywords WHERE trigger_id = ? AND keyword = ?") + .bind(trigger_id) + .bind(keyword) + .execute(pool) + .await?; + update_trigger_timestamp(pool, trigger_id).await?; + Ok(result.rows_affected() > 0) +} + +pub async fn add_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<()> { + sqlx::query("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)") + .bind(trigger_id) + .bind(response) + .execute(pool) + .await?; + update_trigger_timestamp(pool, trigger_id).await +} + +pub async fn remove_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result { + let result = + sqlx::query("DELETE FROM trigger_responses WHERE trigger_id = ? AND response_text = ?") + .bind(trigger_id) + .bind(response) + .execute(pool) + .await?; + update_trigger_timestamp(pool, trigger_id).await?; + Ok(result.rows_affected() > 0) +} + +pub async fn get_keywords(pool: &DbPool, trigger_id: i64) -> Result> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT keyword FROM trigger_keywords WHERE trigger_id = ?") + .bind(trigger_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|r| r.0).collect()) +} + +pub async fn get_responses(pool: &DbPool, trigger_id: i64) -> Result> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT response_text FROM trigger_responses WHERE trigger_id = ?") + .bind(trigger_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|r| r.0).collect()) +} + +pub async fn load_all_triggers_with_keywords_and_responses( + pool: &DbPool, +) -> Result, Vec)>> { + let triggers = list_triggers(pool).await?; + let mut result = Vec::new(); + for t in triggers { + let keywords = get_keywords(pool, t.id).await?; + let responses = get_responses(pool, t.id).await?; + result.push((t, keywords, responses)); + } + Ok(result) +} + +async fn update_trigger_timestamp(pool: &DbPool, id: i64) -> Result<()> { + sqlx::query("UPDATE triggers SET last_updated = CURRENT_TIMESTAMP WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} 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 @@ +use std::fmt; + +#[derive(Debug)] +pub enum Error { + Sqlx(sqlx::Error), + Reqwest(reqwest::Error), + Serenity(serenity::Error), + SongbirdJoin(songbird::error::JoinError), + SongbirdControl(songbird::tracks::ControlError), + Io(std::io::Error), + Cron(cron::error::Error), + Image(image::ImageError), + Qr(qrcode::types::QrError), + Scraper(String), + Config(String), + TaskJoin(String), + Custom(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use crate::lang; + match self { + Self::Sqlx(e) => write!(f, "{}: {e}", lang::L_ERR_DATABASE), + Self::Reqwest(e) => write!(f, "{}: {e}", lang::L_ERR_HTTP), + Self::Serenity(e) => write!(f, "{}: {e}", lang::L_ERR_DISCORD), + Self::SongbirdJoin(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_JOIN), + Self::SongbirdControl(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_CONTROL), + Self::Io(e) => write!(f, "{}: {e}", lang::L_ERR_IO), + Self::Cron(e) => write!(f, "{}: {e}", lang::L_ERR_CRON), + Self::Image(e) => write!(f, "{}: {e}", lang::L_ERR_IMAGE), + Self::Qr(e) => write!(f, "{}: {e}", lang::L_ERR_QR), + Self::Scraper(e) => write!(f, "{}: {e}", lang::L_ERR_SCRAPER), + Self::Config(e) => write!(f, "{}: {e}", lang::L_ERR_CONFIG), + Self::TaskJoin(e) => write!(f, "{}: {e}", lang::L_ERR_TASK_JOIN), + Self::Custom(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Sqlx(e) => Some(e), + Self::Reqwest(e) => Some(e), + Self::Serenity(e) => Some(e), + Self::SongbirdJoin(e) => Some(e), + Self::SongbirdControl(e) => Some(e), + Self::Io(e) => Some(e), + Self::Image(e) => Some(e), + Self::Qr(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + Self::Sqlx(e) + } +} +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Self::Reqwest(e) + } +} +impl From for Error { + fn from(e: serenity::Error) -> Self { + Self::Serenity(e) + } +} +impl From for Error { + fn from(e: songbird::error::JoinError) -> Self { + Self::SongbirdJoin(e) + } +} +impl From for Error { + fn from(e: songbird::tracks::ControlError) -> Self { + Self::SongbirdControl(e) + } +} +impl From for Error { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} +impl From for Error { + fn from(e: cron::error::Error) -> Self { + Self::Cron(e) + } +} +impl From for Error { + fn from(e: image::ImageError) -> Self { + Self::Image(e) + } +} +impl From for Error { + fn from(e: qrcode::types::QrError) -> Self { + Self::Qr(e) + } +} +impl From for Error { + fn from(e: tokio::task::JoinError) -> Self { + Self::TaskJoin(e.to_string()) + } +} + +pub type Result = std::result::Result; 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 @@ +use chrono::{Local, Utc}; +use poise::serenity_prelude as serenity; +use rand::seq::IndexedRandom; +use tracing::{info, warn}; + +use crate::Data; +use crate::database::{self, VoiceActivity}; +use crate::error::Result; +use crate::lang; + +pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> { + info!("{}", lang::L_EV_READY); + Ok(()) +} + +fn format_duration(seconds: f64) -> String { + let total = seconds as u64; + let hours = total / 3600; + let minutes = (total % 3600) / 60; + let secs = total % 60; + if hours > 0 { + format!("{hours}h {minutes}m {secs}s") + } else if minutes > 0 { + format!("{minutes}m {secs}s") + } else { + format!("{secs}s") + } +} + +async fn play_tts_in_channel( + _ctx: &serenity::Context, + data: &Data, + guild_id: serenity::GuildId, + channel_id: serenity::ChannelId, + text: &str, +) { + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + crate::commands::tts::urlencoding(text), + ); + + let audio_data = match data.http_client.get(&tts_url).send().await { + Ok(resp) => match resp.bytes().await { + Ok(b) => b, + Err(e) => { + warn!("Failed to read TTS audio bytes: {e}"); + return; + } + }, + Err(e) => { + warn!("Failed to fetch TTS audio: {e}"); + return; + } + }; + + let call = match data.voice_manager.connect(guild_id, channel_id).await { + Ok(c) => c, + Err(e) => { + warn!("Failed to connect to voice for TTS: {e}"); + return; + } + }; + + let input = match crate::voice::decode_audio(&audio_data) { + Ok(i) => i, + Err(e) => { + warn!("Failed to decode TTS audio: {e}"); + return; + } + }; + + let mut call_lock = call.lock().await; + if call_lock.current_channel().is_some() { + call_lock.play_input(input); + } +} + +pub async fn handle_voice_state_update( + ctx: &serenity::Context, + data: &Data, + old: Option, + new: &serenity::VoiceState, +) -> Result<()> { + let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new); + let current_ts = Utc::now().timestamp(); + let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let user_id = new.user_id; + let guild_id = new.guild_id; + + // skip bots (Python behavior) + let member = guild_id + .and_then(|gid| ctx.cache.guild(gid)) + .and_then(|g| g.members.get(&user_id).cloned()); + if let Some(ref m) = member { + if m.user.bot { + return Ok(()); + } + } + + let member_name = member + .as_ref() + .map(|m| m.user.name.clone()) + .unwrap_or_default(); + let display_name = member + .as_ref() + .map(|m| m.display_name().to_string()) + .unwrap_or_default(); + + let now = current_ts as f64; + + let user_id_str = user_id.to_string(); + let mut va = database::get_voice_activity(&data.db_pool, &user_id_str) + .await? + .unwrap_or(VoiceActivity { + user_id: user_id_str.clone(), + join_time: 0.0, + total_active: 0.0, + total_afk: 0.0, + is_afk: 0, + afk_start: 0.0, + active_start: 0.0, + channel_name: String::new(), + last_updated: None, + }); + + let old_channel = old.as_ref().and_then(|v| v.channel_id); + let new_channel = new.channel_id; + + let mut tts_text: Option = None; + let mut tts_channel: Option = None; + + // ── JOIN ── + if old_channel.is_none() && new_channel.is_some() { + let cid = new_channel.unwrap(); + let is_afk = new.self_mute || new.self_deaf; + + va.join_time = now; + va.total_active = 0.0; + va.total_afk = 0.0; + va.is_afk = if is_afk { 1 } else { 0 }; + va.afk_start = if is_afk { now } else { 0.0 }; + va.active_start = if !is_afk { now } else { 0.0 }; + va.channel_name = guild_id + .and_then(|gid| ctx.cache.guild(gid)) + .map(|g| { + g.channels + .get(&cid) + .map(|c| c.name.clone()) + .unwrap_or_default() + }) + .unwrap_or_default(); + database::upsert_voice_activity(&data.db_pool, &va).await?; + info!("User {user_id} joined voice"); + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} dołączył na {} ", + va.channel_name + ); + lc.say(ctx, &msg).await.ok(); + } + tts_text = Some(lang::fmt_ev_joined(&display_name)); + tts_channel = Some(cid); + } + // ── LEAVE ── + else if old_channel.is_some() && new_channel.is_none() { + let ocid = old_channel.unwrap(); + let channel_name = guild_id + .and_then(|gid| ctx.cache.guild(gid)) + .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone())) + .unwrap_or_default(); + + if va.join_time > 0.0 { + if va.is_afk == 1 { + va.total_afk += now - va.afk_start; + } else { + va.total_active += (now - va.active_start).max(0.0); + } + + let active_str = format_duration(va.total_active); + let afk_str = format_duration(va.total_afk); + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} rozłączył się z {} \n\ + Aktywny: {active_str} | AFK: {afk_str}", + channel_name, + ); + lc.say(ctx, &msg).await.ok(); + } + info!("User {user_id} left voice"); + + database::upsert_voice_activity(&data.db_pool, &va).await?; + } else { + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} rozłączył się z {channel_name} " + ); + lc.say(ctx, &msg).await.ok(); + } + info!("User {user_id} left voice (no data)"); + } + + tts_text = Some(lang::fmt_ev_left(&display_name)); + tts_channel = Some(ocid); + } + // ── AFK STATE CHANGE (only for tracked users already in channel) ── + else if new_channel.is_some() && va.join_time > 0.0 { + let is_afk = new.self_mute || new.self_deaf; + let was_afk = va.is_afk == 1; + + if was_afk && !is_afk { + // Return from AFK + let afk_duration = now - va.afk_start; + va.total_afk += afk_duration; + va.is_afk = 0; + va.afk_start = 0.0; + va.active_start = now; + database::upsert_voice_activity(&data.db_pool, &va).await?; + + let afk_str = format_duration(afk_duration); + // Python behavior: after.self_mute is always false here so always "odgłuchnął" + let activity = if new.self_mute { + "odciszył" + } else { + "odgłuchnął" + }; + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} {activity} się na {} \n\ + AFK: {afk_str}", + va.channel_name, + ); + lc.say(ctx, &msg).await.ok(); + } + info!("User {user_id} returned from AFK"); + } else if !was_afk && is_afk { + // Went AFK + let active_duration = if va.active_start > 0.0 { + (now - va.active_start).max(0.0) + } else { + 0.0 + }; + va.total_active += active_duration; + va.is_afk = 1; + va.afk_start = now; + va.active_start = 0.0; + database::upsert_voice_activity(&data.db_pool, &va).await?; + + let active_str = format_duration(active_duration); + let activity = if new.self_mute { + "wyciszył" + } else { + "ogłuchnął" + }; + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} {activity} się na {} \n\ + Aktywny: {active_str}", + va.channel_name, + ); + lc.say(ctx, &msg).await.ok(); + } + info!("User {user_id} went AFK"); + } + } + + // ── TTS for mute/deafen (deaf takes priority with early return) ── + if old.as_ref().map_or(false, |v| v.self_deaf) != new.self_deaf { + let phrase = if new.self_deaf { + lang::fmt_ev_deafened(&display_name) + } else { + lang::fmt_ev_undeafened(&display_name) + }; + if let Some(gid) = guild_id { + if let Some(cid) = tts_channel.or(new_channel) { + play_tts_in_channel(ctx, data, gid, cid, &phrase).await; + } + } + return Ok(()); + } + + if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute { + let phrase = if new.self_mute { + lang::fmt_ev_muted(&display_name) + } else { + lang::fmt_ev_unmuted(&display_name) + }; + if let Some(gid) = guild_id { + if let Some(cid) = tts_channel.or(new_channel) { + play_tts_in_channel(ctx, data, gid, cid, &phrase).await; + } + } + return Ok(()); + } + + // ── Play join/leave TTS if no mute/deafen change ── + if let (Some(text), Some(cid)) = (tts_text, tts_channel) { + if let Some(gid) = guild_id { + play_tts_in_channel(ctx, data, gid, cid, &text).await; + } + } + + Ok(()) +} + +pub async fn handle_message( + ctx: &serenity::Context, + data: &Data, + new_message: &serenity::Message, +) -> Result<()> { + if new_message.author.id == ctx.cache.current_user().id { + return Ok(()); + } + + let content_lower = new_message.content.to_lowercase(); + let response_to_send = { + let triggers = data.triggers.lock().await; + let mut chosen = None; + for (_trigger, keywords, responses) in triggers.iter() { + if keywords.iter().any(|k| content_lower.contains(k)) { + chosen = responses.choose(&mut rand::rng()).cloned(); + break; + } + } + chosen + }; + if let Some(response) = response_to_send { + new_message.channel_id.say(ctx, response).await.ok(); + } + + let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id); + let has_keyword = content_lower.contains(&data.config.assistant_keyword); + + if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() { + let api_key = data.config.openrouter_api_key.clone().unwrap(); + let model = data.config.openrouter_model.clone().unwrap_or_else(|| { + "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into() + }); + + let client = crate::openrouter::OpenRouterClient::new(api_key, model); + let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT; + + let mut messages = Vec::new(); + let mut current_msg = Some(new_message.clone()); + for _ in 0..5 { + if let Some(msg) = current_msg { + messages.push(("user".into(), msg.content.clone())); + current_msg = match msg.message_reference.and_then(|r| r.message_id) { + Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(), + None => None, + }; + } else { + break; + } + } + messages.reverse(); + + if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await { + new_message.reply(ctx, response).await.ok(); + } + } + + Ok(()) +} 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 @@ +// ┌──────────────────────────────────────────────────────────────────┐ +// │ lang.rs — All user-facing messages live here. │ +// │ Translate the bot by editing this single file. │ +// │ Naming: L__ for simple &str; │ +// │ fn fmt__(...) -> String for parameterized. │ +// └──────────────────────────────────────────────────────────────────┘ + +// ── Error display prefixes (used in error.rs Display impl) ──────────── + +pub const L_ERR_DATABASE: &str = "Database error"; +pub const L_ERR_HTTP: &str = "HTTP error"; +pub const L_ERR_DISCORD: &str = "Discord error"; +pub const L_ERR_VOICE_JOIN: &str = "Voice join error"; +pub const L_ERR_VOICE_CONTROL: &str = "Voice control error"; +pub const L_ERR_IO: &str = "IO error"; +pub const L_ERR_CRON: &str = "Cron error"; +pub const L_ERR_IMAGE: &str = "Image error"; +pub const L_ERR_QR: &str = "QR error"; +pub const L_ERR_SCRAPER: &str = "Scraper error"; +pub const L_ERR_CONFIG: &str = "Config error"; +pub const L_ERR_TASK_JOIN: &str = "Task join error"; + +// ── Common / shared error messages ──────────────────────────────────── + +/// "Nie jestem w gildii" — bot must be invoked inside a guild +pub const L_NOT_IN_GUILD: &str = "Nie jestem w gildii"; +/// "Nie jesteś na kanale głosowym" — user must be in a voice channel +pub const L_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym"; +/// "Zły typ kanału" — wrong channel variant +pub const L_BAD_CHANNEL_TYPE: &str = "Zły typ kanału"; +/// "Grupa triggerów nie znaleziona" — trigger group not found +pub const L_TRIGGER_NOT_FOUND: &str = "Grupa triggerów nie znaleziona"; + +// ── AI / senator command ────────────────────────────────────────────── + +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."; + +pub const L_OPENROUTER_NO_KEY: &str = "Klucz API OpenRouter nie jest skonfigurowany w .env"; + +// ── Cron command ────────────────────────────────────────────────────── + +pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana"; +pub const L_CRON_NONE: &str = "Brak zakolejkowanych prac cron"; +pub const L_CRON_TITLE: &str = "Prace cron"; +pub const L_CRON_ACTIVE: &str = "✅ Aktywna"; +pub const L_CRON_PAUSED: &str = "⏸️ Zapauzowana"; + +pub fn fmt_cron_audio_queued(cron: &str) -> String { + format!("Praca audio zakolejkowana z cron'em `{cron}`") +} +pub fn fmt_cron_label_audio(name: &str) -> String { + format!("Audio: {name}") +} +pub fn fmt_cron_label_msg(msg: &str) -> String { + format!("Wiadomość: {msg}") +} +pub fn fmt_cron_job_status(id: i64, status: &str) -> String { + format!("ID {id} — {status}") +} +pub fn fmt_cron_field(cron: &str, chan: i64, desc: &str) -> String { + format!("`{cron}` — <#{chan}>\n{desc}") +} +pub fn fmt_cron_deleted(id: i64) -> String { + format!("Praca cron z ID {id} została usunięta") +} +pub fn fmt_cron_not_found(id: i64) -> String { + format!("Praca z ID {id} nie została znaleziona") +} +pub fn fmt_cron_paused(id: i64) -> String { + format!("Praca cron z ID {id} została zapauzowana") +} +pub fn fmt_cron_resumed(id: i64) -> String { + format!("Praca cron z ID {id} została wznowiona") +} + +// ── Math command ────────────────────────────────────────────────────── + +pub const L_MATH_TOO_LONG: &str = "Ekspresja za długa (max 1000 znaków"; +pub const L_MATH_NOT_ASCII: &str = "Tylko znaki ASCII dozwolone"; +pub const L_MATH_UNBALANCED: &str = "Nawiasy są niezbalansowane"; +pub const L_MATH_DANGEROUS: &str = "Ekspresja zawiera groźne słowa"; +pub const L_MATH_NO_RESULT: &str = "Brak rezultatu"; + +pub fn fmt_math_qalc_err(e: &dyn std::fmt::Display) -> String { + format!("qalc nie jest dostępny: {e}") +} +pub fn fmt_math_result(r: &str) -> String { + format!("```\n{r}\n```") +} + +// ── Music command ───────────────────────────────────────────────────── + +pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik"; +pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty"; +pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL"; +pub const L_MUSIC_CANT_COMPRESS_LIVE: &str = + "Nie można skompresować transmisji na żywo, odtwarzam normalnie"; +pub const L_MUSIC_STOPPED: &str = "wyszed"; +pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; + +pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String { + format!("Szukanie na copyparty zawiodło: {e}") +} +pub fn fmt_music_playing(name: &str) -> String { + format!("Grajcowanie: {name}") +} +pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String { + format!("Dołączon <#{cid}>") +} +pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String { + format!("Nie udało się połączyć z VC: {e}") +} + +// ── TTS command ─────────────────────────────────────────────────────── + +pub const L_TTS_SAVED: &str = "TTS zapisany"; +pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; + +// ── Trigger command ─────────────────────────────────────────────────── + +pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; +pub const L_TRIGGER_KW_NONE: &str = "Brak"; + +pub fn fmt_trigger_created(name: &str) -> String { + format!("Grupa trigger '{name}' utworzona") +} +pub fn fmt_trigger_failed(e: &dyn std::fmt::Display) -> String { + format!("Nie udało się: {e}") +} +pub fn fmt_trigger_deleted(name: &str) -> String { + format!("Grupa trigger '{name}' usunięta") +} +pub fn fmt_trigger_title(name: &str) -> String { + format!("Trigger: {name}") +} + +// ── Utility commands ────────────────────────────────────────────────── + +pub const L_UTIL_NEED_INPUT: &str = "Podaj plik z copyparty lub załącznik"; +pub const L_UTIL_PICK_FROM_AUTOCOMPLETE: &str = "Wybierz plik z podpowiedzi"; +pub const L_UTIL_DOWNLOAD_FAILED: &str = "Nie udało się pobrać pliku"; +pub const L_UTIL_UNSUPPORTED_TYPE: &str = "Nieobsługiwany typ pliku"; +pub const L_UTIL_COMPRESS_FAILED: &str = "Kompresja nie powiodła się"; +pub const L_UTIL_CONVERT_FAILED: &str = "Konwersja nie powiodła się"; +pub const L_UTIL_UPLOAD_FAILED: &str = "Nie udało się wysłać na serwer"; +pub const L_UTIL_NO_ATTACHMENTS: &str = "Brak załączników w wiadomości"; +pub const L_SEARCH_NO_RESULTS: &str = "No results found"; + +pub fn fmt_util_compressed(url: &str) -> String { + format!("Skompresowano\n{url}") +} +pub fn fmt_util_converted(url: &str) -> String { + format!("Skonwertowano\n{url}") +} +pub fn fmt_util_convert_err(stderr: &str) -> String { + format!("Błąd konwersji: {stderr}") +} + +pub fn fmt_uploaded(url: &str) -> String { + format!("Wysłon: {url}") +} +pub fn fmt_download_uploaded(url: &str) -> String { + format!("pobioron i wysyłon: {url}") +} +pub fn fmt_upload_summary(count: usize) -> String { + format!("Wysłano {count} plików:\n") +} +pub fn fmt_upload_partial_failed(count: usize) -> String { + format!("\n\nNie wysłano {count} plików") +} +pub fn fmt_message_compressed(made: usize, total: usize) -> String { + format!("Skompresowano {made} z {total} plików:") +} +pub fn fmt_search_result(name: &str, url: &str) -> String { + format!("**{name}**\n{url}") +} +pub fn fmt_download_failed(stderr: &str) -> String { + format!( + "Pobieranie się nie powiodło: {}", + translate_ytdlp_stderr(stderr) + ) +} +pub fn fmt_ytdlp_err(e: &dyn std::fmt::Display) -> String { + format!("yt-dlp nie mógło się uruchomić: {e}") +} +pub fn fmt_ytdlp_stderr(stderr: &str) -> String { + translate_ytdlp_stderr(stderr) +} + +/// Translate yt-dlp's verbose stderr into a short, user-friendly Polish +/// message. yt-dlp emits lots of `[youtube] Extracting URL…`, `WARNING:` +/// and `ERROR:` noise that is meaningless to a normal Discord user; this +/// recognises the handful of failure modes users actually hit and falls +/// back to the first `ERROR:`/`WARNING:` line (stripped of the +/// `ERROR: [extractor] id: ` prefix) when nothing matches, so we never +/// show the full multi-line trace. +pub fn translate_ytdlp_stderr(stderr: &str) -> String { + let lower = stderr.to_ascii_lowercase(); + + // YouTube's anti-bot sign-in wall. yt-dlp can't bypass this without + // browser cookies configured on the host — surface that to the user + // rather than the cryptic "Sign in to confirm you're not a bot" line. + if lower.contains("sign in to confirm you") + || lower.contains("confirm you're not a bot") + || lower.contains("confirm you are not a bot") + { + return "YouTube uznaje nas za bota i blokuje pobieranie. Spróbuj ponownie później albo podaj inny link.".into(); + } + + // Generic extractor fallback means the URL didn't match any known + // site — combined with "Requested format is not available" this is + // almost always "the user pasted a non-video URL into /download". + if lower.contains("requested format is not available") + || lower.contains("url could be a direct video link") + || lower.contains("falling back on generic information extractor") + { + 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(); + } + + if lower.contains("private video") { + return "Ten film jest prywatny i nie można go pobrać.".into(); + } + if lower.contains("video unavailable") + || lower.contains("video has been removed") + || lower.contains("this video is unavailable") + { + return "Ten film jest niedostępny lub został usunięty.".into(); + } + if lower.contains("members-only") + || lower.contains("premium-only") + || lower.contains("channel's members") + { + return "Ten film jest dostępny tylko dla subskrybujących / członków kanału.".into(); + } + if lower.contains("not available in your country") + || lower.contains("geo-restricted") + || lower.contains("geo restricted") + { + return "Ten film jest zablokowany regionalnie i nie jest dostępny w naszym regionie." + .into(); + } + if lower.contains("age-restricted") + || lower.contains("age restricted") + || lower.contains("inappropriate for some users") + { + return "Ten film jest ograniczony wiekowo i wymaga uwierzytelnienia.".into(); + } + if lower.contains("http error 404") || lower.contains(" http error 404 ") { + return "Nie znaleziono takiego adresu (404).".into(); + } + if lower.contains("unable to extract") && lower.contains("video data") { + return "Nie udało się wyciągnąć danych filmu — strona mogła się zmienić albo link jest niepełny.".into(); + } + + // Fallback: keep only the first ERROR: line and strip the + // `ERROR: [extractor] id: ` prefix so the message is short and + // free of yt-dlp internals. + for line in stderr.lines() { + if let Some(rest) = line.strip_prefix("ERROR:") { + let rest = rest.trim(); + // Drop leading `[extractor] video_id: ` noise if present. + let msg = rest + .strip_prefix(|c: char| c == '[') + .and_then(|r| r.find(']').map(|i| &r[i + 1..])) + .map(|s| { + s.trim_start_matches(|c: char| { + c.is_alphanumeric() || c == '_' || c == '-' || c == ':' + }) + .trim() + }) + .filter(|s| !s.is_empty()) + .unwrap_or(rest); + return format!("Pobieranie się nie powiodło: {msg}"); + } + } + for line in stderr.lines() { + if let Some(rest) = line.strip_prefix("WARNING:") { + return format!("Pobieranie się nie powiodło: {}", rest.trim()); + } + } + + "Pobieranie się nie powiodło.".into() +} + +// ── Web command ─────────────────────────────────────────────────────── + +pub const L_WEB_NO_RESULTS: &str = "Brak rezultatów"; + +pub fn fmt_web_title(query: &str) -> String { + format!("Wyniki wyszukiwania dla: {query}") +} + +// ── QR command ──────────────────────────────────────────────────────── + +pub const L_QR_GENERATED: &str = "Kod QR wygenerowany:"; +pub const L_QR_DECODE_FAILED: &str = "Nie udało się zdekodować kodu QR."; + +pub fn fmt_qr_decoded(text: &str) -> String { + format!("```\n{text}\n```") +} + +// ── Copyparty client ────────────────────────────────────────────────── + +pub const L_CP_UPLOAD_NO_URL: &str = "Wysłanie zawiodło, brak URL w odpowiedzi"; + +pub fn fmt_cp_search_http(status: &dyn std::fmt::Display, snippet: &str) -> String { + format!("copyparty zwróciło status HTTP {status}: {snippet}") +} +pub fn fmt_cp_invalid_json(e: &dyn std::fmt::Display, snippet: &str) -> String { + format!("copyparty zwróciło nielegalny JSON. ({e}): {snippet}") +} + +// ── OpenRouter ──────────────────────────────────────────────────────── + +pub fn fmt_openrouter_err(status: &dyn std::fmt::Display, text: &str) -> String { + format!("Błąd API OpenRouter. {status}: {text}") +} + +// ── Voice / audio decoding ──────────────────────────────────────────── + +pub const L_VOICE_NO_TRACK: &str = "Brak kawałka"; +pub const L_VOICE_NO_CODEC: &str = "Brak parametrów codec'a audio"; + +pub fn fmt_voice_remove_failed(e: &dyn std::fmt::Display) -> String { + format!("Nie udało się usunąć klienta głosowego: {e}") +} +pub fn fmt_voice_probe_failed(e: &dyn std::fmt::Display) -> String { + format!("Próbkowanie symphonia zawiodło: {e}") +} +pub fn fmt_voice_decoder_failed(e: &dyn std::fmt::Display) -> String { + format!("Nie udało się utworzyć dekodera audio: {e}") +} +pub fn fmt_voice_ytdlp_launch(e: &dyn std::fmt::Display) -> String { + format!("yt-dlp nie mogło się uruchomić: {e}") +} +pub fn fmt_voice_ffmpeg_launch(e: &dyn std::fmt::Display) -> String { + format!("ffmpeg nie mogło się uruchomić: {e}") +} + +// ── Events ──────────────────────────────────────────────────────────── + +pub const L_EV_READY: &str = "ŁUKASZ SPERMA AKTYWACJA"; +pub const L_EV_VOICE_STATE_ERROR: &str = "Błąd stanu głosu"; +pub const L_EV_MESSAGE_HANDLER_ERROR: &str = "Błąd ustanawiacza głosu"; + +// ── Event TTS phrases ───────────────────────────────────────────────── + +pub fn fmt_ev_joined(name: &str) -> String { + format!("{name} sie zespawnił") +} +pub fn fmt_ev_left(name: &str) -> String { + format!("{name} jest chujem") +} +pub fn fmt_ev_deafened(name: &str) -> String { + format!("{name} jest bity przez ojca") +} +pub fn fmt_ev_undeafened(name: &str) -> String { + format!("{name} uciekł od ojca") +} +pub fn fmt_ev_muted(name: &str) -> String { + format!("{name} idzie robić jajecznice") +} +pub fn fmt_ev_unmuted(name: &str) -> String { + format!("{name} zrobił jajecznice") +} + +// ── AI assistant (events/message handler) ───────────────────────────── + +pub const ASSISTANT_SYSTEM_PROMPT: &str = + "Jesteś asystentem. Odpowiadasz po polsku, jesteś pomocny, nie używasz emoji."; + +// ── Convenience helpers for repeated Error::Custom patterns ──────────── + +use crate::error::Error; + +pub fn err_not_in_guild() -> Error { + Error::Custom(L_NOT_IN_GUILD.into()) +} +pub fn err_not_in_voice() -> Error { + Error::Custom(L_NOT_IN_VOICE.into()) +} +pub fn err_trigger_not_found() -> Error { + Error::Custom(L_TRIGGER_NOT_FOUND.into()) +} +pub fn err_bad_channel_type() -> Error { + Error::Custom(L_BAD_CHANNEL_TYPE.into()) +} 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 @@ +use std::collections::HashMap; + +use poise::serenity_prelude as serenity; +use songbird::SerenityInit; +use tracing_subscriber::filter::EnvFilter; + +mod commands; +mod config; +mod copyparty; +mod database; +mod error; +mod events; +mod lang; +mod openrouter; +mod tasks; +mod voice; + +use database::DbPool; + +pub struct Data { + pub db_pool: DbPool, + pub config: config::Config, + pub voice_manager: voice::VoiceManager, + pub copyparty: copyparty::CopypartyClient, + pub http_client: reqwest::Client, + pub volume: std::sync::atomic::AtomicU64, + pub triggers: tokio::sync::Mutex, Vec)>>, + /// Autocomplete lookup, keyed per-user to avoid cross-command collisions. + /// + /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users + /// search the same filename simultaneously, each invocation must resolve its + /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`. + pub ac_cache: tokio::sync::Mutex>, + /// Active music track per guild (only one music source at a time). + pub music_tracks: tokio::sync::Mutex>, +} + +impl Data { + pub async fn reload_triggers(&self) { + let triggers = database::load_all_triggers_with_keywords_and_responses(&self.db_pool) + .await + .unwrap_or_default(); + *self.triggers.lock().await = triggers; + } + + pub async fn ac_stash( + &self, + user_id: serenity::UserId, + key: String, + hit: copyparty::SearchHit, + ) { + self.ac_cache.lock().await.insert((user_id, key), hit); + } + + pub async fn ac_lookup( + &self, + user_id: serenity::UserId, + key: &str, + ) -> Option { + self.ac_cache + .lock() + .await + .get(&(user_id, key.to_string())) + .cloned() + } +} + +#[allow(dead_code)] +type Error = error::Error; + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .parse(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into())) + .unwrap(), + ) + .init(); + + dotenvy::dotenv().ok(); + config::check_env_vars(); + let config = config::Config::from_env(); + + let db_pool = database::init_pool(&config.database_path) + .await + .expect("Failed to initialize database"); + + let http_client = reqwest::Client::new(); + + let copyparty = copyparty::CopypartyClient::new( + config.copyparty_url.clone(), + config.copyparty_url_upload.clone(), + config.copyparty_password.clone(), + ); + + let triggers = database::load_all_triggers_with_keywords_and_responses(&db_pool) + .await + .unwrap_or_default(); + + let commands_list = commands::all_commands(); + let config_for_client = config.clone(); + + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: commands_list, + event_handler: |ctx, event, _framework, user_data| { + Box::pin(async move { + handle_event(ctx, event, user_data).await; + Ok(()) + }) + }, + ..Default::default() + }) + .setup(move |ctx, _ready, framework| { + let config = config.clone(); + let db_pool = db_pool.clone(); + let http_client = http_client.clone(); + let copyparty = copyparty.clone(); + let triggers = triggers.clone(); + Box::pin(async move { + let vm = voice::VoiceManager::new(ctx).await; + + let data = Data { + config, + db_pool, + voice_manager: vm, + copyparty, + http_client, + volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()), + triggers: tokio::sync::Mutex::new(triggers), + ac_cache: tokio::sync::Mutex::new(HashMap::new()), + music_tracks: tokio::sync::Mutex::new(HashMap::new()), + }; + + // Spawn cron scheduler loop. + { + let cron_ctx = crate::tasks::CronContext { + pool: data.db_pool.clone(), + http: ctx.http.clone(), + voice_manager: data.voice_manager.clone(), + copyparty: data.copyparty.clone(), + }; + tokio::spawn(async move { + crate::tasks::cron_scheduler_loop(cron_ctx).await; + }); + } + + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + + events::handle_ready(ctx).await.map_err(|e| { + tracing::error!("on_ready handler: {e}"); + e + })?; + + Ok(data) + }) + }) + .build(); + + let mut client = + serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all()) + .framework(framework) + .register_songbird() + .await + .expect("Error creating client"); + + client.start().await.expect("Error starting client"); +} + +async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) { + match event { + serenity::FullEvent::Ready { + data_about_bot: _, .. + } => {} + serenity::FullEvent::VoiceStateUpdate { old, new } => { + events::handle_voice_state_update(ctx, user_data, old.clone(), new) + .await + .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_VOICE_STATE_ERROR)); + } + serenity::FullEvent::Message { new_message } => { + events::handle_message(ctx, user_data, new_message) + .await + .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_MESSAGE_HANDLER_ERROR)); + } + _ => {} + } +} 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 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::lang; + +pub struct OpenRouterClient { + api_key: String, + model: String, + client: Client, +} + +#[derive(Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + max_tokens: u32, +} + +#[derive(Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Deserialize)] +struct Choice { + message: MessageContent, +} + +#[derive(Deserialize)] +struct MessageContent { + content: String, +} + +impl OpenRouterClient { + pub fn new(api_key: String, model: String) -> Self { + Self { + api_key, + model, + client: Client::new(), + } + } + + pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result { + let body = ChatRequest { + model: self.model.clone(), + messages: vec![ + Message { + role: "system".into(), + content: system_prompt.to_string(), + }, + Message { + role: "user".into(), + content: user_message.to_string(), + }, + ], + max_tokens: 500, + }; + + let resp = self + .client + .post("https://openrouter.ai/api/v1/chat/completions") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(crate::error::Error::Custom(lang::fmt_openrouter_err( + &status, &text, + ))); + } + + let chat_resp: ChatResponse = resp.json().await?; + Ok(chat_resp + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .unwrap_or_default()) + } + + pub async fn chat_with_history( + &self, + system_prompt: &str, + messages: Vec<(String, String)>, // (role, content) pairs + ) -> Result { + let mut msg_vec = vec![Message { + role: "system".into(), + content: system_prompt.to_string(), + }]; + for (role, content) in messages { + msg_vec.push(Message { role, content }); + } + + let body = ChatRequest { + model: self.model.clone(), + messages: msg_vec, + max_tokens: 500, + }; + + let resp = self + .client + .post("https://openrouter.ai/api/v1/chat/completions") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(crate::error::Error::Custom(lang::fmt_openrouter_err( + &status, &text, + ))); + } + + let chat_resp: ChatResponse = resp.json().await?; + Ok(chat_resp + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .unwrap_or_default()) + } +} 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 @@ +use std::sync::Arc; + +use chrono::{Duration, Timelike, Utc}; +use cron::Schedule; +use poise::serenity_prelude as serenity; +use std::str::FromStr; +use tokio::time::sleep; +use tracing::{error, info}; + +use crate::copyparty::CopypartyClient; +use crate::database::{self, DbPool}; +use crate::voice::VoiceManager; + +/// Shared resources the cron scheduler needs. +#[derive(Clone)] +pub struct CronContext { + pub pool: DbPool, + pub http: Arc, + pub voice_manager: VoiceManager, + pub copyparty: CopypartyClient, +} + +/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. +/// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow). +fn parse_cron(raw: &str) -> Result { + let fields: Vec<&str> = raw.split_whitespace().collect(); + let normalized = match fields.len() { + 7 => raw.to_string(), + 6 => format!("{} *", raw), // sec min hour dom month dow → +year + 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year + _ => raw.to_string(), // let the crate reject it + }; + Schedule::from_str(&normalized) +} + +/// Check window: look for cron fires in the last WINDOW_SECONDS. +/// 61s gives 1s overlap with 60s tick to avoid missing exact-boundary fires. +const WINDOW_SECONDS: i64 = 61; +/// Tick interval for the scheduler loop. +const TICK_SECONDS: u64 = 60; + +pub async fn check_and_execute_jobs(ctx: &CronContext) { + let jobs = match database::list_active_cron_jobs(&ctx.pool).await { + Ok(jobs) => jobs, + Err(e) => { + error!("Failed to load cron jobs: {e}"); + return; + } + }; + + let now = Utc::now(); + let window_start = now - Duration::seconds(WINDOW_SECONDS); + + for job in jobs { + let schedule = match parse_cron(&job.cron_expression) { + Ok(s) => s, + Err(e) => { + error!("Invalid cron expression '{}': {e}", job.cron_expression); + continue; + } + }; + + // Check if any fire time fell within the last WINDOW_SECONDS. + let mut has_fired = false; + for next_fire in schedule.after(&window_start) { + if next_fire > now { + break; + } + has_fired = true; + break; // one fire per window is enough + } + + if !has_fired { + continue; + } + + info!("Executing cron job {}", job.id); + match job.r#type.as_str() { + "audio" => execute_audio_job(ctx, &job).await, + "wiadomość" => execute_message_job(ctx, &job).await, + _ => {} + } + } +} + +async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) { + let Some(ref msg_text) = job.message else { + return; + }; + let channel_id = serenity::ChannelId::new(job.channel_id as u64); + if let Err(e) = channel_id.say(&ctx.http, msg_text).await { + error!("Cron job {} failed to send message: {e}", job.id); + } else { + info!("Cron job {} sent message to {}", job.id, job.channel_id); + } +} + +async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { + let Some(ref audio_path) = job.audio_filename else { + error!("Cron audio job {} has no audio_filename", job.id); + return; + }; + + let channel_id = serenity::ChannelId::new(job.channel_id as u64); + + // Resolve guild + build URL concurrently. + let url = ctx.copyparty.build_url(audio_path); + info!("Cron audio job {} streaming via ffmpeg: {url}", job.id); + let guild_channel_fut = channel_id.to_channel(&ctx.http); + let input_fut = crate::voice::ffmpeg_stream_input(&url); + + let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut); + + let guild_id = match channel_result { + Ok(serenity::Channel::Guild(ch)) => ch.guild_id, + other => { + error!( + "Cron audio job {}: channel {} is not a guild voice channel ({:?})", + job.id, + job.channel_id, + other.map(|_| ()) + ); + return; + } + }; + + let input = match input_result { + Ok(i) => i, + Err(e) => { + error!( + "Cron audio job {} ffmpeg stream failed ({url}): {e}", + job.id + ); + return; + } + }; + + // Connect to voice and play. + let call = match ctx.voice_manager.connect(guild_id, channel_id).await { + Ok(c) => c, + Err(e) => { + error!("Cron audio job {} voice connect failed: {e}", job.id); + return; + } + }; + + { + let mut call_lock = call.lock().await; + call_lock.play_input(input); + } + + info!( + "Cron audio job {} playing '{}' (url: {url}) in guild {}", + job.id, audio_path, guild_id + ); +} + +pub async fn cron_scheduler_loop(ctx: CronContext) { + // Align first tick to the start of the next minute, then run every TICK_SECONDS. + let now = Utc::now(); + let next_minute = (now + Duration::minutes(1)) + .with_second(0) + .and_then(|t| t.with_nanosecond(0)) + .unwrap_or(now); + let initial_delay = (next_minute - now).to_std().unwrap_or_default(); + sleep(initial_delay).await; + + loop { + check_and_execute_jobs(&ctx).await; + sleep(std::time::Duration::from_secs(TICK_SECONDS)).await; + } +} 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 @@ +use std::io::Cursor; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use serenity::all::{ChannelId, GuildId}; +use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter}; +use songbird::{Call, Songbird}; +use symphonia::core::audio::sample::Sample; +use symphonia::core::codecs::audio::AudioDecoderOptions; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, TrackType}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +use crate::error::{Error, Result}; +use crate::lang; + +#[derive(Clone)] +pub struct VoiceManager { + pub songbird: Arc, +} + +impl VoiceManager { + pub async fn new(ctx: &serenity::client::Context) -> Self { + Self { + songbird: songbird::get(ctx) + .await + .expect("Songbird not registered via register_songbird()"), + } + } + + pub async fn connect( + &self, + guild_id: GuildId, + channel_id: ChannelId, + ) -> Result>> { + let mut last_err = None; + for attempt in 1..=3 { + match self.songbird.join(guild_id, channel_id).await { + Ok(call) => { + tokio::time::sleep(Duration::from_millis(500)).await; + return Ok(call); + } + Err(e) => { + warn!( + "Voice join attempt {attempt}/3 failed for guild {}: {e}", + guild_id + ); + if let songbird::error::JoinError::Driver(ref driver_err) = e { + warn!("Driver error details: {driver_err:?}"); + } + last_err = Some(e); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + Err(Error::SongbirdJoin(last_err.unwrap())) + } + + pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { + self.songbird + .remove(guild_id) + .await + .map_err(|e| Error::Custom(lang::fmt_voice_remove_failed(&e)))?; + Ok(()) + } + + pub fn get_call(&self, guild_id: GuildId) -> Option>> { + self.songbird.get(guild_id) + } +} + +// ── Audio decoding ───────────────────────────────────────────────────── + +/// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird. +/// Supports mp3, m4a/aac, flac, ogg, wav, opus via symphonia auto-detection. +pub fn decode_audio(data: &[u8]) -> Result { + let mss = MediaSourceStream::new(Box::new(Cursor::new(data.to_vec())), Default::default()); + decode_stream(mss) +} + +fn decode_stream(mss: MediaSourceStream) -> Result { + let hint = Hint::new(); + let fmt_opts = FormatOptions::default(); + let meta_opts = MetadataOptions::default(); + + let mut format = symphonia::default::get_probe() + .probe(&hint, mss, fmt_opts, meta_opts) + .map_err(|e| Error::Custom(lang::fmt_voice_probe_failed(&e)))?; + + let track = format + .default_track(TrackType::Audio) + .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_TRACK.into()))?; + let track_id = track.id; + + let codec_params = track + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_CODEC.into()))?; + + let sample_rate = codec_params.sample_rate.unwrap_or(48000); + let channels = codec_params + .channels + .clone() + .map(|c| c.count() as u32) + .unwrap_or(2); + + let dec_opts = AudioDecoderOptions::default(); + let mut decoder = symphonia::default::get_codecs() + .make_audio_decoder(codec_params, &dec_opts) + .map_err(|e| Error::Custom(lang::fmt_voice_decoder_failed(&e)))?; + + let mut pcm_f32: Vec = Vec::new(); + + loop { + match format.next_packet() { + Ok(Some(packet)) => { + if packet.track_id != track_id { + continue; + } + if let Ok(buf) = decoder.decode(&packet) { + let n = buf.samples_interleaved(); + let start = pcm_f32.len(); + pcm_f32.resize(start + n, f32::MID); + buf.copy_to_slice_interleaved(&mut pcm_f32[start..]); + } + } + Ok(None) => break, + Err(_) => break, + } + } + + let pcm_bytes: Vec = pcm_f32.iter().flat_map(|&s| s.to_le_bytes()).collect(); + let cursor = Cursor::new(pcm_bytes); + let adapter = RawAdapter::new(cursor, sample_rate, channels); + + Ok(adapter.into()) +} + +// ── yt-dlp helper ────────────────────────────────────────────────────── + +/// Download audio via yt-dlp, collect stdout into memory, return the raw bytes. +/// Works for both finite media and live streams (yt-dlp handles the difference). +pub async fn ytdlp_download(url: &str) -> Result> { + let output = tokio::process::Command::new("yt-dlp") + .args(["-f", "bestaudio", "-o", "-", "--no-playlist", url]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_voice_ytdlp_launch(&e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Keep the full yt-dlp trace in the logs for debugging — the user + // only ever sees the short, translated `fmt_ytdlp_stderr` message. + warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); + return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr))); + } + + Ok(output.stdout) +} + +// ── Compression helpers ─────────────────────────────────────────────── + +/// Download a URL into memory via reqwest. +pub async fn download_url(client: &reqwest::Client, url: &str) -> Result> { + let resp = client + .get(url) + .send() + .await + .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?; + let bytes = resp + .bytes() + .await + .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?; + Ok(bytes.to_vec()) +} + +/// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM. +/// If this plays, the streaming pipeline works; bug is in Opus roundtrip. +pub async fn degrade_audio(input: &[u8]) -> Result { + use std::process::Stdio; + + let mut child = tokio::process::Command::new("ffmpeg") + .args([ + "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500", + "pipe:1", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?; + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Custom("stdout not captured".into()))?; + + let owned_input = input.to_vec(); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(&owned_input).await; + }); + + let ffmpeg_child = FfmpegChild { + _child: child, + stdout, + }; + + let async_source = AsyncReadOnlySource::new(ffmpeg_child); + let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); + let raw = RawAdapter::new(adapter, 48000, 2); + Ok(raw.into()) +} + +// ── HTTP streaming via songbird's built-in HttpRequest (legacy) ───── + +/// Create a songbird `Input` that streams audio directly from an HTTP(S) URL. +/// Works for both finite files and infinite Icecast/Shoutcast live streams. +/// +/// Superseded by `ffmpeg_stream_input` for the `/play` command (which needs +/// HLS/playlist/Icecast-metadata handling that symphonia can't provide), but +/// retained for other callers that want lightweight symphonia-based streaming. +#[allow(dead_code)] +pub fn http_stream_input(client: reqwest::Client, url: &str) -> songbird::input::Input { + info!(%url, "streaming via HttpRequest"); + songbird::input::HttpRequest::new(client, url.to_string()).into() +} + +// ── Live streaming via ffmpeg subprocess ───────────────────────────── + +/// Adapter holding an ffmpeg child process and its stdout pipe. +/// +/// Implements `AsyncRead` by delegating to `ChildStdout`. The `Child` is kept +/// alive for the lifetime of the stream and killed on drop via +/// `kill_on_drop(true)`, so stopping playback terminates ffmpeg. +/// +/// `Sync`: the `Child` handle is only touched on `Drop` (a single, one-shot +/// operation); `ChildStdout` is only read by the single `AsyncAdapterSink` +/// task spawned inside `AsyncAdapterStream`. No field is accessed concurrently. +struct FfmpegChild { + _child: tokio::process::Child, + stdout: tokio::process::ChildStdout, +} + +impl tokio::io::AsyncRead for FfmpegChild { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stdout).poll_read(cx, buf) + } +} + +/// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird +/// `Input` of raw f32 PCM at 48 kHz stereo — NO full download. +/// +/// ffmpeg handles every streaming scenario the Python bot relied on it for: +/// Icecast/Shoutcast radio (incl. inline metadata), HLS `.m3u8` (segment +/// fetching), `.m3u`/`.pls` playlists, and direct media files. The +/// `-reconnect` flags let ffmpeg recover from dropped connections. +/// +/// Byte path: `ffmpeg stdout` → `AsyncReadOnlySource` (unseekable async +/// media source) → `AsyncAdapterStream` (async→sync ring buffer) → +/// `RawAdapter` (prepends `SbirdRaw` f32 header) → songbird `Input`. +pub async fn ffmpeg_stream_input(url: &str) -> Result { + use std::process::Stdio; + + let mut child = tokio::process::Command::new("ffmpeg") + .args([ + "-reconnect", + "1", + "-reconnect_streamed", + "1", + "-reconnect_delay_max", + "5", + "-i", + url, + "-vn", + "-f", + "f32le", + "-ar", + "48000", + "-ac", + "2", + "pipe:1", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| Error::Custom(lang::fmt_voice_ffmpeg_launch(&e)))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Custom("ffmpeg stdout was not captured".into()))?; + + // Drain stderr in background so ffmpeg doesn't block; log first 4 KiB on drop. + if let Some(stderr) = child.stderr.take() { + let url_owned = url.to_string(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut buf = Vec::with_capacity(4096); + let mut reader = tokio::io::BufReader::new(stderr); + let _ = reader.read_to_end(&mut buf).await; + let text = String::from_utf8_lossy(&buf); + if !text.is_empty() { + warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end()); + } + }); + } + + let ffmpeg_child = FfmpegChild { + _child: child, + stdout, + }; + + let async_source = AsyncReadOnlySource::new(ffmpeg_child); + let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); + let raw = RawAdapter::new(adapter, 48000, 2); + Ok(raw.into()) +} + +/// Smoke-test an HTTP stream URL: creates a raw reqwest GET, reads the first +/// chunk directly from the async bytes stream (bypasses AsyncAdapterStream). +/// Logs whether data arrives — isolates the hang to songbird's sync adapter. +#[allow(dead_code)] +pub async fn smoke_test_stream(client: reqwest::Client, url: String) { + use futures_util::StreamExt; + + info!(%url, "smoke_test: sending GET"); + let resp = match client.get(url.as_str()).send().await { + Ok(r) => r, + Err(e) => { + warn!(%url, error = %e, "smoke_test: GET FAILED"); + return; + } + }; + info!(%url, status = %resp.status(), "smoke_test: response received"); + + let mut stream = resp.bytes_stream(); + info!(%url, "smoke_test: awaiting first chunk"); + match stream.next().await { + Some(Ok(chunk)) => { + info!(%url, len = chunk.len(), "smoke_test: first chunk arrived"); + } + Some(Err(e)) => { + warn!(%url, error = %e, "smoke_test: chunk error"); + } + None => { + info!(%url, "smoke_test: stream ended immediately (0 bytes)"); + } + } +} + +/// Diagnostic: fetch headers and full body for a URL, logging key metrics. +/// Helps debug symphonia probe EOF issues (Content-Length vs actual bytes). +/// +/// **Warning**: hangs forever on infinite streams (Icecast). Finite URLs only. +#[allow(dead_code)] +pub async fn debug_fetch(client: &reqwest::Client, url: &str) { + match client.get(url).send().await { + Ok(resp) => { + let status = resp.status(); + let headers = resp.headers().clone(); + let content_length = headers + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + let content_type = headers.get("content-type").and_then(|v| v.to_str().ok()); + + info!( + %url, %status, + ?content_length, ?content_type, + "HTTP response headers" + ); + + match resp.bytes().await { + Ok(body) => { + let actual_len = body.len() as u64; + let reported_len = content_length.unwrap_or(0); + info!( + %url, reported = reported_len, actual = actual_len, + delta = actual_len as i64 - reported_len as i64, + "buffered .bytes(): reported vs actual" + ); + } + Err(e) => warn!(%url, error = %e, ".bytes() failed"), + } + } + Err(e) => warn!(%url, error = %e, "HTTP GET failed"), + } +} diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py deleted file mode 100644 index 6d92f55..0000000 --- a/tests/cogs/test_ai.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import discord -import httpx -from conftest import MockApplicationContext - - -@pytest.fixture -def ai_cog(bot): - from cogs.ai import AI - cog = AI(bot) - return cog - - -@pytest.fixture -def mock_ai_response(): - return { - "choices": [ - { - "message": { - "content": "Test response from AI" - } - } - ] - } - - -@pytest.mark.asyncio -async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mock_ai_response): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.json = MagicMock(return_value=mock_ai_response) - - mock_client = MagicMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_response) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: - instance = mock_httpx.return_value.__aenter__.return_value - instance.post = AsyncMock(return_value=mock_response) - with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: - await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) - mock_followup.assert_called_once_with("Test response from AI") - - -@pytest.mark.asyncio -async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.json = MagicMock(return_value={"choices": []}) - - mock_client = MagicMock() - mock_client.post = AsyncMock(return_value=mock_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_response) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: - instance = mock_httpx.return_value.__aenter__.return_value - instance.post = AsyncMock(return_value=mock_response) - with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: - await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) - mock_followup.assert_called_once_with("bot siem zesroł") - - -@pytest.mark.asyncio -async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.json = MagicMock(return_value={}) - - with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: - instance = mock_httpx.return_value.__aenter__.return_value - instance.post = AsyncMock(return_value=mock_response) - with patch('cogs.ai.logger') as mock_logger: - try: - await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) - except KeyError: - pass - assert mock_logger.info.called 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from conftest import MockApplicationContext - - -@pytest.fixture -def assistant_cog(): - with patch('os.getenv', return_value="test_keyword"): - from cogs.assistant import Jarvis - - mock_bot = MagicMock() - mock_bot.user = MagicMock() - mock_bot.user.id = 111 - - cog = Jarvis(mock_bot) - return cog - - -def test_assistant_keyword_default(assistant_cog): - assert assistant_cog.keyword == "test_keyword" - - -@pytest.mark.asyncio -async def test_assistant_on_message_ignored_bot_message(assistant_cog): - mock_author = MagicMock() - mock_author.id = 456 - mock_author.bot = True - - mock_message = MagicMock() - mock_message.author = mock_author - - with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: - await assistant_cog.on_message(mock_message) - mock_assistant.assert_not_called() - - -@pytest.mark.asyncio -async def test_assistant_on_message_mentions_bot(assistant_cog): - mock_message = MagicMock() - mock_message.author = MagicMock() - mock_message.author.id = 123 - - mock_mention = MagicMock() - mock_mention.id = 111 - mock_message.mentions = [mock_mention] - mock_message.content = "test" - - with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: - await assistant_cog.on_message(mock_message) - mock_assistant.assert_called_once_with(mock_message) - - -@pytest.mark.asyncio -async def test_assistant_on_message_keyword_match(assistant_cog): - assistant_cog.keyword = "test" - - mock_message = MagicMock() - mock_message.author = MagicMock() - mock_message.author.id = 123 - - mock_message.mentions = [] - mock_message.content = "test message with test keyword" - - with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: - await assistant_cog.on_message(mock_message) - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import os -import discord -from conftest import MockApplicationContext - - -def test_compression_filetype_video(): - from cogs.compression import filetype - assert filetype("test.mp4") == "Video" - assert filetype("test.avi") == "Video" - - -def test_compression_filetype_audio(): - from cogs.compression import filetype - assert filetype("test.mp3") == "Audio" - assert filetype("test.wav") == "Audio" - - -def test_compression_filetype_image(): - from cogs.compression import filetype - assert filetype("test.jpg") == "Image" - assert filetype("test.png") == "Image" - - -def test_conversion_filetype(): - from cogs.conversion import filetype - assert filetype("test.mp4") == "Video" - assert filetype("test.mp3") == "Audio" - assert filetype("test.jpg") == "Image" - assert filetype("test.xyz") == "Unknown" - - -@pytest.mark.asyncio -async def test_build_conversion_command_video(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.conversion - importlib.reload(cogs.conversion) - from cogs.conversion import Conversion - - cog = Conversion.__new__(Conversion) - cog._baseurl = "https://example.com" - cog._uploadurl = "https://example.com" - - cmd = cog._build_conversion_command("Video", "/tmp/test.mp4", "/tmp/output.mp4", "") - assert cmd is not None - assert "ffmpeg" in cmd - - -@pytest.mark.asyncio -async def test_build_conversion_command_image(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.conversion - importlib.reload(cogs.conversion) - from cogs.conversion import Conversion - - cog = Conversion.__new__(Conversion) - cog._baseurl = "https://example.com" - cog._uploadurl = "https://example.com" - - cmd = cog._build_conversion_command("Image", "/tmp/test.jpg", "/tmp/output.png", "") - assert cmd is not None - assert "magick" in cmd - - -@pytest.mark.asyncio -async def test_build_conversion_command_unknown(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.conversion - importlib.reload(cogs.conversion) - from cogs.conversion import Conversion - - cog = Conversion.__new__(Conversion) - cog._baseurl = "https://example.com" - cog._uploadurl = "https://example.com" - - cmd = cog._build_conversion_command("Unknown", "/tmp/test.xyz", "/tmp/output.abc", "") - assert cmd is None - - -@pytest.mark.asyncio -async def test_compression_get_command_video(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.compression - importlib.reload(cogs.compression) - from cogs.compression import Compression - - cog = Compression.__new__(Compression) - cog.temp_dir = "/tmp" - - cmd = cog._get_compression_command("Video", "/tmp/test.mp4", "/tmp/output.mp4") - assert cmd is not None - assert "ffmpeg" in cmd - - -@pytest.mark.asyncio -async def test_compression_get_command_image(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.compression - importlib.reload(cogs.compression) - from cogs.compression import Compression - - cog = Compression.__new__(Compression) - cog.temp_dir = "/tmp" - - cmd = cog._get_compression_command("Image", "/tmp/test.jpg", "/tmp/output.jpg") - assert cmd is not None - - -@pytest.mark.asyncio -async def test_compression_get_command_unknown(): - with patch('os.getenv', return_value="https://example.com"): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): - import importlib - import cogs.compression - importlib.reload(cogs.compression) - from cogs.compression import Compression - - cog = Compression.__new__(Compression) - cog.temp_dir = "/tmp" - - cmd = cog._get_compression_command("Unknown", "/tmp/test.xyz", "/tmp/output.xyz") - 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 @@ -import pytest -from datetime import datetime -from unittest.mock import MagicMock, patch - -from cogs.cron import Cron -from cogs.utils.database import _ConnectionContextManager - - -@pytest.fixture -def mock_bot(): - bot = MagicMock() - bot.loop = MagicMock() - bot.is_closed = MagicMock(return_value=False) - bot.wait_until_ready = MagicMock() - bot.loop.create_task = MagicMock(return_value=MagicMock()) - return bot - - -def create_cog(mock_bot): - with patch.object(Cron, "__init__", lambda self, bot: None): - with patch("cogs.cron.init_database"): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.task = MagicMock() - return cog - - -class TestLoadTasks: - def test_load_tasks_with_data(self, mock_bot): - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [ - (1, "* * * * *", "audio", 123, 1, None, "test message") - ] - mock_conn.cursor.return_value = mock_cursor - - # Create a context manager mock - mock_ctx = MagicMock() - mock_ctx.__enter__ = MagicMock(return_value=mock_conn) - mock_ctx.__exit__ = MagicMock(return_value=False) - - with patch("cogs.cron.get_connection", return_value=mock_ctx): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.task = MagicMock() - cog.load_tasks() - assert len(cog.scheduled_tasks) == 1 - assert cog.scheduled_tasks[0]["id"] == 1 - -class TestSaveTasks: - def test_save_tasks(self, mock_bot): - cog = create_cog(mock_bot) - cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] - - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - - # Create a context manager mock - mock_ctx = MagicMock() - mock_ctx.__enter__ = MagicMock(return_value=mock_conn) - mock_ctx.__exit__ = MagicMock(return_value=False) - - with patch("cogs.cron.get_connection", return_value=mock_ctx): - cog.save_tasks() - assert len(cog.scheduled_tasks) == 1 - -class TestShouldRunTask: - def test_should_run_task_invalid_cron(self, mock_bot): - cog = create_cog(mock_bot) - task = {"cron_expression": "invalid"} - now = datetime.now() - result = cog.should_run_task(task, now) - assert result is False - - def test_should_run_task_missing_cron_expression(self, mock_bot): - cog = create_cog(mock_bot) - task = {} - now = datetime.now() - result = cog.should_run_task(task, now) - assert result is False - -class TestCogUnload: - def test_cog_unload_cancels_task(self, mock_bot): - with patch.object(Cron, "__init__", lambda self, bot: None): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.task = MagicMock() - - cog.cog_unload() - cog.task.cancel.assert_called_once() - -class TestTaskManagement: - def test_add_task_increments_id(self, mock_bot): - cog = create_cog(mock_bot) - cog.scheduled_tasks = [ - {"id": 1, "cron_expression": "* * * * *"}, - {"id": 2, "cron_expression": "0 * * * *"}, - ] - - new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 - assert new_id == 3 - - def test_find_task_by_id(self, mock_bot): - cog = create_cog(mock_bot) - cog.scheduled_tasks = [ - {"id": 1, "cron_expression": "* * * * *"}, - {"id": 2, "cron_expression": "0 * * * *"}, - ] - - found_task = next((t for t in cog.scheduled_tasks if t["id"] == 2), None) - assert found_task is not None - assert found_task["cron_expression"] == "0 * * * *" - - def test_remove_task_by_index(self, mock_bot): - cog = create_cog(mock_bot) - cog.scheduled_tasks = [ - {"id": 1, "cron_expression": "* * * * *"}, - {"id": 2, "cron_expression": "0 * * * *"}, - ] - - cog.scheduled_tasks.pop(0) - assert len(cog.scheduled_tasks) == 1 - assert cog.scheduled_tasks[0]["id"] == 2 - - def test_toggle_task_active(self, mock_bot): - cog = create_cog(mock_bot) - cog.scheduled_tasks = [{"id": 1, "active": True}] - - cog.scheduled_tasks[0]["active"] = False - assert cog.scheduled_tasks[0]["active"] is False - - cog.scheduled_tasks[0]["active"] = True - assert cog.scheduled_tasks[0]["active"] is True - -class TestTaskTypes: - def test_task_type_audio_aliases(self, mock_bot): - audio_types = ("zagraj_audio", "audio") - for task_type in audio_types: - assert task_type in ("zagraj_audio", "audio") - - def test_task_type_message_aliases(self, mock_bot): - message_types = ("wyślij_wiadomość", "send_message") - for task_type in message_types: - 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 @@ -import pytest -import time -from unittest.mock import MagicMock, AsyncMock, patch - - -@pytest.fixture -def cron_cog(bot): - # Patch create_task to prevent background task creation - with patch.object(bot.loop, 'create_task', return_value=MagicMock()): - from cogs.cron import Cron - cog = Cron(bot) - yield cog - - -@pytest.mark.slow -@pytest.mark.asyncio -async def test_cron_job_execution(cron_cog): - """Test cron job executes when due.""" - cog = cron_cog - - job = { - "id": "test_job", - "cron_expression": "* * * * *", - "type": "message", - "channel_id": 456, - "active": True, - "message": "Test", - } - cog.scheduled_tasks.append(job) - - # Mock the execute_task method - with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec: - # Mock should_run_task to return True - with patch.object(cog, 'should_run_task', return_value=True): - # Test single iteration of schedule_checker logic - for task in cog.scheduled_tasks: - if task.get("active", True) and cog.should_run_task(task, None): - await cog.execute_task(task) - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import discord -from conftest import MockApplicationContext, MockMessage - - -@pytest.fixture -def downloader_cog(bot): - from cogs.downloader import Downloader - cog = Downloader(bot) - return cog - - -@pytest.mark.asyncio -async def test_download_command_success_file(downloader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_file_path = "/tmp/test_video.mp4" - - with patch('cogs.downloader.Downloader.download_standalone', return_value={ - "status": "success", - "type": "file", - "content": mock_file_path, - "filename": "test_video.mp4" - }): - with patch('discord.File', return_value=MagicMock()) as mock_file: - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=False, fformat="mp4", filename_user=None) - mock_respond.assert_called_once() - call_args = mock_respond.call_args - assert 'file' in str(call_args) or mock_file.called - - -@pytest.mark.asyncio -async def test_download_command_success_url(downloader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.downloader.Downloader.download_standalone', return_value={ - "status": "success", - "type": "url", - "content": "https://catbox.2137697.xyz/sperma/test.mp4" - }): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=True, fformat="mp4", filename_user=None) - mock_respond.assert_called_once() - call_args = mock_respond.call_args - assert "pobioron" in str(call_args) - - -@pytest.mark.asyncio -async def test_download_command_error(downloader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.downloader.Downloader.download_standalone', return_value={ - "status": "error", - "type": "error", - "content": "Download failed", - "details": "Connection timeout" - }): - with patch('cogs.downloader.logger') as mock_logger: - await downloader_cog.download.callback(downloader_cog, ctx, url="https://badurl.com", catbox=False, fformat="mp4", filename_user=None) - assert mock_logger.error.called - - -@pytest.mark.asyncio -async def test_message_download_with_url(downloader_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_message = MagicMock() - mock_message.content = "Check this out https://example.com/video" - - with patch('cogs.downloader.Downloader.download_standalone', return_value={ - "status": "success", - "type": "file", - "content": "/tmp/test.mp4" - }): - with patch.object(downloader_cog, 'download', new_callable=AsyncMock) as mock_download: - await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) - mock_download.assert_called_once() - - -@pytest.mark.asyncio -async def test_message_download_no_url(downloader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_message = MagicMock() - mock_message.content = "No URL here" - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) - mock_respond.assert_called_once() - call_args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import MagicMock, AsyncMock, patch - - -@pytest.fixture -def downloader_cog(bot): - from cogs.downloader import Downloader - cog = Downloader(bot) - return cog - - -@pytest.mark.slow -@pytest.mark.asyncio -async def test_download_command_flow(downloader_cog, bot): - """Test download command with URL processing.""" - cog = downloader_cog - - ctx = MagicMock() - ctx.author = MagicMock() - ctx.guild = MagicMock() - ctx.channel = MagicMock() - ctx.respond = AsyncMock() - ctx.defer = AsyncMock() - ctx.followup = AsyncMock() - ctx.voice_client = None - - # Mock the download_standalone static method - mock_response = { - "status": "success", - "type": "url", - "content": "http://example.com/video.mp4" - } - - with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response): - await cog.download.callback(cog, ctx, url="http://youtube.com/test") - - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from conftest import MockApplicationContext - - -@pytest.fixture -def math_cog(bot): - from cogs.math_commands import Math - cog = Math(bot) - return cog - - -@pytest.mark.asyncio -async def test_math_command_valid_expression(math_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_process = MagicMock() - mock_process.returncode = 0 - mock_process.communicate = AsyncMock(return_value=(b"4", b"")) - - with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process) as mock_subprocess: - with patch('cogs.math_commands.re.search', return_value=None): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await math_cog.math.callback(math_cog, ctx, equation="2+2") - mock_subprocess.assert_called_once() - mock_respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_math_command_invalid_expression(math_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.math_commands.re.search', return_value=MagicMock()): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await math_cog.math.callback(math_cog, ctx, equation="eval(1)") - mock_respond.assert_called_once() - args = mock_respond.call_args - assert "Invalid" in str(args) - - -@pytest.mark.asyncio -async def test_math_command_process_error(math_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_process = MagicMock() - mock_process.returncode = 1 - mock_process.communicate = AsyncMock(return_value=(b"", b"Error: invalid expression")) - - with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process): - with patch('cogs.math_commands.re.search', return_value=None): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await math_cog.math.callback(math_cog, ctx, equation="2++") - mock_respond.assert_called_once() - args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import os -from conftest import MockApplicationContext - - -@pytest.mark.asyncio -async def test_validate_voice_channel_not_in_voice(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - mock_ctx = MagicMock() - mock_ctx.author.voice = None - mock_ctx.respond = AsyncMock() - - result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) - - assert result is None - mock_ctx.respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_validate_voice_channel_in_voice(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - mock_channel = MagicMock() - mock_ctx = MagicMock() - mock_ctx.author.voice = MagicMock() - mock_ctx.author.voice.channel = mock_channel - mock_ctx.respond = AsyncMock() - - result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) - - assert result == mock_channel - - -@pytest.mark.asyncio -async def test_prepare_file_source_file_not_found(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") - - assert display_name is None - assert absolute_path is None - - -@pytest.mark.asyncio -async def test_prepare_file_source_absolute_path(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - with patch('os.path.exists', return_value=True): - with patch('os.path.isabs', return_value=True): - display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/tmp/test.mp3") - - assert display_name == "test" - assert absolute_path == "/tmp/test.mp3" - - -@pytest.mark.asyncio -async def test_prepare_source_url(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream") - - assert is_url == True - assert source == "https://example.com/stream" - - -@pytest.mark.asyncio -async def test_prepare_source_local_file(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - with patch('os.path.exists', return_value=True): - source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3") - - assert is_url == False - assert display_name == "test" - - -@pytest.mark.asyncio -async def test_cleanup_temp_file(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - with patch('os.path.exists', return_value=True): - with patch('os.remove'): - await AudioPlaybackManager.cleanup_temp_file("/tmp/test.mp3") - - -@pytest.mark.asyncio -async def test_cleanup_temp_file_not_exists(): - from cogs.voice import VoiceManager as AudioPlaybackManager - - with patch('os.path.exists', return_value=False): - 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 @@ -import pytest -import os -import asyncio -import tempfile -from unittest.mock import AsyncMock, patch, MagicMock -from conftest import MockApplicationContext - - -@pytest.fixture -def play_commands_cog(bot): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - from cogs.music.play_commands import PlayCommands - cog = PlayCommands(bot) - return cog - - -@pytest.fixture -def temp_mp3_file(): - with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: - f.write(b'fake mp3 content') - temp_path = f.name - yield temp_path - if os.path.exists(temp_path): - os.remove(temp_path) - - -@pytest.mark.asyncio -async def test_external_url_pattern_matches_youtube(play_commands_cog): - from cogs.music.play_commands import EXTERNAL_URL_PATTERN - - assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") - assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") - assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") - - -@pytest.mark.asyncio -async def test_external_url_pattern_matches_soundcloud(play_commands_cog): - from cogs.music.play_commands import EXTERNAL_URL_PATTERN - - assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") - assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") - - -@pytest.mark.asyncio -async def test_external_url_pattern_matches_bandcamp(play_commands_cog): - from cogs.music.play_commands import EXTERNAL_URL_PATTERN - - assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") - - -@pytest.mark.asyncio -async def test_external_url_pattern_rejects_copyparty(play_commands_cog): - from cogs.music.play_commands import EXTERNAL_URL_PATTERN - - assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") - assert not EXTERNAL_URL_PATTERN.match("some search query") - - -@pytest.mark.asyncio -async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: - mock_handler.return_value = None - mock_defer = AsyncMock() - ctx.defer = mock_defer - ctx.respond = AsyncMock() - - await play_commands_cog.play.callback( - play_commands_cog, ctx, - melodia="https://youtube.com/watch?v=test", - file=None, compressed=False - ) - - mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") - - -@pytest.mark.asyncio -async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: - mock_handler.return_value = None - mock_defer = AsyncMock() - ctx.defer = mock_defer - ctx.respond = AsyncMock() - - await play_commands_cog.play.callback( - play_commands_cog, ctx, - melodia="some search query", - file=None, compressed=False - ) - - mock_handler.assert_called_once_with(ctx, "some search query", False) - - -@pytest.mark.asyncio -async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): - monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") - monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = '{"fileurl": "https://example.com/file.mp3"}' - mock_response.json = MagicMock(return_value={"fileurl": "https://example.com/file.mp3"}) - - with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post: - await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) - 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 @@ -import pytest -from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock -import sys -sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests') -from conftest import MockApplicationContext - - -@pytest.fixture -def music_cog(bot): - from cogs.music.play_commands import PlayCommands - cog = PlayCommands(bot) - return cog - - -@pytest.mark.slow -@pytest.mark.asyncio -async def test_music_join_stop(music_cog, bot): - """Test music join and stop commands.""" - from cogs.music.join import Join - from cogs.music.stop import Stop - - join_cog = Join(bot) - stop_cog = Stop(bot) - - ctx = MockApplicationContext(bot=bot) - ctx.author.voice = MagicMock() - ctx.author.voice.channel = MagicMock() - ctx.guild = MagicMock() - - # Test join - mock_vm = MagicMock() - mock_vm.connect = AsyncMock() - with patch('cogs.music.join.get_voice_manager', return_value=mock_vm): - with patch.object(ctx, 'respond', new_callable=AsyncMock): - with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): - await join_cog.join.callback(join_cog, ctx) - mock_vm.connect.assert_called_once_with(ctx.author.voice.channel) - - # Test stop - mock_vm_stop = MagicMock() - mock_vm_stop.disconnect = AsyncMock() - with patch('cogs.music.stop.get_voice_manager', return_value=mock_vm_stop): - with patch.object(ctx, 'respond', new_callable=AsyncMock): - with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): - await stop_cog.stop.callback(stop_cog, ctx) - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from conftest import MockApplicationContext - - -@pytest.fixture -def join_cog(bot): - from cogs.music.join import Join - cog = Join(bot) - return cog - - -@pytest.fixture -def stop_cog(bot): - from cogs.music.stop import Stop - cog = Stop(bot) - return cog - - -@pytest.mark.asyncio -async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - mock_voice_channel = MagicMock() - ctx.author.voice = MagicMock() - ctx.author.voice.channel = mock_voice_channel - - with patch('cogs.music.join.get_voice_manager') as mock_get_vm: - mock_vm = MagicMock() - mock_vm.connect = AsyncMock() - mock_get_vm.return_value = mock_vm - - with patch.object(ctx, 'respond', new_callable=AsyncMock): - with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): - await join_cog.join.callback(join_cog, ctx) - mock_vm.connect.assert_called_once_with(mock_voice_channel) - - -@pytest.mark.asyncio -async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - with patch('cogs.music.stop.get_voice_manager') as mock_get_vm: - mock_vm = MagicMock() - mock_vm.disconnect = AsyncMock() - mock_get_vm.return_value = mock_vm - - with patch.object(ctx, 'respond', new_callable=AsyncMock): - with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): - await stop_cog.stop.callback(stop_cog, ctx) - - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import os -from conftest import MockApplicationContext - - -@pytest.fixture -def play_commands_cog(bot): - with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): - from cogs.music.play_commands import PlayCommands - cog = PlayCommands(bot) - return cog - - -@pytest.fixture -def loop_cog(bot): - from cogs.music.loop import Loop - cog = Loop(bot) - return cog - - -@pytest.mark.asyncio -async def test_create_search_embed_empty(play_commands_cog): - embed, view = await play_commands_cog._create_search_embed([]) - - assert embed.title == "Wyniki wyszukiwania" - assert len(view.children) == 0 - - -@pytest.mark.asyncio -async def test_create_search_embed_with_candidates(play_commands_cog): - candidates = [ - "https://example.com/test1.mp3", - "https://example.com/test2.mp3" - ] - - embed, view = await play_commands_cog._create_search_embed(candidates) - - assert embed.title == "Wyniki wyszukiwania" - assert len(embed.fields) == 2 - - -@pytest.mark.asyncio -async def test_play_command_no_file_or_search(play_commands_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await play_commands_cog.play.callback(play_commands_cog, ctx, melodia=None, file=None, compressed=False) - mock_respond.assert_called_once() - args = mock_respond.call_args - assert "Musisz podać" in str(args) - - -@pytest.mark.asyncio -async def test_play_command_both_file_and_search(play_commands_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_file = MagicMock() - mock_file.filename = "test.mp3" - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await play_commands_cog.play.callback(play_commands_cog, ctx, melodia="test", file=mock_file, compressed=False) - mock_respond.assert_called_once() - args = mock_respond.call_args - assert "nie oba" in str(args) or "LUB" in str(args) - - -def test_loop_cog_is_looping_initial(loop_cog): - assert loop_cog.is_looping(123) == False - - -def test_loop_cog_set_looping(loop_cog): - loop_cog.set_looping(123, True) - assert loop_cog.is_looping(123) == True - - -@pytest.mark.asyncio -async def test_loop_command_toggle_on(loop_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - ctx.guild.id = 123 - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await loop_cog.loop.callback(loop_cog, ctx) - mock_respond.assert_called_once() - args = mock_respond.call_args - assert "włączony" in str(args) - - -@pytest.mark.asyncio -async def test_loop_command_toggle_off(loop_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - ctx.guild.id = 456 - - loop_cog.set_looping(456, True) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await loop_cog.loop.callback(loop_cog, ctx) - mock_respond.assert_called_once() - args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock, mock_open -import os -from cogs.qr import QR -from conftest import MockApplicationContext - - -@pytest.fixture -def qr_cog(bot): - cog = QR(bot) - return cog - - -@pytest.fixture -def mock_temp_file(): - with patch('tempfile.NamedTemporaryFile') as mock: - mock_file = MagicMock() - mock_file.name = "/tmp/test_qr.png" - mock.return_value = mock_file - yield mock_file - - -@pytest.mark.asyncio -async def test_qr_generate_success(qr_cog, mock_channel, mock_guild, bot, mock_temp_file): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.qr.QR._generate_qr') as mock_generate: - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - with patch('os.path.exists', return_value=True): - with patch('os.unlink'): - await qr_cog.qr.callback(qr_cog, ctx, text="https://example.com") - mock_generate.assert_called_once() - mock_respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_qr_generate_empty_text(qr_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.qr.QR._generate_qr'): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - with patch('os.path.exists', return_value=False): - await qr_cog.qr.callback(qr_cog, ctx, text="") - mock_respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_qr_decode_not_available(qr_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.url = "https://example.com/qr.png" - - with patch('cogs.qr._ensure_qr', return_value=False): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) - mock_respond.assert_called_once() - args = mock_respond.call_args - assert "nie jest dostępne" in str(args) or "not available" in str(args).lower() - - -@pytest.mark.asyncio -async def test_qr_decode_download_failure(qr_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.url = "https://example.com/qr.png" - - with patch('cogs.qr._ensure_qr', return_value=True): - with patch.object(qr_cog, '_download_image', return_value=None): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) - mock_respond.assert_called_once() - args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch -from cogs.say import Say -from conftest import MockApplicationContext - - -@pytest.fixture -def say_cog(bot): - cog = Say(bot) - return cog - - -@pytest.mark.asyncio -async def test_say_command(say_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: - await say_cog.say.callback(say_cog, ctx, "Hello World") - mock_send.assert_called_once_with(content="Hello World") - - -@pytest.mark.asyncio -async def test_say_command_empty_text(say_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: - await say_cog.say.callback(say_cog, ctx, "") - mock_send.assert_called_once_with(content="") - - -@pytest.mark.asyncio -async def test_say_command_long_text(say_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - long_text = "A" * 2000 - - with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: - await say_cog.say.callback(say_cog, ctx, long_text) - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import discord -from conftest import MockApplicationContext - - -@pytest.fixture -def search_cog(bot): - from cogs.search import Search - cog = Search(bot) - return cog - - -@pytest.mark.asyncio -async def test_search_command_success(search_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - search_cog._cached_results[mock_user.id] = { - "testfile.mp4": "https://example.com/files/testfile.mp4" - } - - with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: - await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") - mock_followup.assert_called_once_with("https://example.com/files/testfile.mp4") - assert mock_user.id not in search_cog._cached_results - - -@pytest.mark.asyncio -async def test_search_command_no_cache(search_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: - await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") - mock_followup.assert_called_once_with("Search results expired. Please search again.") - - -@pytest.mark.asyncio -async def test_search_command_file_not_found(search_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - search_cog._cached_results[mock_user.id] = { - "testfile.mp4": "https://example.com/files/testfile.mp4" - } - - with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: - await search_cog.search.callback(search_cog, ctx, filename="nonexistent.mp4") - mock_followup.assert_called_once_with("File 'nonexistent.mp4' not found.") - - -@pytest.mark.asyncio -async def test_search_command_empty_filename(search_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - with patch.object(ctx, 'reply', new_callable=AsyncMock) as mock_reply: - await search_cog.search.callback(search_cog, ctx, filename="") - mock_reply.assert_called_once_with("Please select a file from the autocomplete options.") - - -@pytest.mark.asyncio -async def test_file_autocomplete(search_cog, mock_channel, mock_guild, bot, mock_user): - mock_autocomplete_ctx = MagicMock() - mock_autocomplete_ctx.value = "test" - mock_autocomplete_ctx.interaction = MagicMock() - mock_autocomplete_ctx.interaction.user = mock_user - - with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: - mock_search.return_value = [ - "https://example.com/files/test1.mp4", - "https://example.com/files/test2.mp4" - ] - - choices = await search_cog.file_autocomplete(mock_autocomplete_ctx) - - assert len(choices) == 2 - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import sqlite3 -from conftest import MockApplicationContext - - -@pytest.fixture -def trigger_cog(bot): - with patch('cogs.trigger.get_connection') as mock_conn: - mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock()) - mock_conn.return_value.__exit__ = MagicMock(return_value=False) - from cogs.trigger import Trigger - cog = Trigger(bot) - return cog - - -@pytest.fixture -def mock_db_connection(): - with patch('cogs.trigger.get_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) - yield mock_conn, mock_cursor - - -@pytest.mark.asyncio -async def test_trigger_create_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_conn, mock_cursor = mock_db_connection - mock_cursor.fetchone.return_value = None - - with patch('cogs.trigger.get_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await trigger_cog.create.callback(trigger_cog, ctx, name="test_trigger") - mock_respond.assert_called_once_with("trigger added") - - -@pytest.mark.asyncio -async def test_trigger_add_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.trigger.get_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = (1,) # trigger exists - mock_conn.cursor.return_value = mock_cursor - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await trigger_cog.add.callback(trigger_cog, ctx, name="test_trigger", trigger="hello", response="Hi there!") - mock_respond.assert_called_once_with("trigger updated") - - -@pytest.mark.asyncio -async def test_trigger_add_command_not_found(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.trigger.get_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = None # trigger not found - mock_conn.cursor.return_value = mock_cursor - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await trigger_cog.add.callback(trigger_cog, ctx, name="nonexistent", trigger="hello", response="Hi") - mock_respond.assert_called_once_with("Trigger not found.") - - -@pytest.mark.asyncio -async def test_trigger_delete_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch('cogs.trigger.get_connection') as mock_get_conn: - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = (1,) # trigger exists - mock_conn.cursor.return_value = mock_cursor - mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await trigger_cog.delete.callback(trigger_cog, ctx, name="test_trigger") - mock_respond.assert_called_once_with("trigger deleted") - - -@pytest.mark.asyncio -async def test_trigger_help_command(trigger_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await trigger_cog.help.callback(trigger_cog, ctx) - mock_respond.assert_called_once() - args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import MagicMock, AsyncMock, patch -from cogs.trigger import Trigger - - -@pytest.fixture -def trigger_cog(bot): - from cogs.utils.database import init_database - init_database() - from cogs.trigger import Trigger - cog = Trigger(bot) - return cog - - -@pytest.mark.slow -@pytest.mark.asyncio -async def test_trigger_response_flow(trigger_cog): - """Test trigger creation and response in a simulated message flow.""" - cog = trigger_cog - - ctx = MagicMock() - ctx.author = MagicMock() - ctx.author.id = 123 - ctx.guild = MagicMock() - ctx.guild.id = 456 - ctx.respond = AsyncMock() - - # Create a trigger using the create command - await cog.create.callback(cog, ctx, name="test_trigger") - - # Add a keyword and response - await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world") - - # Verify trigger was created - check DB - from cogs.utils.database import get_connection - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",)) - result = c.fetchone() - assert result is not None - - # Simulate a message that should trigger - message = MagicMock() - message.author = ctx.author - message.author.bot = False - message.guild = ctx.guild - message.content = "say hello to me" - message.channel.send = AsyncMock() # Make send awaitable - - # Check if trigger matches using the static method - await Trigger.trigger(message) - - # Verify message.channel.send was called with the response - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import discord -from conftest import MockApplicationContext - - -@pytest.fixture -def tts_cog(bot): - from cogs.tts import TTS - cog = TTS(bot) - return cog - - -@pytest.mark.asyncio -async def test_tts_file_output(tts_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - "/tmp/test_tts.mp3" - - with patch('cogs.tts.gTTS') as _: - with patch('os.path.exists', return_value=True): - with patch('os.unlink'): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - with patch('cogs.tts.get_volume', return_value=1.0): - await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") - mock_respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_tts_voice_output_not_in_channel(tts_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - ctx.author.voice = None - - with patch('cogs.tts.gTTS'): - with patch('os.path.exists', return_value=True): - with patch('os.unlink'): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - with patch('cogs.tts.get_volume', return_value=1.0): - await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="vc") - - -@pytest.mark.asyncio -async def test_tts_error_handling(tts_cog, mock_channel, mock_guild, bot, mock_user): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - ctx.user = mock_user - ctx.author = mock_user - - with patch('cogs.tts.gTTS', side_effect=Exception("TTS Error")): - with patch('os.path.exists', return_value=False): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") - mock_respond.assert_called_once() - args = mock_respond.call_args - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import os -import discord -from conftest import MockApplicationContext, MockMessage, MockUser - - -@pytest.fixture -def uploader_cog(bot): - with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'): - from cogs.uploader import Uploader - cog = Uploader(bot) - return cog - - -@pytest.mark.asyncio -async def test_file_autocomplete(uploader_cog): - mock_autocomplete_ctx = MagicMock() - mock_autocomplete_ctx.value = "test" - mock_interaction = MagicMock() - mock_user = MockUser(id=12345) - mock_interaction.user = mock_user - mock_autocomplete_ctx.interaction = mock_interaction - - with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: - mock_search.return_value = [ - "https://copyparty.local/testfile1.mp3", - "https://copyparty.local/testfile2.mp3" - ] - result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) - assert len(result) == 2 - assert result[0].name == "testfile1.mp3" - - -@pytest.mark.asyncio -async def test_file_autocomplete_empty_query(uploader_cog): - mock_autocomplete_ctx = MagicMock() - mock_autocomplete_ctx.value = "" - mock_interaction = MagicMock() - mock_interaction.user = MockUser(id=12345) - mock_autocomplete_ctx.interaction = mock_interaction - - with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: - result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) - mock_search.assert_called_once_with("", max_results=25) - - -@pytest.mark.asyncio -async def test_download_from_url_success(uploader_cog): - with patch('requests.get') as mock_get: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = b"test file content" - mock_get.return_value = mock_response - - mock_file = MagicMock() - mock_file.write = AsyncMock() - - mock_cm = AsyncMock() - mock_cm.__aenter__ = AsyncMock(return_value=mock_file) - mock_cm.__aexit__ = AsyncMock(return_value=None) - - with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): - result = await uploader_cog._download_from_url("https://copyparty.local/file.txt") - - assert result is not None - assert "file.txt" in result - mock_file.write.assert_called_once_with(b"test file content") - - -@pytest.mark.asyncio -async def test_download_from_url_failure(uploader_cog): - with patch('requests.get') as mock_get: - mock_response = MagicMock() - mock_response.status_code = 404 - mock_get.return_value = mock_response - - result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt") - - assert result is None - - -@pytest.mark.asyncio -async def test_upload_to_copyparty_success(uploader_cog): - mock_file = MagicMock() - mock_file.read = AsyncMock(return_value=b"test content") - - mock_cm = AsyncMock() - mock_cm.__aenter__ = AsyncMock(return_value=mock_file) - mock_cm.__aexit__ = AsyncMock(return_value=None) - - with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): - with patch('requests.post') as mock_post: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"} - mock_post.return_value = mock_response - - with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}): - result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") - - assert result == "https://copyparty.local/uploaded.txt" - - -@pytest.mark.asyncio -async def test_upload_to_copyparty_failure(uploader_cog): - mock_file = MagicMock() - mock_file.read = AsyncMock(return_value=b"test content") - - mock_cm = AsyncMock() - mock_cm.__aenter__ = AsyncMock(return_value=mock_file) - mock_cm.__aexit__ = AsyncMock(return_value=None) - - with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): - with patch('requests.post') as mock_post: - mock_response = MagicMock() - mock_response.status_code = 500 - mock_post.return_value = mock_response - - result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") - - assert result is None - - -@pytest.mark.asyncio -async def test_upload_file_no_url(uploader_cog): - uploader_cog._uploadurl = None - - result = await uploader_cog._upload_file("/tmp/test.txt") - - assert result is None - - -@pytest.mark.asyncio -async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_message = MagicMock() - mock_message.attachments = [] - - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) - mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True) - - -@pytest.mark.asyncio -async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.filename = "test.txt" - mock_attachment.save = AsyncMock() - - mock_message = MagicMock() - mock_message.attachments = [mock_attachment] - - with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: - mock_upload.return_value = "https://copyparty.local/uploaded.txt" - - with patch('os.path.exists', return_value=True): - with patch('os.remove'): - with patch.object(ctx, 'defer', new_callable=AsyncMock): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) - mock_respond.assert_called_once() - assert "Wysłano" in str(mock_respond.call_args) - - -@pytest.mark.asyncio -async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.filename = "test.txt" - mock_attachment.save = AsyncMock() - - mock_message = MagicMock() - mock_message.attachments = [mock_attachment] - - with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: - mock_upload.return_value = None - - with patch('os.path.exists', return_value=True): - with patch('os.remove'): - with patch.object(ctx, 'defer', new_callable=AsyncMock): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) - mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True) - - -@pytest.mark.asyncio -async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.filename = "test.txt" - mock_attachment.save = AsyncMock() - - with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: - mock_upload.return_value = "https://copyparty.local/uploaded.txt" - - with patch('os.path.exists', return_value=True): - with patch('os.remove'): - with patch.object(ctx, 'defer', new_callable=AsyncMock): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) - mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt") - - -@pytest.mark.asyncio -async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_attachment = MagicMock() - mock_attachment.filename = "test.txt" - mock_attachment.save = AsyncMock() - - with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: - mock_upload.return_value = None - - with patch('os.path.exists', return_value=True): - with patch('os.remove'): - with patch.object(ctx, 'defer', new_callable=AsyncMock): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from conftest import MockApplicationContext - - -def test_format_duration(): - from cogs.vclogs import format_duration - - assert format_duration(30) == "30s" - assert format_duration(90) == "1m 30s" - assert format_duration(3661) == "1h 1m 1s" - - -def test_load_activity_data(): - with patch("cogs.vclogs.get_connection") as mock_conn: - mock_cursor = MagicMock() - mock_conn.return_value.cursor.return_value = mock_cursor - mock_conn.return_value.close.return_value = None - mock_cursor.fetchall.return_value = [] - - from cogs.vclogs import load_activity_data - data = load_activity_data() - assert isinstance(data, dict) - - -def test_save_activity_data(): - with patch("cogs.vclogs.get_connection") as mock_conn: - mock_cursor = MagicMock() - mock_conn.return_value.cursor.return_value = mock_cursor - mock_conn.return_value.commit.return_value = None - mock_conn.return_value.close.return_value = None - - from cogs.vclogs import save_activity_data - save_activity_data({"123": {"active": True}}) - - -@pytest.fixture -def vclogs_cog(bot): - with patch('cogs.vclogs.get_voice_manager'): - with patch('cogs.vclogs.get_volume', return_value=1.0): - with patch('cogs.vclogs.save_settings'): - from cogs.vclogs import VcLogs - cog = VcLogs(bot) - return cog - - -@pytest.mark.asyncio -async def test_handle_state_change_deaf_true(vclogs_cog): - mock_member = MagicMock() - mock_member.display_name = "TestUser" - mock_channel = MagicMock() - - tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', True, None) - - assert "jest bity przez ojca" in tts_text - - -@pytest.mark.asyncio -async def test_handle_state_change_deaf_false(vclogs_cog): - mock_member = MagicMock() - mock_member.display_name = "TestUser" - mock_channel = MagicMock() - - tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', False, None) - - assert "uciek od ojca" in tts_text - - -@pytest.mark.asyncio -async def test_handle_state_change_mute_true(vclogs_cog): - mock_member = MagicMock() - mock_member.display_name = "TestUser" - mock_channel = MagicMock() - - tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', True, None) - - assert "idzie robić jajecznice" in tts_text - - -@pytest.mark.asyncio -async def test_handle_state_change_mute_false(vclogs_cog): - mock_member = MagicMock() - mock_member.display_name = "TestUser" - mock_channel = MagicMock() - - tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) - - 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 @@ -import pytest -import asyncio -from unittest.mock import MagicMock, AsyncMock, patch - - -@pytest.fixture -def vclogs_cog(bot): - from cogs.vclogs import VcLogs - cog = VcLogs(bot) - return cog - - -@pytest.mark.slow -@pytest.mark.asyncio -async def test_full_voice_session_tracking(vclogs_cog, monkeypatch): - """Test complete voice session with AFK transitions using real time sleeps.""" - cog = vclogs_cog - - # Set up environment - monkeypatch.setenv("STATUS_CHANNEL_ID", "123456") - - member = MagicMock() - member.id = 123 - member.guild.id = 456 - member.bot = False - member.display_name = "TestUser" - member.guild.get_member.return_value = member - - # Create a mock log channel - log_channel = MagicMock() - log_channel.send = AsyncMock(return_value=MagicMock()) - - # Mock bot.get_channel to return our log channel - with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel): - # Join voice - before = MagicMock() - before.channel = None - after_join = MagicMock() - after_join.channel = MagicMock() - after_join.channel.id = 789 - after_join.channel.name = "Test Voice" - after_join.self_deaf = False - after_join.self_mute = False - after_join.deaf = False - after_join.mute = False - await cog.on_voice_state_update(member, before, after_join) - - await asyncio.sleep(2) # Simulate 2s active - - # Go AFK (self_deaf) - before_afk = after_join - after_afk = MagicMock() - after_afk.channel = before_afk.channel - after_afk.self_deaf = True - after_afk.self_mute = False - after_afk.deaf = True - after_afk.mute = False - await cog.on_voice_state_update(member, before_afk, after_afk) - - await asyncio.sleep(3) # Simulate 3s AFK - - # Return from AFK - before_back = after_afk - after_back = MagicMock() - after_back.channel = before_back.channel - after_back.self_deaf = False - after_back.self_mute = False - after_back.deaf = False - after_back.mute = False - await cog.on_voice_state_update(member, before_back, after_back) - - await asyncio.sleep(1) # Simulate 1s active - - # Leave voice - before_leave = after_back - after_leave = MagicMock() - after_leave.channel = None - after_leave.self_deaf = False - after_leave.self_mute = False - await cog.on_voice_state_update(member, before_leave, after_leave) - - # Verify activity was saved (check DB) - from cogs.vclogs import load_activity_data - activity_data = load_activity_data() - # After leaving, data should be deleted, so check the log channel was called - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import asyncio -from conftest import MockApplicationContext - - -@pytest.mark.asyncio -async def test_get_voice_manager(): - from cogs.voice import get_voice_manager, VoiceManager - - mock_bot = MagicMock() - mock_bot.user = MagicMock() - mock_bot.user.id = 12345 - - manager1 = get_voice_manager(mock_bot) - manager2 = get_voice_manager(mock_bot) - - assert isinstance(manager1, VoiceManager) - assert manager1 is manager2 - - -@pytest.mark.asyncio -async def test_voice_manager_join_channel_not_found(): - from cogs.voice import VoiceManager - - mock_bot = MagicMock() - mock_bot.get_channel.return_value = None - - manager = VoiceManager(mock_bot) - result = await manager.join_by_channel_id(999) - - assert result is None - - -@pytest.mark.asyncio -async def test_voice_manager_join_invalid_channel(): - from cogs.voice import VoiceManager - - mock_bot = MagicMock() - mock_channel = MagicMock() - mock_bot.get_channel.return_value = mock_channel - - manager = VoiceManager(mock_bot) - result = await manager.join_by_channel_id(999) - - assert result is None - - -@pytest.mark.asyncio -async def test_voice_manager_get_voice_status_not_connected(): - from cogs.voice import VoiceManager - - mock_bot = MagicMock() - mock_guild = MagicMock() - - with patch('cogs.voice.VoiceManager.get_client', return_value=None): - manager = VoiceManager(mock_bot) - voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) - - assert voice_client is None - assert is_connected == False - assert is_playing == False - - -@pytest.mark.asyncio -async def test_voice_manager_stop(): - from cogs.voice import VoiceManager - - mock_bot = MagicMock() - mock_guild = MagicMock() - mock_voice_client = MagicMock() - mock_voice_client.disconnect = AsyncMock() - - with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client): - manager = VoiceManager(mock_bot) - await manager.stop(mock_guild) - - mock_voice_client.stop.assert_called_once() - 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 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from cogs.web import Web -from conftest import MockApplicationContext - - -@pytest.fixture -def web_cog(bot): - cog = Web(bot) - return cog - - -@pytest.mark.asyncio -async def test_web_command_success(web_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = ''' - - Test Result - Test Result 2 - - ''' - - with patch('cogs.web.requests.get', return_value=mock_response) as mock_get: - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await web_cog.web.callback(web_cog, ctx, query="test query") - mock_get.assert_called_once() - mock_respond.assert_called_once() - - -@pytest.mark.asyncio -async def test_web_command_no_results(web_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = '' - - with patch('cogs.web.requests.get', return_value=mock_response): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await web_cog.web.callback(web_cog, ctx, query="test") - mock_respond.assert_called_once_with(content="No results found.", ephemeral=True) - - -@pytest.mark.asyncio -async def test_web_command_http_error(web_cog, mock_channel, mock_guild, bot): - ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) - - mock_response = MagicMock() - mock_response.status_code = 500 - - with patch('cogs.web.requests.get', return_value=mock_response): - with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: - await web_cog.web.callback(web_cog, ctx, query="test") - 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 @@ -import asyncio -import os -import sys -import tempfile -from unittest.mock import AsyncMock, MagicMock -import pytest - -import discord -from discord.ext import commands - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - - -@pytest.fixture(autouse=True, scope="function") -def setup_test_env(monkeypatch): - test_db = os.path.join(tempfile.gettempdir(), "test_senator.db") - if os.path.exists(test_db): - os.remove(test_db) - monkeypatch.setenv("SENATOR_DB_PATH", test_db) - reload_database() - yield - if os.path.exists(test_db): - os.remove(test_db) - - -def reload_database(): - import cogs.utils.database as db_module - db_module._initialized = False - - -class MockUser: - def __init__(self, id=12345, name="testuser", discriminator="0001"): - self.id = id - self.name = name - self.discriminator = discriminator - self.mention = f"@{name}" - self.display_name = name - self.avatar = None - self.bot = False - self.guild = None - self._state = MagicMock() - - def __str__(self): - return f"{self.name}#{self.discriminator}" - - -class MockChannel: - def __init__(self, id=11111, name="test-channel", type=discord.ChannelType.text): - self.id = id - self.name = name - self.type = type - self.mention = f"#{name}" - self.guild = None - self.category = None - self._state = MagicMock() - - def send(self, *args, **kwargs): - return MagicMock() - - -class MockTextChannel(MockChannel): - def __init__(self, id=11111, name="test-channel"): - super().__init__(id, name, discord.ChannelType.text) - - -class MockVoiceChannel(MockChannel): - def __init__(self, id=11112, name="test-voice"): - super().__init__(id, name, discord.ChannelType.voice) - self.voice_states = {} - - -class MockGuild: - def __init__(self, id=99999, name="Test Server"): - self.id = id - self.name = name - self.channels = [] - self.roles = [] - self.emojis = [] - self.me = MockUser(id=111111, name="Sen Bot") - self._state = MagicMock() - self._default_role = None - - def get_channel(self, channel_id): - for ch in self.channels: - if ch.id == channel_id: - return ch - return None - - def get_role(self, role_id): - for role in self.roles: - if role.id == role_id: - return role - return None - - def get_member(self, member_id): - return None - - -class MockMessage: - def __init__(self, id=22222, content="test message", author=None, channel=None, guild=None): - self.id = id - self.content = content - self.author = author or MockUser() - self.channel = channel or MockChannel() - self.guild = guild - self.attachments = [] - self.embeds = [] - self.jump_url = "https://discord.com/channels/..." - self.created_at = None - - async def reply(self, *args, **kwargs): - return MagicMock() - # Stub for reply - kept async to match Discord interface signature - - -class MockInteraction: - def __init__(self, user=None, channel=None, guild=None): - self.user = user or MockUser() - self.channel = channel or MockChannel() - self.guild = guild or MockGuild() - self.message = None - self.response = AsyncMock() - self.followup = AsyncMock() - self.client = None - self.id = 33333 - self.token = "test_token" - self._state = MagicMock() - - async def defer(self, *args, **kwargs): - pass - - async def respond(self, *args, **kwargs): - pass - - async def delete_original_response(self, *args, **kwargs): - pass - - async def send(self, *args, **kwargs): - return MagicMock() - - -# Stub for Discord interaction - test implementation only -class MockApplicationContext: - def __init__(self, bot=None, interaction=None, user=None, channel=None, guild=None): - self.bot = bot - self.interaction = interaction or MockInteraction(user=user, channel=channel, guild=guild) - self.user = user or MockUser() - self.channel = channel or MockChannel() - self.guild = guild or MockGuild() - self.message = None - self.response = AsyncMock() - self.followup = AsyncMock() - self.client = self.bot - self.id = 33333 - self.token = "test_token" - self._state = self.interaction._state - self.author = self.user - - async def defer(self, *args, **kwargs): - pass - # Stub for defer - tests don't verify interaction state - - async def respond(self, *args, **kwargs): - pass - # Stub for respond - tests mock the response - - async def delete_original_response(self, *args, **kwargs): - pass - # Stub for delete_original_response - not used in tests - - async def send(self, *args, **kwargs): - return MagicMock() - # Stub for send - not used in tests - - async def reply(self, *args, **kwargs): - return MagicMock() - # Stub for reply - not used in tests - - async def send_followup(self, *args, **kwargs): - return MagicMock() - # Stub for send_followup - not used in tests - - -@pytest.fixture -def mock_guild(): - return MockGuild() - - -@pytest.fixture -def mock_channel(mock_guild): - channel = MockTextChannel() - channel.guild = mock_guild - return channel - - -@pytest.fixture -def mock_voice_channel(mock_guild): - channel = MockVoiceChannel() - channel.guild = mock_guild - return channel - - -@pytest.fixture -def mock_user(): - return MockUser() - - -@pytest.fixture -def mock_member(mock_guild): - member = MockUser() - member.guild = mock_guild - return member - - -@pytest.fixture -def mock_message(mock_channel, mock_user, mock_guild): - return MockMessage(channel=mock_channel, author=mock_user, guild=mock_guild) - - -@pytest.fixture -def mock_interaction(mock_channel, mock_user, mock_guild): - return MockInteraction(user=mock_user, channel=mock_channel, guild=mock_guild) - - -@pytest.fixture -def bot(): - intents = discord.Intents.all() - return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop()) -- cgit v1.2.3