aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.env.example2
-rw-r--r--.gitignore5
-rw-r--r--AGENTS.md70
-rw-r--r--Cargo.lock6967
-rw-r--r--Cargo.toml58
-rw-r--r--README.md159
-rw-r--r--cogs/ai.py78
-rw-r--r--cogs/assistant.py130
-rw-r--r--cogs/compression.py306
-rw-r--r--cogs/conversion.py245
-rw-r--r--cogs/cron.py403
-rw-r--r--cogs/downloader.py234
-rw-r--r--cogs/math_commands.py83
-rw-r--r--cogs/music/join.py29
-rw-r--r--cogs/music/loop.py40
-rw-r--r--cogs/music/media_processing.py135
-rw-r--r--cogs/music/play_commands.py320
-rw-r--r--cogs/music/stop.py29
-rw-r--r--cogs/qr.py154
-rw-r--r--cogs/say.py28
-rw-r--r--cogs/search.py73
-rw-r--r--cogs/trigger.py260
-rw-r--r--cogs/tts.py111
-rw-r--r--cogs/uploader.py156
-rw-r--r--cogs/utils/database.py126
-rw-r--r--cogs/utils/file_search.py167
-rw-r--r--cogs/utils/settings.py114
-rw-r--r--cogs/utils/temp.py18
-rw-r--r--cogs/utils/url_helper.py21
-rw-r--r--cogs/vclogs.py316
-rw-r--r--cogs/voice.py444
-rw-r--r--cogs/web.py90
-rw-r--r--main.py96
-rw-r--r--pyrightconfig.json19
-rw-r--r--pytest.ini9
-rw-r--r--requirements-dev.txt18
-rw-r--r--requirements.txt15
-rw-r--r--scripts/bump_reqs.py50
-rw-r--r--src/commands/ai.rs76
-rw-r--r--src/commands/autocomplete.rs128
-rw-r--r--src/commands/cron.rs155
-rw-r--r--src/commands/math.rs77
-rw-r--r--src/commands/mod.rs26
-rw-r--r--src/commands/music.rs414
-rw-r--r--src/commands/qr.rs87
-rw-r--r--src/commands/trigger.rs199
-rw-r--r--src/commands/tts.rs106
-rw-r--r--src/commands/utilities.rs918
-rw-r--r--src/commands/web.rs122
-rw-r--r--src/config.rs145
-rw-r--r--src/copyparty/mod.rs267
-rw-r--r--src/database/mod.rs344
-rw-r--r--src/error.rs108
-rw-r--r--src/events/mod.rs368
-rw-r--r--src/lang.rs387
-rw-r--r--src/main.rs190
-rw-r--r--src/openrouter/mod.rs134
-rw-r--r--src/tasks/mod.rs172
-rw-r--r--src/voice/mod.rs404
-rw-r--r--tests/cogs/test_ai.py83
-rw-r--r--tests/cogs/test_assistant.py66
-rw-r--r--tests/cogs/test_compression_conversion.py139
-rw-r--r--tests/cogs/test_cron.py147
-rw-r--r--tests/cogs/test_cron_integration.py39
-rw-r--r--tests/cogs/test_downloader.py93
-rw-r--r--tests/cogs/test_downloader_integration.py37
-rw-r--r--tests/cogs/test_math_commands.py55
-rw-r--r--tests/cogs/test_music_audio.py93
-rw-r--r--tests/cogs/test_music_external_play.py109
-rw-r--r--tests/cogs/test_music_integration.py46
-rw-r--r--tests/cogs/test_music_join.py56
-rw-r--r--tests/cogs/test_music_play.py104
-rw-r--r--tests/cogs/test_qr.py75
-rw-r--r--tests/cogs/test_say.py38
-rw-r--r--tests/cogs/test_search.py83
-rw-r--r--tests/cogs/test_trigger.py106
-rw-r--r--tests/cogs/test_trigger_integration.py54
-rw-r--r--tests/cogs/test_tts.py58
-rw-r--r--tests/cogs/test_uploader.py226
-rw-r--r--tests/cogs/test_vclogs.py88
-rw-r--r--tests/cogs/test_vclogs_integration.py86
-rw-r--r--tests/cogs/test_voice.py79
-rw-r--r--tests/cogs/test_web.py57
-rw-r--r--tests/conftest.py228
84 files changed, 11991 insertions, 6659 deletions
diff --git a/.env.example b/.env.example
index 9623155..fe76c05 100644
--- a/.env.example
+++ b/.env.example
@@ -5,7 +5,7 @@ TOKEN=
5STATUS_CHANNEL_ID= 5STATUS_CHANNEL_ID=
6ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant 6ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant
7 7
8# Optional - File Server (for music /search commands) 8# Optional - Copyparty File Server (for music /search commands)
9COPYPARTY_URL=http://localhost:8080 9COPYPARTY_URL=http://localhost:8080
10COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads 10COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads
11COPYPARTY_PASSWORD= 11COPYPARTY_PASSWORD=
diff --git a/.gitignore b/.gitignore
index 40d765c..31136fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,4 @@
1.env 1.env
2__pycache__/
3data/ 2data/
4.idea/
5.venv/
6.zed/ 3.zed/
7.pytest_cache/ 4target
diff --git a/AGENTS.md b/AGENTS.md
index 5f62ec3..f5a78ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,33 +1,47 @@
1# Agent Instructions for Senator Sperma 1# senator-sperma-rust — Agent Guide
2 2
3## Required Setup 3## Project Map
4- Requires **Python 3.14**
5- System dependencies: `ffmpeg`, `zbar` library (installed system-wide)
6- Bot token in `.env` file (copy from `.env.example`)
7 4
8## Commands 5```
9- Run bot: `.venv/bin/python main.py` 6src/
10- Type check: `.venv/bin/basedpyright` 7├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity
11- Test: `.venv/bin/pytest` 8├── config.rs — Config struct (env-driven), pub const TEMP_PATH
9├── error.rs — Unified Error enum + From impls, pub type Result<T>
10├── commands/
11│ ├── mod.rs — all_commands() aggregates every submodule
12│ ├── ai.rs — /senator (OpenRouter chat + optional TTS)
13│ ├── cron.rs — /cron group (6 subcommands)
14│ ├── math.rs — /math (qalc subprocess)
15│ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle
16│ ├── qr.rs — /qr encode + decode
17│ ├── trigger.rs — /trigger group (6 subcommands)
18│ ├── tts.rs — /tts (Google TTS → file/vc), /log_volume
19│ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say
20│ └── web.rs — /web (DuckDuckGo scrape)
21├── copyparty/mod.rs — CopypartyClient (search, upload)
22├── database/mod.rs — DbPool, 21 query fns, 6 tables, migration
23├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message
24├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history)
25├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies)
26└── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input
27```
12 28
13## Architecture 29## Key Conventions
14- Slash commands defined as cogs in `cogs/*.py`
15- Discord API uses `discord.ApplicationContext`
16- Music auto-complete queries copyparty server (`COPYPARTY_URL` config)
17 30
18## Environment 31- **Framework**: poise 0.6 + serenity 0.12 + songbird 0.6
19- Copyparty URL for read-only access is COPYPARTY_URL 32- **DB**: sqlx 0.9 async SQLite (pool stored in `Data.db_pool`)
20- Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD 33- **Errors**: `crate::error::Error` enum with `?` propagation; use `Error::Custom(string)` for ad-hoc
21- Above should be accessed using COPYPARTY_PASSWORD as a cookie 34- **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data`
22- Channel for vclogs and alike is STATUS_CHANNEL_ID 35- **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>`
23- Trigger word for assistant is ASSISTANT_KEYWORD 36- **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`)
24 37
25## Testing 38## Stubs to Fill (see COMPARISON.md)
26- Tests in `tests/cogs/test_*.py` 39- `commands/music.rs` — `/play` is placeholder
27- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` 40- `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part
28- `conftest.py` provides fixtures for mocking Discord objects 41- `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action
29- **Slow integration tests** marked with `@pytest.mark.slow` 42
30 - Test real-world feature usage with longer execution times 43## Database Tables
31 - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses 44`settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses`
32 - Run only fast tests: `.venv/bin/pytest -m "not slow"` 45
33 - Run only slow tests: `.venv/bin/pytest -m slow` 46## All Commands (26 total)
47`/senator`, `/cron` (6), `/math`, `/play`, `/stop`, `/join`, `/loop_toggle`, `/qr` (2), `/trigger` (6), `/tts`, `/log_volume`, `/compress`, `/convert`, `/upload`, `/search`, `/download`, `/say`, `/web`
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..27964a6
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,6967 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "ab_glyph"
7version = "0.2.32"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
10dependencies = [
11 "ab_glyph_rasterizer",
12 "owned_ttf_parser",
13]
14
15[[package]]
16name = "ab_glyph_rasterizer"
17version = "0.1.10"
18source = "registry+https://github.com/rust-lang/crates.io-index"
19checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
20
21[[package]]
22name = "adler2"
23version = "2.0.1"
24source = "registry+https://github.com/rust-lang/crates.io-index"
25checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
26
27[[package]]
28name = "aead"
29version = "0.5.2"
30source = "registry+https://github.com/rust-lang/crates.io-index"
31checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
32dependencies = [
33 "crypto-common 0.1.7",
34 "generic-array",
35]
36
37[[package]]
38name = "aes"
39version = "0.8.4"
40source = "registry+https://github.com/rust-lang/crates.io-index"
41checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
42dependencies = [
43 "cfg-if",
44 "cipher",
45 "cpufeatures 0.2.17",
46]
47
48[[package]]
49name = "aes-gcm"
50version = "0.10.3"
51source = "registry+https://github.com/rust-lang/crates.io-index"
52checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
53dependencies = [
54 "aead",
55 "aes",
56 "cipher",
57 "ctr",
58 "ghash",
59 "subtle",
60]
61
62[[package]]
63name = "aho-corasick"
64version = "1.1.4"
65source = "registry+https://github.com/rust-lang/crates.io-index"
66checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
67dependencies = [
68 "memchr",
69]
70
71[[package]]
72name = "aligned-vec"
73version = "0.6.4"
74source = "registry+https://github.com/rust-lang/crates.io-index"
75checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
76dependencies = [
77 "equator",
78]
79
80[[package]]
81name = "allocator-api2"
82version = "0.2.21"
83source = "registry+https://github.com/rust-lang/crates.io-index"
84checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
85
86[[package]]
87name = "android_system_properties"
88version = "0.1.5"
89source = "registry+https://github.com/rust-lang/crates.io-index"
90checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
91dependencies = [
92 "libc",
93]
94
95[[package]]
96name = "anyhow"
97version = "1.0.102"
98source = "registry+https://github.com/rust-lang/crates.io-index"
99checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
100
101[[package]]
102name = "approx"
103version = "0.5.1"
104source = "registry+https://github.com/rust-lang/crates.io-index"
105checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
106dependencies = [
107 "num-traits",
108]
109
110[[package]]
111name = "arbitrary"
112version = "1.4.2"
113source = "registry+https://github.com/rust-lang/crates.io-index"
114checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
115
116[[package]]
117name = "arg_enum_proc_macro"
118version = "0.3.4"
119source = "registry+https://github.com/rust-lang/crates.io-index"
120checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
121dependencies = [
122 "proc-macro2",
123 "quote",
124 "syn 2.0.118",
125]
126
127[[package]]
128name = "arrayvec"
129version = "0.7.6"
130source = "registry+https://github.com/rust-lang/crates.io-index"
131checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
132dependencies = [
133 "serde",
134]
135
136[[package]]
137name = "async-trait"
138version = "0.1.89"
139source = "registry+https://github.com/rust-lang/crates.io-index"
140checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
141dependencies = [
142 "proc-macro2",
143 "quote",
144 "syn 2.0.118",
145]
146
147[[package]]
148name = "atoi"
149version = "2.0.0"
150source = "registry+https://github.com/rust-lang/crates.io-index"
151checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
152dependencies = [
153 "num-traits",
154]
155
156[[package]]
157name = "atomic-waker"
158version = "1.1.2"
159source = "registry+https://github.com/rust-lang/crates.io-index"
160checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
161
162[[package]]
163name = "autocfg"
164version = "1.5.1"
165source = "registry+https://github.com/rust-lang/crates.io-index"
166checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
167
168[[package]]
169name = "av1-grain"
170version = "0.2.5"
171source = "registry+https://github.com/rust-lang/crates.io-index"
172checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
173dependencies = [
174 "anyhow",
175 "arrayvec",
176 "log",
177 "nom",
178 "num-rational",
179 "v_frame",
180]
181
182[[package]]
183name = "avif-serialize"
184version = "0.8.9"
185source = "registry+https://github.com/rust-lang/crates.io-index"
186checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
187dependencies = [
188 "arrayvec",
189]
190
191[[package]]
192name = "aws-lc-rs"
193version = "1.17.0"
194source = "registry+https://github.com/rust-lang/crates.io-index"
195checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
196dependencies = [
197 "aws-lc-sys",
198 "zeroize",
199]
200
201[[package]]
202name = "aws-lc-sys"
203version = "0.41.0"
204source = "registry+https://github.com/rust-lang/crates.io-index"
205checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
206dependencies = [
207 "cc",
208 "cmake",
209 "dunce",
210 "fs_extra",
211]
212
213[[package]]
214name = "base16ct"
215version = "0.2.0"
216source = "registry+https://github.com/rust-lang/crates.io-index"
217checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
218
219[[package]]
220name = "base64"
221version = "0.22.1"
222source = "registry+https://github.com/rust-lang/crates.io-index"
223checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
224
225[[package]]
226name = "base64ct"
227version = "1.8.3"
228source = "registry+https://github.com/rust-lang/crates.io-index"
229checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
230
231[[package]]
232name = "bit-set"
233version = "0.8.0"
234source = "registry+https://github.com/rust-lang/crates.io-index"
235checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
236dependencies = [
237 "bit-vec",
238]
239
240[[package]]
241name = "bit-vec"
242version = "0.8.0"
243source = "registry+https://github.com/rust-lang/crates.io-index"
244checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
245
246[[package]]
247name = "bit_field"
248version = "0.10.3"
249source = "registry+https://github.com/rust-lang/crates.io-index"
250checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
251
252[[package]]
253name = "bitflags"
254version = "1.3.2"
255source = "registry+https://github.com/rust-lang/crates.io-index"
256checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
257
258[[package]]
259name = "bitflags"
260version = "2.13.0"
261source = "registry+https://github.com/rust-lang/crates.io-index"
262checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
263dependencies = [
264 "serde_core",
265]
266
267[[package]]
268name = "bitstream-io"
269version = "2.6.0"
270source = "registry+https://github.com/rust-lang/crates.io-index"
271checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
272
273[[package]]
274name = "block-buffer"
275version = "0.10.4"
276source = "registry+https://github.com/rust-lang/crates.io-index"
277checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
278dependencies = [
279 "generic-array",
280]
281
282[[package]]
283name = "block-buffer"
284version = "0.12.1"
285source = "registry+https://github.com/rust-lang/crates.io-index"
286checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
287dependencies = [
288 "hybrid-array",
289]
290
291[[package]]
292name = "built"
293version = "0.7.7"
294source = "registry+https://github.com/rust-lang/crates.io-index"
295checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
296
297[[package]]
298name = "bumpalo"
299version = "3.20.3"
300source = "registry+https://github.com/rust-lang/crates.io-index"
301checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
302
303[[package]]
304name = "bytecount"
305version = "0.6.9"
306source = "registry+https://github.com/rust-lang/crates.io-index"
307checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
308
309[[package]]
310name = "bytemuck"
311version = "1.25.0"
312source = "registry+https://github.com/rust-lang/crates.io-index"
313checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
314
315[[package]]
316name = "byteorder"
317version = "1.5.0"
318source = "registry+https://github.com/rust-lang/crates.io-index"
319checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
320
321[[package]]
322name = "byteorder-lite"
323version = "0.1.0"
324source = "registry+https://github.com/rust-lang/crates.io-index"
325checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
326
327[[package]]
328name = "bytes"
329version = "1.11.1"
330source = "registry+https://github.com/rust-lang/crates.io-index"
331checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
332
333[[package]]
334name = "camino"
335version = "1.2.2"
336source = "registry+https://github.com/rust-lang/crates.io-index"
337checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
338dependencies = [
339 "serde_core",
340]
341
342[[package]]
343name = "cargo-platform"
344version = "0.1.9"
345source = "registry+https://github.com/rust-lang/crates.io-index"
346checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
347dependencies = [
348 "serde",
349]
350
351[[package]]
352name = "cargo_metadata"
353version = "0.14.2"
354source = "registry+https://github.com/rust-lang/crates.io-index"
355checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa"
356dependencies = [
357 "camino",
358 "cargo-platform",
359 "semver",
360 "serde",
361 "serde_json",
362]
363
364[[package]]
365name = "cc"
366version = "1.2.64"
367source = "registry+https://github.com/rust-lang/crates.io-index"
368checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
369dependencies = [
370 "find-msvc-tools",
371 "jobserver",
372 "libc",
373 "shlex",
374]
375
376[[package]]
377name = "cfg-expr"
378version = "0.15.8"
379source = "registry+https://github.com/rust-lang/crates.io-index"
380checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
381dependencies = [
382 "smallvec",
383 "target-lexicon",
384]
385
386[[package]]
387name = "cfg-if"
388version = "1.0.4"
389source = "registry+https://github.com/rust-lang/crates.io-index"
390checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
391
392[[package]]
393name = "cfg_aliases"
394version = "0.2.1"
395source = "registry+https://github.com/rust-lang/crates.io-index"
396checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
397
398[[package]]
399name = "chacha20"
400version = "0.9.1"
401source = "registry+https://github.com/rust-lang/crates.io-index"
402checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
403dependencies = [
404 "cfg-if",
405 "cipher",
406 "cpufeatures 0.2.17",
407]
408
409[[package]]
410name = "chacha20"
411version = "0.10.0"
412source = "registry+https://github.com/rust-lang/crates.io-index"
413checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
414dependencies = [
415 "cfg-if",
416 "cpufeatures 0.3.0",
417 "rand_core 0.10.1",
418]
419
420[[package]]
421name = "chacha20poly1305"
422version = "0.10.1"
423source = "registry+https://github.com/rust-lang/crates.io-index"
424checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
425dependencies = [
426 "aead",
427 "chacha20 0.9.1",
428 "cipher",
429 "poly1305",
430 "zeroize",
431]
432
433[[package]]
434name = "chrono"
435version = "0.4.45"
436source = "registry+https://github.com/rust-lang/crates.io-index"
437checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
438dependencies = [
439 "iana-time-zone",
440 "js-sys",
441 "num-traits",
442 "serde",
443 "wasm-bindgen",
444 "windows-link",
445]
446
447[[package]]
448name = "chrono-tz"
449version = "0.10.4"
450source = "registry+https://github.com/rust-lang/crates.io-index"
451checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
452dependencies = [
453 "chrono",
454 "phf 0.12.1",
455]
456
457[[package]]
458name = "cipher"
459version = "0.4.4"
460source = "registry+https://github.com/rust-lang/crates.io-index"
461checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
462dependencies = [
463 "crypto-common 0.1.7",
464 "inout",
465 "zeroize",
466]
467
468[[package]]
469name = "cmake"
470version = "0.1.58"
471source = "registry+https://github.com/rust-lang/crates.io-index"
472checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
473dependencies = [
474 "cc",
475]
476
477[[package]]
478name = "cmov"
479version = "0.5.4"
480source = "registry+https://github.com/rust-lang/crates.io-index"
481checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
482
483[[package]]
484name = "codepage-437"
485version = "0.1.0"
486source = "registry+https://github.com/rust-lang/crates.io-index"
487checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535"
488dependencies = [
489 "csv",
490]
491
492[[package]]
493name = "color_quant"
494version = "1.1.0"
495source = "registry+https://github.com/rust-lang/crates.io-index"
496checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
497
498[[package]]
499name = "command_attr"
500version = "0.5.4"
501source = "registry+https://github.com/rust-lang/crates.io-index"
502checksum = "8208103c5e25a091226dfa8d61d08d0561cc14f31b25691811ba37d4ec9b157b"
503dependencies = [
504 "proc-macro2",
505 "quote",
506 "syn 1.0.109",
507]
508
509[[package]]
510name = "concurrent-queue"
511version = "2.5.0"
512source = "registry+https://github.com/rust-lang/crates.io-index"
513checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
514dependencies = [
515 "crossbeam-utils",
516]
517
518[[package]]
519name = "const-oid"
520version = "0.9.6"
521source = "registry+https://github.com/rust-lang/crates.io-index"
522checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
523
524[[package]]
525name = "convert_case"
526version = "0.4.0"
527source = "registry+https://github.com/rust-lang/crates.io-index"
528checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
529
530[[package]]
531name = "core-foundation"
532version = "0.9.4"
533source = "registry+https://github.com/rust-lang/crates.io-index"
534checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
535dependencies = [
536 "core-foundation-sys",
537 "libc",
538]
539
540[[package]]
541name = "core-foundation"
542version = "0.10.1"
543source = "registry+https://github.com/rust-lang/crates.io-index"
544checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
545dependencies = [
546 "core-foundation-sys",
547 "libc",
548]
549
550[[package]]
551name = "core-foundation-sys"
552version = "0.8.7"
553source = "registry+https://github.com/rust-lang/crates.io-index"
554checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
555
556[[package]]
557name = "core-models"
558version = "0.0.5"
559source = "registry+https://github.com/rust-lang/crates.io-index"
560checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4"
561dependencies = [
562 "hax-lib",
563 "pastey",
564 "rand 0.9.4",
565]
566
567[[package]]
568name = "cpufeatures"
569version = "0.2.17"
570source = "registry+https://github.com/rust-lang/crates.io-index"
571checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
572dependencies = [
573 "libc",
574]
575
576[[package]]
577name = "cpufeatures"
578version = "0.3.0"
579source = "registry+https://github.com/rust-lang/crates.io-index"
580checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
581dependencies = [
582 "libc",
583]
584
585[[package]]
586name = "crc"
587version = "3.4.0"
588source = "registry+https://github.com/rust-lang/crates.io-index"
589checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
590dependencies = [
591 "crc-catalog",
592]
593
594[[package]]
595name = "crc-catalog"
596version = "2.5.0"
597source = "registry+https://github.com/rust-lang/crates.io-index"
598checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
599
600[[package]]
601name = "crc32fast"
602version = "1.5.0"
603source = "registry+https://github.com/rust-lang/crates.io-index"
604checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
605dependencies = [
606 "cfg-if",
607]
608
609[[package]]
610name = "cron"
611version = "0.17.0"
612source = "registry+https://github.com/rust-lang/crates.io-index"
613checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09"
614dependencies = [
615 "chrono",
616 "once_cell",
617 "phf 0.11.3",
618 "winnow",
619]
620
621[[package]]
622name = "crossbeam-channel"
623version = "0.5.15"
624source = "registry+https://github.com/rust-lang/crates.io-index"
625checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
626dependencies = [
627 "crossbeam-utils",
628]
629
630[[package]]
631name = "crossbeam-deque"
632version = "0.8.6"
633source = "registry+https://github.com/rust-lang/crates.io-index"
634checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
635dependencies = [
636 "crossbeam-epoch",
637 "crossbeam-utils",
638]
639
640[[package]]
641name = "crossbeam-epoch"
642version = "0.9.18"
643source = "registry+https://github.com/rust-lang/crates.io-index"
644checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
645dependencies = [
646 "crossbeam-utils",
647]
648
649[[package]]
650name = "crossbeam-queue"
651version = "0.3.12"
652source = "registry+https://github.com/rust-lang/crates.io-index"
653checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
654dependencies = [
655 "crossbeam-utils",
656]
657
658[[package]]
659name = "crossbeam-utils"
660version = "0.8.21"
661source = "registry+https://github.com/rust-lang/crates.io-index"
662checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
663
664[[package]]
665name = "crunchy"
666version = "0.2.4"
667source = "registry+https://github.com/rust-lang/crates.io-index"
668checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
669
670[[package]]
671name = "crypto-bigint"
672version = "0.5.5"
673source = "registry+https://github.com/rust-lang/crates.io-index"
674checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
675dependencies = [
676 "generic-array",
677 "rand_core 0.6.4",
678 "subtle",
679 "zeroize",
680]
681
682[[package]]
683name = "crypto-common"
684version = "0.1.7"
685source = "registry+https://github.com/rust-lang/crates.io-index"
686checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
687dependencies = [
688 "generic-array",
689 "rand_core 0.6.4",
690 "typenum",
691]
692
693[[package]]
694name = "crypto-common"
695version = "0.2.2"
696source = "registry+https://github.com/rust-lang/crates.io-index"
697checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
698dependencies = [
699 "hybrid-array",
700]
701
702[[package]]
703name = "cssparser"
704version = "0.37.0"
705source = "registry+https://github.com/rust-lang/crates.io-index"
706checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
707dependencies = [
708 "cssparser-macros",
709 "dtoa-short",
710 "itoa",
711 "phf 0.13.1",
712 "smallvec",
713]
714
715[[package]]
716name = "cssparser-macros"
717version = "0.7.0"
718source = "registry+https://github.com/rust-lang/crates.io-index"
719checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
720dependencies = [
721 "quote",
722 "syn 2.0.118",
723]
724
725[[package]]
726name = "csv"
727version = "1.4.0"
728source = "registry+https://github.com/rust-lang/crates.io-index"
729checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
730dependencies = [
731 "csv-core",
732 "itoa",
733 "ryu",
734 "serde_core",
735]
736
737[[package]]
738name = "csv-core"
739version = "0.1.13"
740source = "registry+https://github.com/rust-lang/crates.io-index"
741checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
742dependencies = [
743 "memchr",
744]
745
746[[package]]
747name = "ctr"
748version = "0.9.2"
749source = "registry+https://github.com/rust-lang/crates.io-index"
750checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
751dependencies = [
752 "cipher",
753]
754
755[[package]]
756name = "ctutils"
757version = "0.4.2"
758source = "registry+https://github.com/rust-lang/crates.io-index"
759checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
760dependencies = [
761 "cmov",
762]
763
764[[package]]
765name = "curve25519-dalek"
766version = "4.1.3"
767source = "registry+https://github.com/rust-lang/crates.io-index"
768checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
769dependencies = [
770 "cfg-if",
771 "cpufeatures 0.2.17",
772 "curve25519-dalek-derive",
773 "digest 0.10.7",
774 "fiat-crypto",
775 "rustc_version",
776 "subtle",
777 "zeroize",
778]
779
780[[package]]
781name = "curve25519-dalek-derive"
782version = "0.1.1"
783source = "registry+https://github.com/rust-lang/crates.io-index"
784checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
785dependencies = [
786 "proc-macro2",
787 "quote",
788 "syn 2.0.118",
789]
790
791[[package]]
792name = "darling"
793version = "0.10.2"
794source = "registry+https://github.com/rust-lang/crates.io-index"
795checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858"
796dependencies = [
797 "darling_core 0.10.2",
798 "darling_macro 0.10.2",
799]
800
801[[package]]
802name = "darling"
803version = "0.20.11"
804source = "registry+https://github.com/rust-lang/crates.io-index"
805checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
806dependencies = [
807 "darling_core 0.20.11",
808 "darling_macro 0.20.11",
809]
810
811[[package]]
812name = "darling_core"
813version = "0.10.2"
814source = "registry+https://github.com/rust-lang/crates.io-index"
815checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b"
816dependencies = [
817 "fnv",
818 "ident_case",
819 "proc-macro2",
820 "quote",
821 "strsim 0.9.3",
822 "syn 1.0.109",
823]
824
825[[package]]
826name = "darling_core"
827version = "0.20.11"
828source = "registry+https://github.com/rust-lang/crates.io-index"
829checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
830dependencies = [
831 "fnv",
832 "ident_case",
833 "proc-macro2",
834 "quote",
835 "strsim 0.11.1",
836 "syn 2.0.118",
837]
838
839[[package]]
840name = "darling_macro"
841version = "0.10.2"
842source = "registry+https://github.com/rust-lang/crates.io-index"
843checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72"
844dependencies = [
845 "darling_core 0.10.2",
846 "quote",
847 "syn 1.0.109",
848]
849
850[[package]]
851name = "darling_macro"
852version = "0.20.11"
853source = "registry+https://github.com/rust-lang/crates.io-index"
854checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
855dependencies = [
856 "darling_core 0.20.11",
857 "quote",
858 "syn 2.0.118",
859]
860
861[[package]]
862name = "dashmap"
863version = "5.5.3"
864source = "registry+https://github.com/rust-lang/crates.io-index"
865checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
866dependencies = [
867 "cfg-if",
868 "hashbrown 0.14.5",
869 "lock_api",
870 "once_cell",
871 "parking_lot_core 0.9.12",
872 "serde",
873]
874
875[[package]]
876name = "dashmap"
877version = "6.2.1"
878source = "registry+https://github.com/rust-lang/crates.io-index"
879checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
880dependencies = [
881 "cfg-if",
882 "crossbeam-utils",
883 "hashbrown 0.14.5",
884 "lock_api",
885 "once_cell",
886 "parking_lot_core 0.9.12",
887]
888
889[[package]]
890name = "data-encoding"
891version = "2.11.0"
892source = "registry+https://github.com/rust-lang/crates.io-index"
893checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
894
895[[package]]
896name = "davey"
897version = "0.1.3"
898source = "registry+https://github.com/rust-lang/crates.io-index"
899checksum = "623f518197232b3e3d23697fd0060e0291e21b9ab02376b6a4c71a235bb9aced"
900dependencies = [
901 "aead",
902 "aes",
903 "cipher",
904 "ctr",
905 "ghash",
906 "hex-literal",
907 "hmac 0.12.1",
908 "num-derive",
909 "num-traits",
910 "openmls",
911 "openmls_basic_credential",
912 "openmls_rust_crypto",
913 "p256",
914 "rand 0.8.6",
915 "scrypt",
916 "sha2 0.10.9",
917 "subtle",
918 "thiserror 2.0.18",
919 "tracing",
920]
921
922[[package]]
923name = "der"
924version = "0.7.10"
925source = "registry+https://github.com/rust-lang/crates.io-index"
926checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
927dependencies = [
928 "const-oid",
929 "pem-rfc7468",
930 "zeroize",
931]
932
933[[package]]
934name = "deranged"
935version = "0.5.8"
936source = "registry+https://github.com/rust-lang/crates.io-index"
937checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
938dependencies = [
939 "serde_core",
940]
941
942[[package]]
943name = "derivative"
944version = "2.2.0"
945source = "registry+https://github.com/rust-lang/crates.io-index"
946checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
947dependencies = [
948 "proc-macro2",
949 "quote",
950 "syn 1.0.109",
951]
952
953[[package]]
954name = "derive_builder"
955version = "0.9.0"
956source = "registry+https://github.com/rust-lang/crates.io-index"
957checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0"
958dependencies = [
959 "darling 0.10.2",
960 "derive_builder_core",
961 "proc-macro2",
962 "quote",
963 "syn 1.0.109",
964]
965
966[[package]]
967name = "derive_builder_core"
968version = "0.9.0"
969source = "registry+https://github.com/rust-lang/crates.io-index"
970checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef"
971dependencies = [
972 "darling 0.10.2",
973 "proc-macro2",
974 "quote",
975 "syn 1.0.109",
976]
977
978[[package]]
979name = "derive_more"
980version = "0.99.20"
981source = "registry+https://github.com/rust-lang/crates.io-index"
982checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
983dependencies = [
984 "convert_case",
985 "proc-macro2",
986 "quote",
987 "rustc_version",
988 "syn 2.0.118",
989]
990
991[[package]]
992name = "derive_more"
993version = "2.1.1"
994source = "registry+https://github.com/rust-lang/crates.io-index"
995checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
996dependencies = [
997 "derive_more-impl",
998]
999
1000[[package]]
1001name = "derive_more-impl"
1002version = "2.1.1"
1003source = "registry+https://github.com/rust-lang/crates.io-index"
1004checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
1005dependencies = [
1006 "proc-macro2",
1007 "quote",
1008 "rustc_version",
1009 "syn 2.0.118",
1010]
1011
1012[[package]]
1013name = "digest"
1014version = "0.10.7"
1015source = "registry+https://github.com/rust-lang/crates.io-index"
1016checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
1017dependencies = [
1018 "block-buffer 0.10.4",
1019 "const-oid",
1020 "crypto-common 0.1.7",
1021 "subtle",
1022]
1023
1024[[package]]
1025name = "digest"
1026version = "0.11.3"
1027source = "registry+https://github.com/rust-lang/crates.io-index"
1028checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
1029dependencies = [
1030 "block-buffer 0.12.1",
1031 "crypto-common 0.2.2",
1032 "ctutils",
1033]
1034
1035[[package]]
1036name = "discortp"
1037version = "0.6.0"
1038source = "registry+https://github.com/rust-lang/crates.io-index"
1039checksum = "5c793408a15d361754613fa68123ffa60424c2617fafdf82127b4bedf37d3f5d"
1040dependencies = [
1041 "pnet_macros",
1042 "pnet_macros_support",
1043]
1044
1045[[package]]
1046name = "displaydoc"
1047version = "0.2.6"
1048source = "registry+https://github.com/rust-lang/crates.io-index"
1049checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
1050dependencies = [
1051 "proc-macro2",
1052 "quote",
1053 "syn 2.0.118",
1054]
1055
1056[[package]]
1057name = "dotenvy"
1058version = "0.15.7"
1059source = "registry+https://github.com/rust-lang/crates.io-index"
1060checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
1061
1062[[package]]
1063name = "dtoa"
1064version = "1.0.11"
1065source = "registry+https://github.com/rust-lang/crates.io-index"
1066checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1067
1068[[package]]
1069name = "dtoa-short"
1070version = "0.3.5"
1071source = "registry+https://github.com/rust-lang/crates.io-index"
1072checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1073dependencies = [
1074 "dtoa",
1075]
1076
1077[[package]]
1078name = "dunce"
1079version = "1.0.5"
1080source = "registry+https://github.com/rust-lang/crates.io-index"
1081checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
1082
1083[[package]]
1084name = "ecdsa"
1085version = "0.16.9"
1086source = "registry+https://github.com/rust-lang/crates.io-index"
1087checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
1088dependencies = [
1089 "der",
1090 "digest 0.10.7",
1091 "elliptic-curve",
1092 "rfc6979",
1093 "signature",
1094 "spki",
1095]
1096
1097[[package]]
1098name = "ed25519"
1099version = "2.2.3"
1100source = "registry+https://github.com/rust-lang/crates.io-index"
1101checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
1102dependencies = [
1103 "pkcs8",
1104 "signature",
1105]
1106
1107[[package]]
1108name = "ed25519-dalek"
1109version = "2.2.0"
1110source = "registry+https://github.com/rust-lang/crates.io-index"
1111checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
1112dependencies = [
1113 "curve25519-dalek",
1114 "ed25519",
1115 "rand_core 0.6.4",
1116 "serde",
1117 "sha2 0.10.9",
1118 "subtle",
1119 "zeroize",
1120]
1121
1122[[package]]
1123name = "ego-tree"
1124version = "0.11.0"
1125source = "registry+https://github.com/rust-lang/crates.io-index"
1126checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
1127
1128[[package]]
1129name = "either"
1130version = "1.16.0"
1131source = "registry+https://github.com/rust-lang/crates.io-index"
1132checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
1133dependencies = [
1134 "serde",
1135]
1136
1137[[package]]
1138name = "elliptic-curve"
1139version = "0.13.8"
1140source = "registry+https://github.com/rust-lang/crates.io-index"
1141checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
1142dependencies = [
1143 "base16ct",
1144 "crypto-bigint",
1145 "digest 0.10.7",
1146 "ff",
1147 "generic-array",
1148 "group",
1149 "hkdf 0.12.4",
1150 "pem-rfc7468",
1151 "pkcs8",
1152 "rand_core 0.6.4",
1153 "sec1",
1154 "subtle",
1155 "zeroize",
1156]
1157
1158[[package]]
1159name = "encoding_rs"
1160version = "0.8.35"
1161source = "registry+https://github.com/rust-lang/crates.io-index"
1162checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1163dependencies = [
1164 "cfg-if",
1165]
1166
1167[[package]]
1168name = "enum-map"
1169version = "2.7.3"
1170source = "registry+https://github.com/rust-lang/crates.io-index"
1171checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
1172dependencies = [
1173 "enum-map-derive",
1174]
1175
1176[[package]]
1177name = "enum-map-derive"
1178version = "0.17.0"
1179source = "registry+https://github.com/rust-lang/crates.io-index"
1180checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
1181dependencies = [
1182 "proc-macro2",
1183 "quote",
1184 "syn 2.0.118",
1185]
1186
1187[[package]]
1188name = "equator"
1189version = "0.4.2"
1190source = "registry+https://github.com/rust-lang/crates.io-index"
1191checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
1192dependencies = [
1193 "equator-macro",
1194]
1195
1196[[package]]
1197name = "equator-macro"
1198version = "0.4.2"
1199source = "registry+https://github.com/rust-lang/crates.io-index"
1200checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
1201dependencies = [
1202 "proc-macro2",
1203 "quote",
1204 "syn 2.0.118",
1205]
1206
1207[[package]]
1208name = "equivalent"
1209version = "1.0.2"
1210source = "registry+https://github.com/rust-lang/crates.io-index"
1211checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
1212
1213[[package]]
1214name = "errno"
1215version = "0.3.14"
1216source = "registry+https://github.com/rust-lang/crates.io-index"
1217checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1218dependencies = [
1219 "libc",
1220 "windows-sys 0.61.2",
1221]
1222
1223[[package]]
1224name = "error-chain"
1225version = "0.12.4"
1226source = "registry+https://github.com/rust-lang/crates.io-index"
1227checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc"
1228dependencies = [
1229 "version_check",
1230]
1231
1232[[package]]
1233name = "etcetera"
1234version = "0.11.0"
1235source = "registry+https://github.com/rust-lang/crates.io-index"
1236checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
1237dependencies = [
1238 "cfg-if",
1239 "windows-sys 0.61.2",
1240]
1241
1242[[package]]
1243name = "event-listener"
1244version = "5.4.1"
1245source = "registry+https://github.com/rust-lang/crates.io-index"
1246checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
1247dependencies = [
1248 "concurrent-queue",
1249 "parking",
1250 "pin-project-lite",
1251]
1252
1253[[package]]
1254name = "exr"
1255version = "1.74.0"
1256source = "registry+https://github.com/rust-lang/crates.io-index"
1257checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
1258dependencies = [
1259 "bit_field",
1260 "half",
1261 "lebe",
1262 "miniz_oxide",
1263 "rayon-core",
1264 "smallvec",
1265 "zune-inflate",
1266]
1267
1268[[package]]
1269name = "extended"
1270version = "0.1.0"
1271source = "registry+https://github.com/rust-lang/crates.io-index"
1272checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
1273
1274[[package]]
1275name = "fancy-regex"
1276version = "0.17.0"
1277source = "registry+https://github.com/rust-lang/crates.io-index"
1278checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
1279dependencies = [
1280 "bit-set",
1281 "regex-automata",
1282 "regex-syntax",
1283]
1284
1285[[package]]
1286name = "fastrand"
1287version = "2.4.1"
1288source = "registry+https://github.com/rust-lang/crates.io-index"
1289checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
1290
1291[[package]]
1292name = "fax"
1293version = "0.2.7"
1294source = "registry+https://github.com/rust-lang/crates.io-index"
1295checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
1296
1297[[package]]
1298name = "fdeflate"
1299version = "0.3.7"
1300source = "registry+https://github.com/rust-lang/crates.io-index"
1301checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1302dependencies = [
1303 "simd-adler32",
1304]
1305
1306[[package]]
1307name = "ff"
1308version = "0.13.1"
1309source = "registry+https://github.com/rust-lang/crates.io-index"
1310checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1311dependencies = [
1312 "rand_core 0.6.4",
1313 "subtle",
1314]
1315
1316[[package]]
1317name = "fiat-crypto"
1318version = "0.2.9"
1319source = "registry+https://github.com/rust-lang/crates.io-index"
1320checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1321
1322[[package]]
1323name = "find-msvc-tools"
1324version = "0.1.9"
1325source = "registry+https://github.com/rust-lang/crates.io-index"
1326checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
1327
1328[[package]]
1329name = "flate2"
1330version = "1.1.9"
1331source = "registry+https://github.com/rust-lang/crates.io-index"
1332checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1333dependencies = [
1334 "crc32fast",
1335 "miniz_oxide",
1336]
1337
1338[[package]]
1339name = "flume"
1340version = "0.11.1"
1341source = "registry+https://github.com/rust-lang/crates.io-index"
1342checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
1343dependencies = [
1344 "futures-core",
1345 "futures-sink",
1346 "nanorand",
1347 "spin",
1348]
1349
1350[[package]]
1351name = "flume"
1352version = "0.12.0"
1353source = "registry+https://github.com/rust-lang/crates.io-index"
1354checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
1355dependencies = [
1356 "futures-core",
1357 "futures-sink",
1358 "spin",
1359]
1360
1361[[package]]
1362name = "fluvio-wasm-timer"
1363version = "0.2.5"
1364source = "registry+https://github.com/rust-lang/crates.io-index"
1365checksum = "b768c170dc045fa587a8f948c91f9bcfb87f774930477c6215addf54317f137f"
1366dependencies = [
1367 "futures",
1368 "js-sys",
1369 "parking_lot 0.11.2",
1370 "pin-utils",
1371 "wasm-bindgen",
1372 "wasm-bindgen-futures",
1373 "web-sys",
1374]
1375
1376[[package]]
1377name = "fnv"
1378version = "1.0.7"
1379source = "registry+https://github.com/rust-lang/crates.io-index"
1380checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1381
1382[[package]]
1383name = "foldhash"
1384version = "0.2.0"
1385source = "registry+https://github.com/rust-lang/crates.io-index"
1386checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1387
1388[[package]]
1389name = "foreign-types"
1390version = "0.3.2"
1391source = "registry+https://github.com/rust-lang/crates.io-index"
1392checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
1393dependencies = [
1394 "foreign-types-shared",
1395]
1396
1397[[package]]
1398name = "foreign-types-shared"
1399version = "0.1.1"
1400source = "registry+https://github.com/rust-lang/crates.io-index"
1401checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
1402
1403[[package]]
1404name = "form_urlencoded"
1405version = "1.2.2"
1406source = "registry+https://github.com/rust-lang/crates.io-index"
1407checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1408dependencies = [
1409 "percent-encoding",
1410]
1411
1412[[package]]
1413name = "from_map"
1414version = "0.1.0"
1415source = "registry+https://github.com/rust-lang/crates.io-index"
1416checksum = "99f31122ab0445ff8cee420b805f24e07683073815de1dd276ee7d588d301700"
1417dependencies = [
1418 "hashmap_derive",
1419]
1420
1421[[package]]
1422name = "fs_extra"
1423version = "1.3.0"
1424source = "registry+https://github.com/rust-lang/crates.io-index"
1425checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1426
1427[[package]]
1428name = "futures"
1429version = "0.3.32"
1430source = "registry+https://github.com/rust-lang/crates.io-index"
1431checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
1432dependencies = [
1433 "futures-channel",
1434 "futures-core",
1435 "futures-executor",
1436 "futures-io",
1437 "futures-sink",
1438 "futures-task",
1439 "futures-util",
1440]
1441
1442[[package]]
1443name = "futures-channel"
1444version = "0.3.32"
1445source = "registry+https://github.com/rust-lang/crates.io-index"
1446checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
1447dependencies = [
1448 "futures-core",
1449 "futures-sink",
1450]
1451
1452[[package]]
1453name = "futures-core"
1454version = "0.3.32"
1455source = "registry+https://github.com/rust-lang/crates.io-index"
1456checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
1457
1458[[package]]
1459name = "futures-executor"
1460version = "0.3.32"
1461source = "registry+https://github.com/rust-lang/crates.io-index"
1462checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
1463dependencies = [
1464 "futures-core",
1465 "futures-task",
1466 "futures-util",
1467]
1468
1469[[package]]
1470name = "futures-intrusive"
1471version = "0.5.0"
1472source = "registry+https://github.com/rust-lang/crates.io-index"
1473checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
1474dependencies = [
1475 "futures-core",
1476 "lock_api",
1477 "parking_lot 0.12.5",
1478]
1479
1480[[package]]
1481name = "futures-io"
1482version = "0.3.32"
1483source = "registry+https://github.com/rust-lang/crates.io-index"
1484checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
1485
1486[[package]]
1487name = "futures-macro"
1488version = "0.3.32"
1489source = "registry+https://github.com/rust-lang/crates.io-index"
1490checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
1491dependencies = [
1492 "proc-macro2",
1493 "quote",
1494 "syn 2.0.118",
1495]
1496
1497[[package]]
1498name = "futures-sink"
1499version = "0.3.32"
1500source = "registry+https://github.com/rust-lang/crates.io-index"
1501checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
1502
1503[[package]]
1504name = "futures-task"
1505version = "0.3.32"
1506source = "registry+https://github.com/rust-lang/crates.io-index"
1507checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
1508
1509[[package]]
1510name = "futures-util"
1511version = "0.3.32"
1512source = "registry+https://github.com/rust-lang/crates.io-index"
1513checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1514dependencies = [
1515 "futures-channel",
1516 "futures-core",
1517 "futures-io",
1518 "futures-macro",
1519 "futures-sink",
1520 "futures-task",
1521 "memchr",
1522 "pin-project-lite",
1523 "slab",
1524]
1525
1526[[package]]
1527name = "generator"
1528version = "0.7.5"
1529source = "registry+https://github.com/rust-lang/crates.io-index"
1530checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
1531dependencies = [
1532 "cc",
1533 "libc",
1534 "log",
1535 "rustversion",
1536 "windows",
1537]
1538
1539[[package]]
1540name = "generic-array"
1541version = "0.14.7"
1542source = "registry+https://github.com/rust-lang/crates.io-index"
1543checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1544dependencies = [
1545 "typenum",
1546 "version_check",
1547 "zeroize",
1548]
1549
1550[[package]]
1551name = "getopts"
1552version = "0.2.24"
1553source = "registry+https://github.com/rust-lang/crates.io-index"
1554checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
1555dependencies = [
1556 "unicode-width",
1557]
1558
1559[[package]]
1560name = "getrandom"
1561version = "0.2.17"
1562source = "registry+https://github.com/rust-lang/crates.io-index"
1563checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1564dependencies = [
1565 "cfg-if",
1566 "js-sys",
1567 "libc",
1568 "wasi",
1569 "wasm-bindgen",
1570]
1571
1572[[package]]
1573name = "getrandom"
1574version = "0.3.4"
1575source = "registry+https://github.com/rust-lang/crates.io-index"
1576checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1577dependencies = [
1578 "cfg-if",
1579 "js-sys",
1580 "libc",
1581 "r-efi 5.3.0",
1582 "wasip2",
1583 "wasm-bindgen",
1584]
1585
1586[[package]]
1587name = "getrandom"
1588version = "0.4.3"
1589source = "registry+https://github.com/rust-lang/crates.io-index"
1590checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1591dependencies = [
1592 "cfg-if",
1593 "libc",
1594 "r-efi 6.0.0",
1595 "rand_core 0.10.1",
1596]
1597
1598[[package]]
1599name = "ghash"
1600version = "0.5.1"
1601source = "registry+https://github.com/rust-lang/crates.io-index"
1602checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
1603dependencies = [
1604 "opaque-debug",
1605 "polyval",
1606]
1607
1608[[package]]
1609name = "gif"
1610version = "0.13.3"
1611source = "registry+https://github.com/rust-lang/crates.io-index"
1612checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
1613dependencies = [
1614 "color_quant",
1615 "weezl",
1616]
1617
1618[[package]]
1619name = "glob"
1620version = "0.3.3"
1621source = "registry+https://github.com/rust-lang/crates.io-index"
1622checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
1623
1624[[package]]
1625name = "group"
1626version = "0.13.0"
1627source = "registry+https://github.com/rust-lang/crates.io-index"
1628checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1629dependencies = [
1630 "ff",
1631 "rand_core 0.6.4",
1632 "subtle",
1633]
1634
1635[[package]]
1636name = "h2"
1637version = "0.4.15"
1638source = "registry+https://github.com/rust-lang/crates.io-index"
1639checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
1640dependencies = [
1641 "atomic-waker",
1642 "bytes",
1643 "fnv",
1644 "futures-core",
1645 "futures-sink",
1646 "http",
1647 "indexmap",
1648 "slab",
1649 "tokio",
1650 "tokio-util",
1651 "tracing",
1652]
1653
1654[[package]]
1655name = "half"
1656version = "2.7.1"
1657source = "registry+https://github.com/rust-lang/crates.io-index"
1658checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
1659dependencies = [
1660 "cfg-if",
1661 "crunchy",
1662 "zerocopy",
1663]
1664
1665[[package]]
1666name = "hashbrown"
1667version = "0.14.5"
1668source = "registry+https://github.com/rust-lang/crates.io-index"
1669checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1670
1671[[package]]
1672name = "hashbrown"
1673version = "0.16.1"
1674source = "registry+https://github.com/rust-lang/crates.io-index"
1675checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1676dependencies = [
1677 "allocator-api2",
1678 "equivalent",
1679 "foldhash",
1680]
1681
1682[[package]]
1683name = "hashbrown"
1684version = "0.17.1"
1685source = "registry+https://github.com/rust-lang/crates.io-index"
1686checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1687
1688[[package]]
1689name = "hashlink"
1690version = "0.11.1"
1691source = "registry+https://github.com/rust-lang/crates.io-index"
1692checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
1693dependencies = [
1694 "hashbrown 0.16.1",
1695]
1696
1697[[package]]
1698name = "hashmap_derive"
1699version = "0.1.0"
1700source = "registry+https://github.com/rust-lang/crates.io-index"
1701checksum = "fb30bf173e72cc31b5265dac095423ca14e7789ff7c3b0e6096a37a996f12883"
1702dependencies = [
1703 "proc-macro2",
1704 "quote",
1705 "syn 1.0.109",
1706]
1707
1708[[package]]
1709name = "hax-lib"
1710version = "0.3.6"
1711source = "registry+https://github.com/rust-lang/crates.io-index"
1712checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc"
1713dependencies = [
1714 "hax-lib-macros",
1715 "num-bigint",
1716 "num-traits",
1717]
1718
1719[[package]]
1720name = "hax-lib-macros"
1721version = "0.3.6"
1722source = "registry+https://github.com/rust-lang/crates.io-index"
1723checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f"
1724dependencies = [
1725 "hax-lib-macros-types",
1726 "proc-macro-error2",
1727 "proc-macro2",
1728 "quote",
1729 "syn 2.0.118",
1730]
1731
1732[[package]]
1733name = "hax-lib-macros-types"
1734version = "0.3.6"
1735source = "registry+https://github.com/rust-lang/crates.io-index"
1736checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2"
1737dependencies = [
1738 "proc-macro2",
1739 "quote",
1740 "serde",
1741 "serde_json",
1742 "uuid",
1743]
1744
1745[[package]]
1746name = "heck"
1747version = "0.3.3"
1748source = "registry+https://github.com/rust-lang/crates.io-index"
1749checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
1750dependencies = [
1751 "unicode-segmentation",
1752]
1753
1754[[package]]
1755name = "heck"
1756version = "0.5.0"
1757source = "registry+https://github.com/rust-lang/crates.io-index"
1758checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1759
1760[[package]]
1761name = "hermit-abi"
1762version = "0.5.2"
1763source = "registry+https://github.com/rust-lang/crates.io-index"
1764checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
1765
1766[[package]]
1767name = "hex"
1768version = "0.4.3"
1769source = "registry+https://github.com/rust-lang/crates.io-index"
1770checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1771
1772[[package]]
1773name = "hex-literal"
1774version = "0.3.4"
1775source = "registry+https://github.com/rust-lang/crates.io-index"
1776checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0"
1777
1778[[package]]
1779name = "hkdf"
1780version = "0.12.4"
1781source = "registry+https://github.com/rust-lang/crates.io-index"
1782checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1783dependencies = [
1784 "hmac 0.12.1",
1785]
1786
1787[[package]]
1788name = "hkdf"
1789version = "0.13.0"
1790source = "registry+https://github.com/rust-lang/crates.io-index"
1791checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
1792dependencies = [
1793 "hmac 0.13.0",
1794]
1795
1796[[package]]
1797name = "hls_m3u8"
1798version = "0.4.2"
1799source = "registry+https://github.com/rust-lang/crates.io-index"
1800checksum = "b906521a5b0e6d2ec07ea0bb855d92a1db30b48812744a645a3b2a1405cb8159"
1801dependencies = [
1802 "derive_builder",
1803 "derive_more 0.99.20",
1804 "hex",
1805 "shorthand",
1806 "stable-vec",
1807 "strum",
1808 "thiserror 1.0.69",
1809]
1810
1811[[package]]
1812name = "hmac"
1813version = "0.12.1"
1814source = "registry+https://github.com/rust-lang/crates.io-index"
1815checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1816dependencies = [
1817 "digest 0.10.7",
1818]
1819
1820[[package]]
1821name = "hmac"
1822version = "0.13.0"
1823source = "registry+https://github.com/rust-lang/crates.io-index"
1824checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
1825dependencies = [
1826 "digest 0.11.3",
1827]
1828
1829[[package]]
1830name = "hpke-rs"
1831version = "0.6.1"
1832source = "registry+https://github.com/rust-lang/crates.io-index"
1833checksum = "b6ad6a58eb3e0ee30be8bfc7a9770ae98adcfa1d9bc820a5847732ce84f70837"
1834dependencies = [
1835 "hpke-rs-crypto",
1836 "hpke-rs-libcrux",
1837 "hpke-rs-rust-crypto",
1838 "libcrux-sha3",
1839 "log",
1840 "rand_core 0.9.5",
1841 "serde",
1842 "subtle",
1843 "tls_codec",
1844 "zeroize",
1845]
1846
1847[[package]]
1848name = "hpke-rs-crypto"
1849version = "0.6.1"
1850source = "registry+https://github.com/rust-lang/crates.io-index"
1851checksum = "0a73a99d9008010d73289f41335a3f6e14fb8c04eaf60e9111b450463b1bbc7f"
1852dependencies = [
1853 "rand_core 0.9.5",
1854 "zeroize",
1855]
1856
1857[[package]]
1858name = "hpke-rs-libcrux"
1859version = "0.6.1"
1860source = "registry+https://github.com/rust-lang/crates.io-index"
1861checksum = "c0ce6b7e54aebe540faee869c67ee253bede44ea6cb67c6e72c7847d6c59f1df"
1862dependencies = [
1863 "hpke-rs-crypto",
1864 "libcrux-aead",
1865 "libcrux-ecdh",
1866 "libcrux-hkdf",
1867 "libcrux-kem",
1868 "libcrux-traits",
1869 "rand 0.10.1",
1870 "rand_chacha 0.10.0",
1871 "rand_core 0.10.1",
1872 "zeroize",
1873]
1874
1875[[package]]
1876name = "hpke-rs-rust-crypto"
1877version = "0.6.1"
1878source = "registry+https://github.com/rust-lang/crates.io-index"
1879checksum = "14b28be6cba9081c7feda2651d51c2a900029798e78b4c1e093e792f4571a870"
1880dependencies = [
1881 "aes-gcm",
1882 "chacha20poly1305",
1883 "hkdf 0.12.4",
1884 "hpke-rs-crypto",
1885 "k256",
1886 "p256",
1887 "p384",
1888 "rand 0.8.6",
1889 "rand_chacha 0.3.1",
1890 "rand_core 0.10.1",
1891 "rand_core 0.6.4",
1892 "sha2 0.10.9",
1893 "subtle",
1894 "x25519-dalek",
1895 "zeroize",
1896]
1897
1898[[package]]
1899name = "html5ever"
1900version = "0.39.0"
1901source = "registry+https://github.com/rust-lang/crates.io-index"
1902checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1903dependencies = [
1904 "log",
1905 "markup5ever",
1906]
1907
1908[[package]]
1909name = "http"
1910version = "1.4.2"
1911source = "registry+https://github.com/rust-lang/crates.io-index"
1912checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
1913dependencies = [
1914 "bytes",
1915 "itoa",
1916]
1917
1918[[package]]
1919name = "http-body"
1920version = "1.0.1"
1921source = "registry+https://github.com/rust-lang/crates.io-index"
1922checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
1923dependencies = [
1924 "bytes",
1925 "http",
1926]
1927
1928[[package]]
1929name = "http-body-util"
1930version = "0.1.3"
1931source = "registry+https://github.com/rust-lang/crates.io-index"
1932checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
1933dependencies = [
1934 "bytes",
1935 "futures-core",
1936 "http",
1937 "http-body",
1938 "pin-project-lite",
1939]
1940
1941[[package]]
1942name = "httparse"
1943version = "1.10.1"
1944source = "registry+https://github.com/rust-lang/crates.io-index"
1945checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1946
1947[[package]]
1948name = "hybrid-array"
1949version = "0.4.12"
1950source = "registry+https://github.com/rust-lang/crates.io-index"
1951checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
1952dependencies = [
1953 "typenum",
1954]
1955
1956[[package]]
1957name = "hyper"
1958version = "1.10.1"
1959source = "registry+https://github.com/rust-lang/crates.io-index"
1960checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
1961dependencies = [
1962 "atomic-waker",
1963 "bytes",
1964 "futures-channel",
1965 "futures-core",
1966 "h2",
1967 "http",
1968 "http-body",
1969 "httparse",
1970 "itoa",
1971 "pin-project-lite",
1972 "smallvec",
1973 "tokio",
1974 "want",
1975]
1976
1977[[package]]
1978name = "hyper-rustls"
1979version = "0.27.9"
1980source = "registry+https://github.com/rust-lang/crates.io-index"
1981checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
1982dependencies = [
1983 "http",
1984 "hyper",
1985 "hyper-util",
1986 "rustls 0.23.40",
1987 "tokio",
1988 "tokio-rustls 0.26.4",
1989 "tower-service",
1990 "webpki-roots 1.0.8",
1991]
1992
1993[[package]]
1994name = "hyper-tls"
1995version = "0.6.0"
1996source = "registry+https://github.com/rust-lang/crates.io-index"
1997checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1998dependencies = [
1999 "bytes",
2000 "http-body-util",
2001 "hyper",
2002 "hyper-util",
2003 "native-tls",
2004 "tokio",
2005 "tokio-native-tls",
2006 "tower-service",
2007]
2008
2009[[package]]
2010name = "hyper-util"
2011version = "0.1.20"
2012source = "registry+https://github.com/rust-lang/crates.io-index"
2013checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
2014dependencies = [
2015 "base64",
2016 "bytes",
2017 "futures-channel",
2018 "futures-util",
2019 "http",
2020 "http-body",
2021 "hyper",
2022 "ipnet",
2023 "libc",
2024 "percent-encoding",
2025 "pin-project-lite",
2026 "socket2 0.6.4",
2027 "system-configuration",
2028 "tokio",
2029 "tower-service",
2030 "tracing",
2031 "windows-registry",
2032]
2033
2034[[package]]
2035name = "iana-time-zone"
2036version = "0.1.65"
2037source = "registry+https://github.com/rust-lang/crates.io-index"
2038checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
2039dependencies = [
2040 "android_system_properties",
2041 "core-foundation-sys",
2042 "iana-time-zone-haiku",
2043 "js-sys",
2044 "log",
2045 "wasm-bindgen",
2046 "windows-core",
2047]
2048
2049[[package]]
2050name = "iana-time-zone-haiku"
2051version = "0.1.2"
2052source = "registry+https://github.com/rust-lang/crates.io-index"
2053checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
2054dependencies = [
2055 "cc",
2056]
2057
2058[[package]]
2059name = "icu_collections"
2060version = "2.2.0"
2061source = "registry+https://github.com/rust-lang/crates.io-index"
2062checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
2063dependencies = [
2064 "displaydoc",
2065 "potential_utf",
2066 "utf8_iter",
2067 "yoke",
2068 "zerofrom",
2069 "zerovec",
2070]
2071
2072[[package]]
2073name = "icu_locale_core"
2074version = "2.2.0"
2075source = "registry+https://github.com/rust-lang/crates.io-index"
2076checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
2077dependencies = [
2078 "displaydoc",
2079 "litemap",
2080 "tinystr",
2081 "writeable",
2082 "zerovec",
2083]
2084
2085[[package]]
2086name = "icu_normalizer"
2087version = "2.2.0"
2088source = "registry+https://github.com/rust-lang/crates.io-index"
2089checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
2090dependencies = [
2091 "icu_collections",
2092 "icu_normalizer_data",
2093 "icu_properties",
2094 "icu_provider",
2095 "smallvec",
2096 "zerovec",
2097]
2098
2099[[package]]
2100name = "icu_normalizer_data"
2101version = "2.2.0"
2102source = "registry+https://github.com/rust-lang/crates.io-index"
2103checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
2104
2105[[package]]
2106name = "icu_properties"
2107version = "2.2.0"
2108source = "registry+https://github.com/rust-lang/crates.io-index"
2109checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
2110dependencies = [
2111 "icu_collections",
2112 "icu_locale_core",
2113 "icu_properties_data",
2114 "icu_provider",
2115 "zerotrie",
2116 "zerovec",
2117]
2118
2119[[package]]
2120name = "icu_properties_data"
2121version = "2.2.0"
2122source = "registry+https://github.com/rust-lang/crates.io-index"
2123checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
2124
2125[[package]]
2126name = "icu_provider"
2127version = "2.2.0"
2128source = "registry+https://github.com/rust-lang/crates.io-index"
2129checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
2130dependencies = [
2131 "displaydoc",
2132 "icu_locale_core",
2133 "writeable",
2134 "yoke",
2135 "zerofrom",
2136 "zerotrie",
2137 "zerovec",
2138]
2139
2140[[package]]
2141name = "ident_case"
2142version = "1.0.1"
2143source = "registry+https://github.com/rust-lang/crates.io-index"
2144checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
2145
2146[[package]]
2147name = "idna"
2148version = "1.1.0"
2149source = "registry+https://github.com/rust-lang/crates.io-index"
2150checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
2151dependencies = [
2152 "idna_adapter",
2153 "smallvec",
2154 "utf8_iter",
2155]
2156
2157[[package]]
2158name = "idna_adapter"
2159version = "1.2.2"
2160source = "registry+https://github.com/rust-lang/crates.io-index"
2161checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
2162dependencies = [
2163 "icu_normalizer",
2164 "icu_properties",
2165]
2166
2167[[package]]
2168name = "image"
2169version = "0.25.8"
2170source = "registry+https://github.com/rust-lang/crates.io-index"
2171checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
2172dependencies = [
2173 "bytemuck",
2174 "byteorder-lite",
2175 "color_quant",
2176 "exr",
2177 "gif",
2178 "image-webp",
2179 "moxcms",
2180 "num-traits",
2181 "png",
2182 "qoi",
2183 "ravif",
2184 "rayon",
2185 "rgb",
2186 "tiff",
2187 "zune-core",
2188 "zune-jpeg",
2189]
2190
2191[[package]]
2192name = "image-webp"
2193version = "0.2.4"
2194source = "registry+https://github.com/rust-lang/crates.io-index"
2195checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
2196dependencies = [
2197 "byteorder-lite",
2198 "quick-error",
2199]
2200
2201[[package]]
2202name = "imageproc"
2203version = "0.25.1"
2204source = "registry+https://github.com/rust-lang/crates.io-index"
2205checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77"
2206dependencies = [
2207 "ab_glyph",
2208 "approx",
2209 "getrandom 0.2.17",
2210 "image",
2211 "itertools",
2212 "nalgebra",
2213 "num",
2214 "rand 0.8.6",
2215 "rand_distr",
2216 "rayon",
2217]
2218
2219[[package]]
2220name = "imgref"
2221version = "1.12.2"
2222source = "registry+https://github.com/rust-lang/crates.io-index"
2223checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
2224
2225[[package]]
2226name = "indexmap"
2227version = "2.14.0"
2228source = "registry+https://github.com/rust-lang/crates.io-index"
2229checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
2230dependencies = [
2231 "equivalent",
2232 "hashbrown 0.17.1",
2233]
2234
2235[[package]]
2236name = "inout"
2237version = "0.1.4"
2238source = "registry+https://github.com/rust-lang/crates.io-index"
2239checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
2240dependencies = [
2241 "generic-array",
2242]
2243
2244[[package]]
2245name = "instant"
2246version = "0.1.13"
2247source = "registry+https://github.com/rust-lang/crates.io-index"
2248checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
2249dependencies = [
2250 "cfg-if",
2251 "js-sys",
2252 "wasm-bindgen",
2253 "web-sys",
2254]
2255
2256[[package]]
2257name = "interpolate_name"
2258version = "0.2.4"
2259source = "registry+https://github.com/rust-lang/crates.io-index"
2260checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
2261dependencies = [
2262 "proc-macro2",
2263 "quote",
2264 "syn 2.0.118",
2265]
2266
2267[[package]]
2268name = "ipnet"
2269version = "2.12.0"
2270source = "registry+https://github.com/rust-lang/crates.io-index"
2271checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
2272
2273[[package]]
2274name = "itertools"
2275version = "0.12.1"
2276source = "registry+https://github.com/rust-lang/crates.io-index"
2277checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
2278dependencies = [
2279 "either",
2280]
2281
2282[[package]]
2283name = "itoa"
2284version = "1.0.18"
2285source = "registry+https://github.com/rust-lang/crates.io-index"
2286checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2287
2288[[package]]
2289name = "jobserver"
2290version = "0.1.34"
2291source = "registry+https://github.com/rust-lang/crates.io-index"
2292checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
2293dependencies = [
2294 "getrandom 0.3.4",
2295 "libc",
2296]
2297
2298[[package]]
2299name = "js-sys"
2300version = "0.3.102"
2301source = "registry+https://github.com/rust-lang/crates.io-index"
2302checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
2303dependencies = [
2304 "cfg-if",
2305 "futures-util",
2306 "wasm-bindgen",
2307]
2308
2309[[package]]
2310name = "k256"
2311version = "0.13.4"
2312source = "registry+https://github.com/rust-lang/crates.io-index"
2313checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
2314dependencies = [
2315 "cfg-if",
2316 "elliptic-curve",
2317]
2318
2319[[package]]
2320name = "lazy_static"
2321version = "1.5.0"
2322source = "registry+https://github.com/rust-lang/crates.io-index"
2323checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2324
2325[[package]]
2326name = "lebe"
2327version = "0.5.3"
2328source = "registry+https://github.com/rust-lang/crates.io-index"
2329checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
2330
2331[[package]]
2332name = "levenshtein"
2333version = "1.0.5"
2334source = "registry+https://github.com/rust-lang/crates.io-index"
2335checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760"
2336
2337[[package]]
2338name = "libc"
2339version = "0.2.186"
2340source = "registry+https://github.com/rust-lang/crates.io-index"
2341checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
2342
2343[[package]]
2344name = "libcrux-aead"
2345version = "0.0.7"
2346source = "registry+https://github.com/rust-lang/crates.io-index"
2347checksum = "13297ce29869a5c0edab0378837b0fc5f88bf99a843712d9201c3b1150b3b476"
2348dependencies = [
2349 "libcrux-aesgcm",
2350 "libcrux-chacha20poly1305",
2351 "libcrux-secrets",
2352 "libcrux-traits",
2353]
2354
2355[[package]]
2356name = "libcrux-aesgcm"
2357version = "0.0.7"
2358source = "registry+https://github.com/rust-lang/crates.io-index"
2359checksum = "99f2a019dab4097585a7d4f5b9deebe46cd1e628b16a5bc4cb0ce35e1da334e6"
2360dependencies = [
2361 "libcrux-intrinsics",
2362 "libcrux-platform",
2363 "libcrux-secrets",
2364 "libcrux-traits",
2365]
2366
2367[[package]]
2368name = "libcrux-chacha20poly1305"
2369version = "0.0.7"
2370source = "registry+https://github.com/rust-lang/crates.io-index"
2371checksum = "cc08d044676af21343b32b988411fa98dbb5cf65a03c9df478ced221bbdfdb1b"
2372dependencies = [
2373 "libcrux-hacl-rs",
2374 "libcrux-macros",
2375 "libcrux-poly1305",
2376 "libcrux-secrets",
2377 "libcrux-traits",
2378]
2379
2380[[package]]
2381name = "libcrux-curve25519"
2382version = "0.0.6"
2383source = "registry+https://github.com/rust-lang/crates.io-index"
2384checksum = "bb1e5fd8476a6ed609d24ef42aee5ab6f99f7c65d054f92412da9f499e423299"
2385dependencies = [
2386 "libcrux-hacl-rs",
2387 "libcrux-macros",
2388 "libcrux-secrets",
2389 "libcrux-traits",
2390]
2391
2392[[package]]
2393name = "libcrux-ecdh"
2394version = "0.0.6"
2395source = "registry+https://github.com/rust-lang/crates.io-index"
2396checksum = "b65f73ce79337c762eb38bbac91e4c9b9e60cf318e8501b812750c640814d45e"
2397dependencies = [
2398 "libcrux-curve25519",
2399 "libcrux-p256",
2400 "rand 0.9.4",
2401]
2402
2403[[package]]
2404name = "libcrux-hacl-rs"
2405version = "0.0.4"
2406source = "registry+https://github.com/rust-lang/crates.io-index"
2407checksum = "2637dc87d158e1f1b550fd9b226443e84153fded4de69028d897b534d16d22e6"
2408dependencies = [
2409 "libcrux-macros",
2410]
2411
2412[[package]]
2413name = "libcrux-hkdf"
2414version = "0.0.6"
2415source = "registry+https://github.com/rust-lang/crates.io-index"
2416checksum = "8c1a89ca0c89be3a268a921e47105fb7873badf7267f5e3ebf4ea46baedd73ef"
2417dependencies = [
2418 "libcrux-hacl-rs",
2419 "libcrux-hmac",
2420 "libcrux-secrets",
2421]
2422
2423[[package]]
2424name = "libcrux-hmac"
2425version = "0.0.6"
2426source = "registry+https://github.com/rust-lang/crates.io-index"
2427checksum = "8a7a242707d65960770bd7e14e4f18a92bdf0b967777dd404887db8d087a643b"
2428dependencies = [
2429 "libcrux-hacl-rs",
2430 "libcrux-macros",
2431 "libcrux-sha2",
2432]
2433
2434[[package]]
2435name = "libcrux-intrinsics"
2436version = "0.0.6"
2437source = "registry+https://github.com/rust-lang/crates.io-index"
2438checksum = "b1b5db005ff8001e026b73a6842ee81bbef8ec5ff0e1915a67ae65fd2a9fafa5"
2439dependencies = [
2440 "core-models",
2441 "hax-lib",
2442]
2443
2444[[package]]
2445name = "libcrux-kem"
2446version = "0.0.7"
2447source = "registry+https://github.com/rust-lang/crates.io-index"
2448checksum = "12631592f491d22fd1a176d32b2c6edfb673998fd3987e9d95f8fa79ad2a737b"
2449dependencies = [
2450 "libcrux-curve25519",
2451 "libcrux-ecdh",
2452 "libcrux-ml-kem",
2453 "libcrux-p256",
2454 "libcrux-sha3",
2455 "libcrux-traits",
2456 "rand 0.9.4",
2457]
2458
2459[[package]]
2460name = "libcrux-macros"
2461version = "0.0.3"
2462source = "registry+https://github.com/rust-lang/crates.io-index"
2463checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338"
2464dependencies = [
2465 "quote",
2466 "syn 2.0.118",
2467]
2468
2469[[package]]
2470name = "libcrux-ml-kem"
2471version = "0.0.8"
2472source = "registry+https://github.com/rust-lang/crates.io-index"
2473checksum = "a14ab3e477de9df6ee1273a114018ff62c4996ca9220070c4e5cb1743f94a67d"
2474dependencies = [
2475 "hax-lib",
2476 "libcrux-intrinsics",
2477 "libcrux-platform",
2478 "libcrux-secrets",
2479 "libcrux-sha3",
2480 "libcrux-traits",
2481 "rand 0.9.4",
2482 "tls_codec",
2483]
2484
2485[[package]]
2486name = "libcrux-p256"
2487version = "0.0.6"
2488source = "registry+https://github.com/rust-lang/crates.io-index"
2489checksum = "f4778ba25cb08bb8a96bd100e19ed9aecf78337198fd176036e21042b2dd99bc"
2490dependencies = [
2491 "libcrux-hacl-rs",
2492 "libcrux-macros",
2493 "libcrux-secrets",
2494 "libcrux-sha2",
2495 "libcrux-traits",
2496]
2497
2498[[package]]
2499name = "libcrux-platform"
2500version = "0.0.3"
2501source = "registry+https://github.com/rust-lang/crates.io-index"
2502checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4"
2503dependencies = [
2504 "libc",
2505]
2506
2507[[package]]
2508name = "libcrux-poly1305"
2509version = "0.0.5"
2510source = "registry+https://github.com/rust-lang/crates.io-index"
2511checksum = "02491808ee5b9db8cb65fad64ae0be812db64beef179d945c00c7787dc7dfcf9"
2512dependencies = [
2513 "libcrux-hacl-rs",
2514 "libcrux-macros",
2515]
2516
2517[[package]]
2518name = "libcrux-secrets"
2519version = "0.0.5"
2520source = "registry+https://github.com/rust-lang/crates.io-index"
2521checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b"
2522dependencies = [
2523 "hax-lib",
2524]
2525
2526[[package]]
2527name = "libcrux-sha2"
2528version = "0.0.6"
2529source = "registry+https://github.com/rust-lang/crates.io-index"
2530checksum = "e9d253473f259fc74a280c43f29c464f7e374abdf28b4942234dc707f529d4b7"
2531dependencies = [
2532 "libcrux-hacl-rs",
2533 "libcrux-macros",
2534 "libcrux-traits",
2535]
2536
2537[[package]]
2538name = "libcrux-sha3"
2539version = "0.0.8"
2540source = "registry+https://github.com/rust-lang/crates.io-index"
2541checksum = "b1ae0b7d0e1cc4793a609fd0ff2ca3b3a3fabae523770c619a3d4bc86417b0d7"
2542dependencies = [
2543 "hax-lib",
2544 "libcrux-intrinsics",
2545 "libcrux-platform",
2546 "libcrux-traits",
2547]
2548
2549[[package]]
2550name = "libcrux-traits"
2551version = "0.0.6"
2552source = "registry+https://github.com/rust-lang/crates.io-index"
2553checksum = "812e4fa89f3f5e34b47f928b22b1b78395a0d4ec23b1f583db635f128159d65f"
2554dependencies = [
2555 "libcrux-secrets",
2556 "rand 0.9.4",
2557]
2558
2559[[package]]
2560name = "libfuzzer-sys"
2561version = "0.4.13"
2562source = "registry+https://github.com/rust-lang/crates.io-index"
2563checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
2564dependencies = [
2565 "arbitrary",
2566 "cc",
2567]
2568
2569[[package]]
2570name = "libm"
2571version = "0.2.16"
2572source = "registry+https://github.com/rust-lang/crates.io-index"
2573checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
2574
2575[[package]]
2576name = "libopus_sys"
2577version = "0.3.3"
2578source = "registry+https://github.com/rust-lang/crates.io-index"
2579checksum = "b81c32f233fb2507347a93f97b9919493be9db7ec2249ce2c3ed0c89ad8edf60"
2580dependencies = [
2581 "cmake",
2582 "log",
2583 "pkg-config",
2584]
2585
2586[[package]]
2587name = "libsqlite3-sys"
2588version = "0.30.1"
2589source = "registry+https://github.com/rust-lang/crates.io-index"
2590checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
2591dependencies = [
2592 "cc",
2593 "pkg-config",
2594 "vcpkg",
2595]
2596
2597[[package]]
2598name = "linux-raw-sys"
2599version = "0.12.1"
2600source = "registry+https://github.com/rust-lang/crates.io-index"
2601checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
2602
2603[[package]]
2604name = "litemap"
2605version = "0.8.2"
2606source = "registry+https://github.com/rust-lang/crates.io-index"
2607checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
2608
2609[[package]]
2610name = "lock_api"
2611version = "0.4.14"
2612source = "registry+https://github.com/rust-lang/crates.io-index"
2613checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
2614dependencies = [
2615 "scopeguard",
2616]
2617
2618[[package]]
2619name = "log"
2620version = "0.4.32"
2621source = "registry+https://github.com/rust-lang/crates.io-index"
2622checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
2623
2624[[package]]
2625name = "loom"
2626version = "0.5.6"
2627source = "registry+https://github.com/rust-lang/crates.io-index"
2628checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
2629dependencies = [
2630 "cfg-if",
2631 "generator",
2632 "scoped-tls",
2633 "serde",
2634 "serde_json",
2635 "tracing",
2636 "tracing-subscriber",
2637]
2638
2639[[package]]
2640name = "loop9"
2641version = "0.1.5"
2642source = "registry+https://github.com/rust-lang/crates.io-index"
2643checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
2644dependencies = [
2645 "imgref",
2646]
2647
2648[[package]]
2649name = "lru-slab"
2650version = "0.1.2"
2651source = "registry+https://github.com/rust-lang/crates.io-index"
2652checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
2653
2654[[package]]
2655name = "markup5ever"
2656version = "0.39.0"
2657source = "registry+https://github.com/rust-lang/crates.io-index"
2658checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
2659dependencies = [
2660 "log",
2661 "tendril",
2662 "web_atoms",
2663]
2664
2665[[package]]
2666name = "matchers"
2667version = "0.2.0"
2668source = "registry+https://github.com/rust-lang/crates.io-index"
2669checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2670dependencies = [
2671 "regex-automata",
2672]
2673
2674[[package]]
2675name = "matrixmultiply"
2676version = "0.3.10"
2677source = "registry+https://github.com/rust-lang/crates.io-index"
2678checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
2679dependencies = [
2680 "autocfg",
2681 "rawpointer",
2682]
2683
2684[[package]]
2685name = "maybe-rayon"
2686version = "0.1.1"
2687source = "registry+https://github.com/rust-lang/crates.io-index"
2688checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
2689dependencies = [
2690 "cfg-if",
2691 "rayon",
2692]
2693
2694[[package]]
2695name = "md-5"
2696version = "0.11.0"
2697source = "registry+https://github.com/rust-lang/crates.io-index"
2698checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
2699dependencies = [
2700 "cfg-if",
2701 "digest 0.11.3",
2702]
2703
2704[[package]]
2705name = "memchr"
2706version = "2.8.2"
2707source = "registry+https://github.com/rust-lang/crates.io-index"
2708checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
2709
2710[[package]]
2711name = "mime"
2712version = "0.3.17"
2713source = "registry+https://github.com/rust-lang/crates.io-index"
2714checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2715
2716[[package]]
2717name = "mime_guess"
2718version = "2.0.5"
2719source = "registry+https://github.com/rust-lang/crates.io-index"
2720checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2721dependencies = [
2722 "mime",
2723 "unicase",
2724]
2725
2726[[package]]
2727name = "mini-moka"
2728version = "0.10.3"
2729source = "registry+https://github.com/rust-lang/crates.io-index"
2730checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803"
2731dependencies = [
2732 "crossbeam-channel",
2733 "crossbeam-utils",
2734 "dashmap 5.5.3",
2735 "skeptic",
2736 "smallvec",
2737 "tagptr",
2738 "triomphe",
2739]
2740
2741[[package]]
2742name = "miniz_oxide"
2743version = "0.8.9"
2744source = "registry+https://github.com/rust-lang/crates.io-index"
2745checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
2746dependencies = [
2747 "adler2",
2748 "simd-adler32",
2749]
2750
2751[[package]]
2752name = "mio"
2753version = "1.2.1"
2754source = "registry+https://github.com/rust-lang/crates.io-index"
2755checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
2756dependencies = [
2757 "libc",
2758 "wasi",
2759 "windows-sys 0.61.2",
2760]
2761
2762[[package]]
2763name = "moxcms"
2764version = "0.7.11"
2765source = "registry+https://github.com/rust-lang/crates.io-index"
2766checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
2767dependencies = [
2768 "num-traits",
2769 "pxfm",
2770]
2771
2772[[package]]
2773name = "multimap"
2774version = "0.10.1"
2775source = "registry+https://github.com/rust-lang/crates.io-index"
2776checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
2777dependencies = [
2778 "serde",
2779]
2780
2781[[package]]
2782name = "nalgebra"
2783version = "0.32.6"
2784source = "registry+https://github.com/rust-lang/crates.io-index"
2785checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
2786dependencies = [
2787 "approx",
2788 "matrixmultiply",
2789 "num-complex",
2790 "num-rational",
2791 "num-traits",
2792 "simba",
2793 "typenum",
2794]
2795
2796[[package]]
2797name = "nanorand"
2798version = "0.7.0"
2799source = "registry+https://github.com/rust-lang/crates.io-index"
2800checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
2801dependencies = [
2802 "getrandom 0.2.17",
2803]
2804
2805[[package]]
2806name = "native-tls"
2807version = "0.2.18"
2808source = "registry+https://github.com/rust-lang/crates.io-index"
2809checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
2810dependencies = [
2811 "libc",
2812 "log",
2813 "openssl",
2814 "openssl-probe",
2815 "openssl-sys",
2816 "schannel",
2817 "security-framework",
2818 "security-framework-sys",
2819 "tempfile",
2820]
2821
2822[[package]]
2823name = "new_debug_unreachable"
2824version = "1.0.6"
2825source = "registry+https://github.com/rust-lang/crates.io-index"
2826checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2827
2828[[package]]
2829name = "no-std-net"
2830version = "0.6.0"
2831source = "registry+https://github.com/rust-lang/crates.io-index"
2832checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
2833
2834[[package]]
2835name = "nohash-hasher"
2836version = "0.2.0"
2837source = "registry+https://github.com/rust-lang/crates.io-index"
2838checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
2839
2840[[package]]
2841name = "nom"
2842version = "8.0.0"
2843source = "registry+https://github.com/rust-lang/crates.io-index"
2844checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
2845dependencies = [
2846 "memchr",
2847]
2848
2849[[package]]
2850name = "nonmax"
2851version = "0.5.5"
2852source = "registry+https://github.com/rust-lang/crates.io-index"
2853checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
2854
2855[[package]]
2856name = "noop_proc_macro"
2857version = "0.3.0"
2858source = "registry+https://github.com/rust-lang/crates.io-index"
2859checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
2860
2861[[package]]
2862name = "nu-ansi-term"
2863version = "0.50.3"
2864source = "registry+https://github.com/rust-lang/crates.io-index"
2865checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2866dependencies = [
2867 "windows-sys 0.61.2",
2868]
2869
2870[[package]]
2871name = "num"
2872version = "0.4.3"
2873source = "registry+https://github.com/rust-lang/crates.io-index"
2874checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
2875dependencies = [
2876 "num-bigint",
2877 "num-complex",
2878 "num-integer",
2879 "num-iter",
2880 "num-rational",
2881 "num-traits",
2882]
2883
2884[[package]]
2885name = "num-bigint"
2886version = "0.4.6"
2887source = "registry+https://github.com/rust-lang/crates.io-index"
2888checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
2889dependencies = [
2890 "num-integer",
2891 "num-traits",
2892]
2893
2894[[package]]
2895name = "num-complex"
2896version = "0.4.6"
2897source = "registry+https://github.com/rust-lang/crates.io-index"
2898checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
2899dependencies = [
2900 "num-traits",
2901]
2902
2903[[package]]
2904name = "num-conv"
2905version = "0.2.2"
2906source = "registry+https://github.com/rust-lang/crates.io-index"
2907checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2908
2909[[package]]
2910name = "num-derive"
2911version = "0.4.2"
2912source = "registry+https://github.com/rust-lang/crates.io-index"
2913checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
2914dependencies = [
2915 "proc-macro2",
2916 "quote",
2917 "syn 2.0.118",
2918]
2919
2920[[package]]
2921name = "num-integer"
2922version = "0.1.46"
2923source = "registry+https://github.com/rust-lang/crates.io-index"
2924checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
2925dependencies = [
2926 "num-traits",
2927]
2928
2929[[package]]
2930name = "num-iter"
2931version = "0.1.45"
2932source = "registry+https://github.com/rust-lang/crates.io-index"
2933checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
2934dependencies = [
2935 "autocfg",
2936 "num-integer",
2937 "num-traits",
2938]
2939
2940[[package]]
2941name = "num-rational"
2942version = "0.4.2"
2943source = "registry+https://github.com/rust-lang/crates.io-index"
2944checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
2945dependencies = [
2946 "num-bigint",
2947 "num-integer",
2948 "num-traits",
2949]
2950
2951[[package]]
2952name = "num-traits"
2953version = "0.2.19"
2954source = "registry+https://github.com/rust-lang/crates.io-index"
2955checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2956dependencies = [
2957 "autocfg",
2958 "libm",
2959]
2960
2961[[package]]
2962name = "num_cpus"
2963version = "1.17.0"
2964source = "registry+https://github.com/rust-lang/crates.io-index"
2965checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2966dependencies = [
2967 "hermit-abi",
2968 "libc",
2969]
2970
2971[[package]]
2972name = "once_cell"
2973version = "1.21.4"
2974source = "registry+https://github.com/rust-lang/crates.io-index"
2975checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2976
2977[[package]]
2978name = "opaque-debug"
2979version = "0.3.1"
2980source = "registry+https://github.com/rust-lang/crates.io-index"
2981checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2982
2983[[package]]
2984name = "openmls"
2985version = "0.8.1"
2986source = "registry+https://github.com/rust-lang/crates.io-index"
2987checksum = "dcb512bfe6a55777518853ea535c6241f069cb0e8984678c117151d2a1e7e903"
2988dependencies = [
2989 "fluvio-wasm-timer",
2990 "getrandom 0.3.4",
2991 "log",
2992 "openmls_traits",
2993 "rayon",
2994 "serde",
2995 "serde_bytes",
2996 "thiserror 2.0.18",
2997 "tls_codec",
2998 "zeroize",
2999]
3000
3001[[package]]
3002name = "openmls_basic_credential"
3003version = "0.5.0"
3004source = "registry+https://github.com/rust-lang/crates.io-index"
3005checksum = "983e8be1457dd6f316f409292cec334af3b57b49a19deadc925c83c3c35e15b6"
3006dependencies = [
3007 "ed25519-dalek",
3008 "openmls_traits",
3009 "p256",
3010 "rand 0.8.6",
3011 "serde",
3012 "tls_codec",
3013]
3014
3015[[package]]
3016name = "openmls_memory_storage"
3017version = "0.5.0"
3018source = "registry+https://github.com/rust-lang/crates.io-index"
3019checksum = "1a52c927ddb9940acb96d51aebd54b8b9c601c7119e6609622fb3f2cbe16abe3"
3020dependencies = [
3021 "log",
3022 "openmls_traits",
3023 "serde",
3024 "serde_json",
3025 "thiserror 2.0.18",
3026]
3027
3028[[package]]
3029name = "openmls_rust_crypto"
3030version = "0.5.1"
3031source = "registry+https://github.com/rust-lang/crates.io-index"
3032checksum = "fafcc8a3552b10fbb3ab757cccaf1a34081e826ca819f49aa7e6645b1d95c00f"
3033dependencies = [
3034 "aes-gcm",
3035 "chacha20poly1305",
3036 "ed25519-dalek",
3037 "hkdf 0.12.4",
3038 "hmac 0.12.1",
3039 "hpke-rs",
3040 "hpke-rs-crypto",
3041 "hpke-rs-rust-crypto",
3042 "openmls_memory_storage",
3043 "openmls_traits",
3044 "p256",
3045 "rand 0.8.6",
3046 "rand_chacha 0.3.1",
3047 "serde",
3048 "sha2 0.10.9",
3049 "thiserror 2.0.18",
3050 "tls_codec",
3051]
3052
3053[[package]]
3054name = "openmls_traits"
3055version = "0.5.0"
3056source = "registry+https://github.com/rust-lang/crates.io-index"
3057checksum = "4f88ccdd53448dfdbfa5b8da8ba4e527c418fdb966418172bace2e3b41eedd56"
3058dependencies = [
3059 "serde",
3060 "tls_codec",
3061]
3062
3063[[package]]
3064name = "openssl"
3065version = "0.10.81"
3066source = "registry+https://github.com/rust-lang/crates.io-index"
3067checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
3068dependencies = [
3069 "bitflags 2.13.0",
3070 "cfg-if",
3071 "foreign-types",
3072 "libc",
3073 "openssl-macros",
3074 "openssl-sys",
3075]
3076
3077[[package]]
3078name = "openssl-macros"
3079version = "0.1.1"
3080source = "registry+https://github.com/rust-lang/crates.io-index"
3081checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
3082dependencies = [
3083 "proc-macro2",
3084 "quote",
3085 "syn 2.0.118",
3086]
3087
3088[[package]]
3089name = "openssl-probe"
3090version = "0.2.1"
3091source = "registry+https://github.com/rust-lang/crates.io-index"
3092checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
3093
3094[[package]]
3095name = "openssl-sys"
3096version = "0.9.117"
3097source = "registry+https://github.com/rust-lang/crates.io-index"
3098checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
3099dependencies = [
3100 "cc",
3101 "libc",
3102 "pkg-config",
3103 "vcpkg",
3104]
3105
3106[[package]]
3107name = "opus2"
3108version = "0.4.0"
3109source = "registry+https://github.com/rust-lang/crates.io-index"
3110checksum = "49521e33fbf825d2abc8d696506c278cb8469d0c6cc05d3785bef5c5f7ee957b"
3111dependencies = [
3112 "libopus_sys",
3113]
3114
3115[[package]]
3116name = "ordered-float"
3117version = "2.10.1"
3118source = "registry+https://github.com/rust-lang/crates.io-index"
3119checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
3120dependencies = [
3121 "num-traits",
3122]
3123
3124[[package]]
3125name = "owned_ttf_parser"
3126version = "0.25.1"
3127source = "registry+https://github.com/rust-lang/crates.io-index"
3128checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
3129dependencies = [
3130 "ttf-parser",
3131]
3132
3133[[package]]
3134name = "p256"
3135version = "0.13.2"
3136source = "registry+https://github.com/rust-lang/crates.io-index"
3137checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
3138dependencies = [
3139 "ecdsa",
3140 "elliptic-curve",
3141 "primeorder",
3142 "sha2 0.10.9",
3143]
3144
3145[[package]]
3146name = "p384"
3147version = "0.13.1"
3148source = "registry+https://github.com/rust-lang/crates.io-index"
3149checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
3150dependencies = [
3151 "elliptic-curve",
3152 "primeorder",
3153]
3154
3155[[package]]
3156name = "parking"
3157version = "2.2.1"
3158source = "registry+https://github.com/rust-lang/crates.io-index"
3159checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
3160
3161[[package]]
3162name = "parking_lot"
3163version = "0.11.2"
3164source = "registry+https://github.com/rust-lang/crates.io-index"
3165checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
3166dependencies = [
3167 "instant",
3168 "lock_api",
3169 "parking_lot_core 0.8.6",
3170]
3171
3172[[package]]
3173name = "parking_lot"
3174version = "0.12.5"
3175source = "registry+https://github.com/rust-lang/crates.io-index"
3176checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
3177dependencies = [
3178 "lock_api",
3179 "parking_lot_core 0.9.12",
3180]
3181
3182[[package]]
3183name = "parking_lot_core"
3184version = "0.8.6"
3185source = "registry+https://github.com/rust-lang/crates.io-index"
3186checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
3187dependencies = [
3188 "cfg-if",
3189 "instant",
3190 "libc",
3191 "redox_syscall 0.2.16",
3192 "smallvec",
3193 "winapi",
3194]
3195
3196[[package]]
3197name = "parking_lot_core"
3198version = "0.9.12"
3199source = "registry+https://github.com/rust-lang/crates.io-index"
3200checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
3201dependencies = [
3202 "cfg-if",
3203 "libc",
3204 "redox_syscall 0.5.18",
3205 "smallvec",
3206 "windows-link",
3207]
3208
3209[[package]]
3210name = "paste"
3211version = "1.0.15"
3212source = "registry+https://github.com/rust-lang/crates.io-index"
3213checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
3214
3215[[package]]
3216name = "pastey"
3217version = "0.2.3"
3218source = "registry+https://github.com/rust-lang/crates.io-index"
3219checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
3220
3221[[package]]
3222name = "patricia_tree"
3223version = "0.8.0"
3224source = "registry+https://github.com/rust-lang/crates.io-index"
3225checksum = "31f2f4539bffe53fc4b4da301df49d114b845b077bd5727b7fe2bd9d8df2ae68"
3226dependencies = [
3227 "bitflags 2.13.0",
3228]
3229
3230[[package]]
3231name = "pbkdf2"
3232version = "0.12.2"
3233source = "registry+https://github.com/rust-lang/crates.io-index"
3234checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
3235dependencies = [
3236 "digest 0.10.7",
3237 "hmac 0.12.1",
3238]
3239
3240[[package]]
3241name = "pem-rfc7468"
3242version = "0.7.0"
3243source = "registry+https://github.com/rust-lang/crates.io-index"
3244checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
3245dependencies = [
3246 "base64ct",
3247]
3248
3249[[package]]
3250name = "percent-encoding"
3251version = "2.3.2"
3252source = "registry+https://github.com/rust-lang/crates.io-index"
3253checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
3254
3255[[package]]
3256name = "phf"
3257version = "0.11.3"
3258source = "registry+https://github.com/rust-lang/crates.io-index"
3259checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
3260dependencies = [
3261 "phf_macros 0.11.3",
3262 "phf_shared 0.11.3",
3263]
3264
3265[[package]]
3266name = "phf"
3267version = "0.12.1"
3268source = "registry+https://github.com/rust-lang/crates.io-index"
3269checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
3270dependencies = [
3271 "phf_shared 0.12.1",
3272]
3273
3274[[package]]
3275name = "phf"
3276version = "0.13.1"
3277source = "registry+https://github.com/rust-lang/crates.io-index"
3278checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
3279dependencies = [
3280 "phf_macros 0.13.1",
3281 "phf_shared 0.13.1",
3282 "serde",
3283]
3284
3285[[package]]
3286name = "phf_codegen"
3287version = "0.13.1"
3288source = "registry+https://github.com/rust-lang/crates.io-index"
3289checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
3290dependencies = [
3291 "phf_generator 0.13.1",
3292 "phf_shared 0.13.1",
3293]
3294
3295[[package]]
3296name = "phf_generator"
3297version = "0.11.3"
3298source = "registry+https://github.com/rust-lang/crates.io-index"
3299checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
3300dependencies = [
3301 "phf_shared 0.11.3",
3302 "rand 0.8.6",
3303]
3304
3305[[package]]
3306name = "phf_generator"
3307version = "0.13.1"
3308source = "registry+https://github.com/rust-lang/crates.io-index"
3309checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
3310dependencies = [
3311 "fastrand",
3312 "phf_shared 0.13.1",
3313]
3314
3315[[package]]
3316name = "phf_macros"
3317version = "0.11.3"
3318source = "registry+https://github.com/rust-lang/crates.io-index"
3319checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
3320dependencies = [
3321 "phf_generator 0.11.3",
3322 "phf_shared 0.11.3",
3323 "proc-macro2",
3324 "quote",
3325 "syn 2.0.118",
3326]
3327
3328[[package]]
3329name = "phf_macros"
3330version = "0.13.1"
3331source = "registry+https://github.com/rust-lang/crates.io-index"
3332checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
3333dependencies = [
3334 "phf_generator 0.13.1",
3335 "phf_shared 0.13.1",
3336 "proc-macro2",
3337 "quote",
3338 "syn 2.0.118",
3339]
3340
3341[[package]]
3342name = "phf_shared"
3343version = "0.11.3"
3344source = "registry+https://github.com/rust-lang/crates.io-index"
3345checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
3346dependencies = [
3347 "siphasher",
3348]
3349
3350[[package]]
3351name = "phf_shared"
3352version = "0.12.1"
3353source = "registry+https://github.com/rust-lang/crates.io-index"
3354checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
3355dependencies = [
3356 "siphasher",
3357]
3358
3359[[package]]
3360name = "phf_shared"
3361version = "0.13.1"
3362source = "registry+https://github.com/rust-lang/crates.io-index"
3363checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
3364dependencies = [
3365 "siphasher",
3366]
3367
3368[[package]]
3369name = "pin-project"
3370version = "1.1.13"
3371source = "registry+https://github.com/rust-lang/crates.io-index"
3372checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
3373dependencies = [
3374 "pin-project-internal",
3375]
3376
3377[[package]]
3378name = "pin-project-internal"
3379version = "1.1.13"
3380source = "registry+https://github.com/rust-lang/crates.io-index"
3381checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
3382dependencies = [
3383 "proc-macro2",
3384 "quote",
3385 "syn 2.0.118",
3386]
3387
3388[[package]]
3389name = "pin-project-lite"
3390version = "0.2.17"
3391source = "registry+https://github.com/rust-lang/crates.io-index"
3392checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
3393
3394[[package]]
3395name = "pin-utils"
3396version = "0.1.0"
3397source = "registry+https://github.com/rust-lang/crates.io-index"
3398checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
3399
3400[[package]]
3401name = "pkcs8"
3402version = "0.10.2"
3403source = "registry+https://github.com/rust-lang/crates.io-index"
3404checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
3405dependencies = [
3406 "der",
3407 "spki",
3408]
3409
3410[[package]]
3411name = "pkg-config"
3412version = "0.3.33"
3413source = "registry+https://github.com/rust-lang/crates.io-index"
3414checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
3415
3416[[package]]
3417name = "pnet_base"
3418version = "0.34.0"
3419source = "registry+https://github.com/rust-lang/crates.io-index"
3420checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c"
3421dependencies = [
3422 "no-std-net",
3423]
3424
3425[[package]]
3426name = "pnet_macros"
3427version = "0.34.0"
3428source = "registry+https://github.com/rust-lang/crates.io-index"
3429checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804"
3430dependencies = [
3431 "proc-macro2",
3432 "quote",
3433 "regex",
3434 "syn 2.0.118",
3435]
3436
3437[[package]]
3438name = "pnet_macros_support"
3439version = "0.34.0"
3440source = "registry+https://github.com/rust-lang/crates.io-index"
3441checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56"
3442dependencies = [
3443 "pnet_base",
3444]
3445
3446[[package]]
3447name = "png"
3448version = "0.18.1"
3449source = "registry+https://github.com/rust-lang/crates.io-index"
3450checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
3451dependencies = [
3452 "bitflags 2.13.0",
3453 "crc32fast",
3454 "fdeflate",
3455 "flate2",
3456 "miniz_oxide",
3457]
3458
3459[[package]]
3460name = "poise"
3461version = "0.6.2"
3462source = "registry+https://github.com/rust-lang/crates.io-index"
3463checksum = "711433114bca9ff68582aa58af5cbcf573e83250a8714c2dca1c36f453cad3ae"
3464dependencies = [
3465 "async-trait",
3466 "derivative",
3467 "futures-util",
3468 "indexmap",
3469 "parking_lot 0.12.5",
3470 "regex",
3471 "serenity",
3472 "serenity_poise_macros",
3473 "tokio",
3474 "tracing",
3475 "trim-in-place",
3476]
3477
3478[[package]]
3479name = "poly1305"
3480version = "0.8.0"
3481source = "registry+https://github.com/rust-lang/crates.io-index"
3482checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
3483dependencies = [
3484 "cpufeatures 0.2.17",
3485 "opaque-debug",
3486 "universal-hash",
3487]
3488
3489[[package]]
3490name = "polyval"
3491version = "0.6.2"
3492source = "registry+https://github.com/rust-lang/crates.io-index"
3493checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
3494dependencies = [
3495 "cfg-if",
3496 "cpufeatures 0.2.17",
3497 "opaque-debug",
3498 "universal-hash",
3499]
3500
3501[[package]]
3502name = "portable-atomic"
3503version = "1.13.1"
3504source = "registry+https://github.com/rust-lang/crates.io-index"
3505checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
3506
3507[[package]]
3508name = "portable-atomic-util"
3509version = "0.2.7"
3510source = "registry+https://github.com/rust-lang/crates.io-index"
3511checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
3512dependencies = [
3513 "portable-atomic",
3514]
3515
3516[[package]]
3517name = "potential_utf"
3518version = "0.1.5"
3519source = "registry+https://github.com/rust-lang/crates.io-index"
3520checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
3521dependencies = [
3522 "zerovec",
3523]
3524
3525[[package]]
3526name = "powerfmt"
3527version = "0.2.0"
3528source = "registry+https://github.com/rust-lang/crates.io-index"
3529checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
3530
3531[[package]]
3532name = "ppv-lite86"
3533version = "0.2.21"
3534source = "registry+https://github.com/rust-lang/crates.io-index"
3535checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
3536dependencies = [
3537 "zerocopy",
3538]
3539
3540[[package]]
3541name = "precomputed-hash"
3542version = "0.1.1"
3543source = "registry+https://github.com/rust-lang/crates.io-index"
3544checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
3545
3546[[package]]
3547name = "primal-check"
3548version = "0.3.4"
3549source = "registry+https://github.com/rust-lang/crates.io-index"
3550checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
3551dependencies = [
3552 "num-integer",
3553]
3554
3555[[package]]
3556name = "primeorder"
3557version = "0.13.6"
3558source = "registry+https://github.com/rust-lang/crates.io-index"
3559checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
3560dependencies = [
3561 "elliptic-curve",
3562]
3563
3564[[package]]
3565name = "proc-macro-error-attr2"
3566version = "2.0.0"
3567source = "registry+https://github.com/rust-lang/crates.io-index"
3568checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
3569dependencies = [
3570 "proc-macro2",
3571 "quote",
3572]
3573
3574[[package]]
3575name = "proc-macro-error2"
3576version = "2.0.1"
3577source = "registry+https://github.com/rust-lang/crates.io-index"
3578checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
3579dependencies = [
3580 "proc-macro-error-attr2",
3581 "proc-macro2",
3582 "quote",
3583 "syn 2.0.118",
3584]
3585
3586[[package]]
3587name = "proc-macro2"
3588version = "1.0.106"
3589source = "registry+https://github.com/rust-lang/crates.io-index"
3590checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
3591dependencies = [
3592 "unicode-ident",
3593]
3594
3595[[package]]
3596name = "profiling"
3597version = "1.0.18"
3598source = "registry+https://github.com/rust-lang/crates.io-index"
3599checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
3600dependencies = [
3601 "profiling-procmacros",
3602]
3603
3604[[package]]
3605name = "profiling-procmacros"
3606version = "1.0.18"
3607source = "registry+https://github.com/rust-lang/crates.io-index"
3608checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
3609dependencies = [
3610 "quote",
3611 "syn 2.0.118",
3612]
3613
3614[[package]]
3615name = "pulldown-cmark"
3616version = "0.9.6"
3617source = "registry+https://github.com/rust-lang/crates.io-index"
3618checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
3619dependencies = [
3620 "bitflags 2.13.0",
3621 "memchr",
3622 "unicase",
3623]
3624
3625[[package]]
3626name = "pxfm"
3627version = "0.1.29"
3628source = "registry+https://github.com/rust-lang/crates.io-index"
3629checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
3630
3631[[package]]
3632name = "qoi"
3633version = "0.4.1"
3634source = "registry+https://github.com/rust-lang/crates.io-index"
3635checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
3636dependencies = [
3637 "bytemuck",
3638]
3639
3640[[package]]
3641name = "qrcode"
3642version = "0.14.1"
3643source = "registry+https://github.com/rust-lang/crates.io-index"
3644checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
3645dependencies = [
3646 "image",
3647]
3648
3649[[package]]
3650name = "quick-error"
3651version = "2.0.1"
3652source = "registry+https://github.com/rust-lang/crates.io-index"
3653checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
3654
3655[[package]]
3656name = "quinn"
3657version = "0.11.9"
3658source = "registry+https://github.com/rust-lang/crates.io-index"
3659checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
3660dependencies = [
3661 "bytes",
3662 "cfg_aliases",
3663 "pin-project-lite",
3664 "quinn-proto",
3665 "quinn-udp",
3666 "rustc-hash",
3667 "rustls 0.23.40",
3668 "socket2 0.6.4",
3669 "thiserror 2.0.18",
3670 "tokio",
3671 "tracing",
3672 "web-time",
3673]
3674
3675[[package]]
3676name = "quinn-proto"
3677version = "0.11.14"
3678source = "registry+https://github.com/rust-lang/crates.io-index"
3679checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
3680dependencies = [
3681 "bytes",
3682 "getrandom 0.3.4",
3683 "lru-slab",
3684 "rand 0.9.4",
3685 "ring",
3686 "rustc-hash",
3687 "rustls 0.23.40",
3688 "rustls-pki-types",
3689 "slab",
3690 "thiserror 2.0.18",
3691 "tinyvec",
3692 "tracing",
3693 "web-time",
3694]
3695
3696[[package]]
3697name = "quinn-udp"
3698version = "0.5.14"
3699source = "registry+https://github.com/rust-lang/crates.io-index"
3700checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
3701dependencies = [
3702 "cfg_aliases",
3703 "libc",
3704 "once_cell",
3705 "socket2 0.6.4",
3706 "tracing",
3707 "windows-sys 0.60.2",
3708]
3709
3710[[package]]
3711name = "quote"
3712version = "1.0.45"
3713source = "registry+https://github.com/rust-lang/crates.io-index"
3714checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
3715dependencies = [
3716 "proc-macro2",
3717]
3718
3719[[package]]
3720name = "r-efi"
3721version = "5.3.0"
3722source = "registry+https://github.com/rust-lang/crates.io-index"
3723checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
3724
3725[[package]]
3726name = "r-efi"
3727version = "6.0.0"
3728source = "registry+https://github.com/rust-lang/crates.io-index"
3729checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
3730
3731[[package]]
3732name = "rand"
3733version = "0.8.6"
3734source = "registry+https://github.com/rust-lang/crates.io-index"
3735checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
3736dependencies = [
3737 "libc",
3738 "rand_chacha 0.3.1",
3739 "rand_core 0.6.4",
3740]
3741
3742[[package]]
3743name = "rand"
3744version = "0.9.4"
3745source = "registry+https://github.com/rust-lang/crates.io-index"
3746checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
3747dependencies = [
3748 "rand_chacha 0.9.0",
3749 "rand_core 0.9.5",
3750]
3751
3752[[package]]
3753name = "rand"
3754version = "0.10.1"
3755source = "registry+https://github.com/rust-lang/crates.io-index"
3756checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
3757dependencies = [
3758 "chacha20 0.10.0",
3759 "getrandom 0.4.3",
3760 "rand_core 0.10.1",
3761]
3762
3763[[package]]
3764name = "rand_chacha"
3765version = "0.3.1"
3766source = "registry+https://github.com/rust-lang/crates.io-index"
3767checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
3768dependencies = [
3769 "ppv-lite86",
3770 "rand_core 0.6.4",
3771]
3772
3773[[package]]
3774name = "rand_chacha"
3775version = "0.9.0"
3776source = "registry+https://github.com/rust-lang/crates.io-index"
3777checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3778dependencies = [
3779 "ppv-lite86",
3780 "rand_core 0.9.5",
3781]
3782
3783[[package]]
3784name = "rand_chacha"
3785version = "0.10.0"
3786source = "registry+https://github.com/rust-lang/crates.io-index"
3787checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
3788dependencies = [
3789 "ppv-lite86",
3790 "rand_core 0.10.1",
3791]
3792
3793[[package]]
3794name = "rand_core"
3795version = "0.6.4"
3796source = "registry+https://github.com/rust-lang/crates.io-index"
3797checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
3798dependencies = [
3799 "getrandom 0.2.17",
3800]
3801
3802[[package]]
3803name = "rand_core"
3804version = "0.9.5"
3805source = "registry+https://github.com/rust-lang/crates.io-index"
3806checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3807dependencies = [
3808 "getrandom 0.3.4",
3809]
3810
3811[[package]]
3812name = "rand_core"
3813version = "0.10.1"
3814source = "registry+https://github.com/rust-lang/crates.io-index"
3815checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
3816
3817[[package]]
3818name = "rand_distr"
3819version = "0.4.3"
3820source = "registry+https://github.com/rust-lang/crates.io-index"
3821checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
3822dependencies = [
3823 "num-traits",
3824 "rand 0.8.6",
3825]
3826
3827[[package]]
3828name = "rav1e"
3829version = "0.7.1"
3830source = "registry+https://github.com/rust-lang/crates.io-index"
3831checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
3832dependencies = [
3833 "arbitrary",
3834 "arg_enum_proc_macro",
3835 "arrayvec",
3836 "av1-grain",
3837 "bitstream-io",
3838 "built",
3839 "cfg-if",
3840 "interpolate_name",
3841 "itertools",
3842 "libc",
3843 "libfuzzer-sys",
3844 "log",
3845 "maybe-rayon",
3846 "new_debug_unreachable",
3847 "noop_proc_macro",
3848 "num-derive",
3849 "num-traits",
3850 "once_cell",
3851 "paste",
3852 "profiling",
3853 "rand 0.8.6",
3854 "rand_chacha 0.3.1",
3855 "simd_helpers",
3856 "system-deps",
3857 "thiserror 1.0.69",
3858 "v_frame",
3859 "wasm-bindgen",
3860]
3861
3862[[package]]
3863name = "ravif"
3864version = "0.11.20"
3865source = "registry+https://github.com/rust-lang/crates.io-index"
3866checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
3867dependencies = [
3868 "avif-serialize",
3869 "imgref",
3870 "loop9",
3871 "quick-error",
3872 "rav1e",
3873 "rayon",
3874 "rgb",
3875]
3876
3877[[package]]
3878name = "rawpointer"
3879version = "0.2.1"
3880source = "registry+https://github.com/rust-lang/crates.io-index"
3881checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
3882
3883[[package]]
3884name = "rayon"
3885version = "1.12.0"
3886source = "registry+https://github.com/rust-lang/crates.io-index"
3887checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
3888dependencies = [
3889 "either",
3890 "rayon-core",
3891]
3892
3893[[package]]
3894name = "rayon-core"
3895version = "1.13.0"
3896source = "registry+https://github.com/rust-lang/crates.io-index"
3897checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
3898dependencies = [
3899 "crossbeam-deque",
3900 "crossbeam-utils",
3901]
3902
3903[[package]]
3904name = "realfft"
3905version = "3.5.0"
3906source = "registry+https://github.com/rust-lang/crates.io-index"
3907checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
3908dependencies = [
3909 "rustfft",
3910]
3911
3912[[package]]
3913name = "redox_syscall"
3914version = "0.2.16"
3915source = "registry+https://github.com/rust-lang/crates.io-index"
3916checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
3917dependencies = [
3918 "bitflags 1.3.2",
3919]
3920
3921[[package]]
3922name = "redox_syscall"
3923version = "0.5.18"
3924source = "registry+https://github.com/rust-lang/crates.io-index"
3925checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
3926dependencies = [
3927 "bitflags 2.13.0",
3928]
3929
3930[[package]]
3931name = "regex"
3932version = "1.12.4"
3933source = "registry+https://github.com/rust-lang/crates.io-index"
3934checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
3935dependencies = [
3936 "aho-corasick",
3937 "memchr",
3938 "regex-automata",
3939 "regex-syntax",
3940]
3941
3942[[package]]
3943name = "regex-automata"
3944version = "0.4.14"
3945source = "registry+https://github.com/rust-lang/crates.io-index"
3946checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
3947dependencies = [
3948 "aho-corasick",
3949 "memchr",
3950 "regex-syntax",
3951]
3952
3953[[package]]
3954name = "regex-lite"
3955version = "0.1.9"
3956source = "registry+https://github.com/rust-lang/crates.io-index"
3957checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
3958
3959[[package]]
3960name = "regex-syntax"
3961version = "0.8.11"
3962source = "registry+https://github.com/rust-lang/crates.io-index"
3963checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
3964
3965[[package]]
3966name = "reqwest"
3967version = "0.12.28"
3968source = "registry+https://github.com/rust-lang/crates.io-index"
3969checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3970dependencies = [
3971 "base64",
3972 "bytes",
3973 "encoding_rs",
3974 "futures-core",
3975 "futures-util",
3976 "h2",
3977 "http",
3978 "http-body",
3979 "http-body-util",
3980 "hyper",
3981 "hyper-rustls",
3982 "hyper-tls",
3983 "hyper-util",
3984 "js-sys",
3985 "log",
3986 "mime",
3987 "mime_guess",
3988 "native-tls",
3989 "percent-encoding",
3990 "pin-project-lite",
3991 "quinn",
3992 "rustls 0.23.40",
3993 "rustls-pki-types",
3994 "serde",
3995 "serde_json",
3996 "serde_urlencoded",
3997 "sync_wrapper",
3998 "tokio",
3999 "tokio-native-tls",
4000 "tokio-rustls 0.26.4",
4001 "tokio-util",
4002 "tower",
4003 "tower-http",
4004 "tower-service",
4005 "url",
4006 "wasm-bindgen",
4007 "wasm-bindgen-futures",
4008 "wasm-streams",
4009 "web-sys",
4010 "webpki-roots 1.0.8",
4011]
4012
4013[[package]]
4014name = "rfc6979"
4015version = "0.4.0"
4016source = "registry+https://github.com/rust-lang/crates.io-index"
4017checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
4018dependencies = [
4019 "hmac 0.12.1",
4020 "subtle",
4021]
4022
4023[[package]]
4024name = "rgb"
4025version = "0.8.53"
4026source = "registry+https://github.com/rust-lang/crates.io-index"
4027checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
4028
4029[[package]]
4030name = "ring"
4031version = "0.17.14"
4032source = "registry+https://github.com/rust-lang/crates.io-index"
4033checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
4034dependencies = [
4035 "cc",
4036 "cfg-if",
4037 "getrandom 0.2.17",
4038 "libc",
4039 "untrusted",
4040 "windows-sys 0.52.0",
4041]
4042
4043[[package]]
4044name = "ringbuf"
4045version = "0.4.8"
4046source = "registry+https://github.com/rust-lang/crates.io-index"
4047checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c"
4048dependencies = [
4049 "crossbeam-utils",
4050 "portable-atomic",
4051 "portable-atomic-util",
4052]
4053
4054[[package]]
4055name = "rubato"
4056version = "0.16.2"
4057source = "registry+https://github.com/rust-lang/crates.io-index"
4058checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1"
4059dependencies = [
4060 "num-complex",
4061 "num-integer",
4062 "num-traits",
4063 "realfft",
4064]
4065
4066[[package]]
4067name = "rustc-hash"
4068version = "2.1.2"
4069source = "registry+https://github.com/rust-lang/crates.io-index"
4070checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
4071
4072[[package]]
4073name = "rustc_version"
4074version = "0.4.1"
4075source = "registry+https://github.com/rust-lang/crates.io-index"
4076checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
4077dependencies = [
4078 "semver",
4079]
4080
4081[[package]]
4082name = "rustfft"
4083version = "6.4.1"
4084source = "registry+https://github.com/rust-lang/crates.io-index"
4085checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
4086dependencies = [
4087 "num-complex",
4088 "num-integer",
4089 "num-traits",
4090 "primal-check",
4091 "strength_reduce",
4092 "transpose",
4093]
4094
4095[[package]]
4096name = "rustix"
4097version = "1.1.4"
4098source = "registry+https://github.com/rust-lang/crates.io-index"
4099checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
4100dependencies = [
4101 "bitflags 2.13.0",
4102 "errno",
4103 "libc",
4104 "linux-raw-sys",
4105 "windows-sys 0.61.2",
4106]
4107
4108[[package]]
4109name = "rustls"
4110version = "0.22.4"
4111source = "registry+https://github.com/rust-lang/crates.io-index"
4112checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
4113dependencies = [
4114 "log",
4115 "ring",
4116 "rustls-pki-types",
4117 "rustls-webpki 0.102.8",
4118 "subtle",
4119 "zeroize",
4120]
4121
4122[[package]]
4123name = "rustls"
4124version = "0.23.40"
4125source = "registry+https://github.com/rust-lang/crates.io-index"
4126checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
4127dependencies = [
4128 "aws-lc-rs",
4129 "log",
4130 "once_cell",
4131 "ring",
4132 "rustls-pki-types",
4133 "rustls-webpki 0.103.13",
4134 "subtle",
4135 "zeroize",
4136]
4137
4138[[package]]
4139name = "rustls-native-certs"
4140version = "0.8.4"
4141source = "registry+https://github.com/rust-lang/crates.io-index"
4142checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
4143dependencies = [
4144 "openssl-probe",
4145 "rustls-pki-types",
4146 "schannel",
4147 "security-framework",
4148]
4149
4150[[package]]
4151name = "rustls-pki-types"
4152version = "1.14.1"
4153source = "registry+https://github.com/rust-lang/crates.io-index"
4154checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
4155dependencies = [
4156 "web-time",
4157 "zeroize",
4158]
4159
4160[[package]]
4161name = "rustls-webpki"
4162version = "0.102.8"
4163source = "registry+https://github.com/rust-lang/crates.io-index"
4164checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
4165dependencies = [
4166 "ring",
4167 "rustls-pki-types",
4168 "untrusted",
4169]
4170
4171[[package]]
4172name = "rustls-webpki"
4173version = "0.103.13"
4174source = "registry+https://github.com/rust-lang/crates.io-index"
4175checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
4176dependencies = [
4177 "aws-lc-rs",
4178 "ring",
4179 "rustls-pki-types",
4180 "untrusted",
4181]
4182
4183[[package]]
4184name = "rustversion"
4185version = "1.0.22"
4186source = "registry+https://github.com/rust-lang/crates.io-index"
4187checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
4188
4189[[package]]
4190name = "rusty_pool"
4191version = "0.7.0"
4192source = "registry+https://github.com/rust-lang/crates.io-index"
4193checksum = "4ed36cdb20de66d89a17ea04b8883fc7a386f2cf877aaedca5005583ce4876ff"
4194dependencies = [
4195 "crossbeam-channel",
4196 "futures",
4197 "futures-channel",
4198 "futures-executor",
4199 "num_cpus",
4200]
4201
4202[[package]]
4203name = "rxing"
4204version = "0.8.6"
4205source = "registry+https://github.com/rust-lang/crates.io-index"
4206checksum = "dc56bde1cf7f3c9cc908ef2670efede68bbad283acb6674b709aa3befdab451d"
4207dependencies = [
4208 "chrono",
4209 "chrono-tz",
4210 "codepage-437",
4211 "encoding_rs",
4212 "fancy-regex",
4213 "image",
4214 "imageproc",
4215 "multimap",
4216 "num",
4217 "once_cell",
4218 "regex",
4219 "rxing-one-d-proc-derive",
4220 "thiserror 2.0.18",
4221 "unicode-segmentation",
4222 "uriparse",
4223 "urlencoding",
4224]
4225
4226[[package]]
4227name = "rxing-one-d-proc-derive"
4228version = "0.8.0"
4229source = "registry+https://github.com/rust-lang/crates.io-index"
4230checksum = "d7e5f136594d614d0d8e44f63508abd25c8fe0280655bb1d477b177bf718d0a4"
4231dependencies = [
4232 "quote",
4233 "syn 2.0.118",
4234]
4235
4236[[package]]
4237name = "ryu"
4238version = "1.0.23"
4239source = "registry+https://github.com/rust-lang/crates.io-index"
4240checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
4241
4242[[package]]
4243name = "safe_arch"
4244version = "0.7.4"
4245source = "registry+https://github.com/rust-lang/crates.io-index"
4246checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
4247dependencies = [
4248 "bytemuck",
4249]
4250
4251[[package]]
4252name = "salsa20"
4253version = "0.10.2"
4254source = "registry+https://github.com/rust-lang/crates.io-index"
4255checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
4256dependencies = [
4257 "cipher",
4258]
4259
4260[[package]]
4261name = "same-file"
4262version = "1.0.6"
4263source = "registry+https://github.com/rust-lang/crates.io-index"
4264checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
4265dependencies = [
4266 "winapi-util",
4267]
4268
4269[[package]]
4270name = "schannel"
4271version = "0.1.29"
4272source = "registry+https://github.com/rust-lang/crates.io-index"
4273checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
4274dependencies = [
4275 "windows-sys 0.61.2",
4276]
4277
4278[[package]]
4279name = "scoped-tls"
4280version = "1.0.1"
4281source = "registry+https://github.com/rust-lang/crates.io-index"
4282checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
4283
4284[[package]]
4285name = "scopeguard"
4286version = "1.2.0"
4287source = "registry+https://github.com/rust-lang/crates.io-index"
4288checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
4289
4290[[package]]
4291name = "scraper"
4292version = "0.27.0"
4293source = "registry+https://github.com/rust-lang/crates.io-index"
4294checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
4295dependencies = [
4296 "cssparser",
4297 "ego-tree",
4298 "getopts",
4299 "html5ever",
4300 "precomputed-hash",
4301 "selectors",
4302 "tendril",
4303]
4304
4305[[package]]
4306name = "scrypt"
4307version = "0.11.0"
4308source = "registry+https://github.com/rust-lang/crates.io-index"
4309checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
4310dependencies = [
4311 "pbkdf2",
4312 "salsa20",
4313 "sha2 0.10.9",
4314]
4315
4316[[package]]
4317name = "sec1"
4318version = "0.7.3"
4319source = "registry+https://github.com/rust-lang/crates.io-index"
4320checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
4321dependencies = [
4322 "base16ct",
4323 "der",
4324 "generic-array",
4325 "pkcs8",
4326 "subtle",
4327 "zeroize",
4328]
4329
4330[[package]]
4331name = "secrecy"
4332version = "0.8.0"
4333source = "registry+https://github.com/rust-lang/crates.io-index"
4334checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
4335dependencies = [
4336 "serde",
4337 "zeroize",
4338]
4339
4340[[package]]
4341name = "security-framework"
4342version = "3.7.0"
4343source = "registry+https://github.com/rust-lang/crates.io-index"
4344checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
4345dependencies = [
4346 "bitflags 2.13.0",
4347 "core-foundation 0.10.1",
4348 "core-foundation-sys",
4349 "libc",
4350 "security-framework-sys",
4351]
4352
4353[[package]]
4354name = "security-framework-sys"
4355version = "2.17.0"
4356source = "registry+https://github.com/rust-lang/crates.io-index"
4357checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
4358dependencies = [
4359 "core-foundation-sys",
4360 "libc",
4361]
4362
4363[[package]]
4364name = "selectors"
4365version = "0.38.0"
4366source = "registry+https://github.com/rust-lang/crates.io-index"
4367checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
4368dependencies = [
4369 "bitflags 2.13.0",
4370 "cssparser",
4371 "derive_more 2.1.1",
4372 "log",
4373 "new_debug_unreachable",
4374 "phf 0.13.1",
4375 "phf_codegen",
4376 "precomputed-hash",
4377 "rustc-hash",
4378 "servo_arc",
4379 "smallvec",
4380]
4381
4382[[package]]
4383name = "semver"
4384version = "1.0.28"
4385source = "registry+https://github.com/rust-lang/crates.io-index"
4386checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
4387dependencies = [
4388 "serde",
4389 "serde_core",
4390]
4391
4392[[package]]
4393name = "senator-sperma-rust"
4394version = "0.2.0"
4395dependencies = [
4396 "chrono",
4397 "cron",
4398 "dotenvy",
4399 "futures-util",
4400 "glob",
4401 "image",
4402 "poise",
4403 "qrcode",
4404 "rand 0.10.1",
4405 "reqwest",
4406 "rustls 0.23.40",
4407 "rxing",
4408 "scraper",
4409 "serde",
4410 "serde_json",
4411 "serenity",
4412 "songbird",
4413 "sqlx",
4414 "symphonia 0.5.5",
4415 "symphonia 0.6.0",
4416 "tempfile",
4417 "tokio",
4418 "tracing",
4419 "tracing-subscriber",
4420 "uuid",
4421]
4422
4423[[package]]
4424name = "serde"
4425version = "1.0.228"
4426source = "registry+https://github.com/rust-lang/crates.io-index"
4427checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
4428dependencies = [
4429 "serde_core",
4430 "serde_derive",
4431]
4432
4433[[package]]
4434name = "serde-aux"
4435version = "4.7.0"
4436source = "registry+https://github.com/rust-lang/crates.io-index"
4437checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
4438dependencies = [
4439 "chrono",
4440 "serde",
4441 "serde-value",
4442 "serde_json",
4443]
4444
4445[[package]]
4446name = "serde-value"
4447version = "0.7.0"
4448source = "registry+https://github.com/rust-lang/crates.io-index"
4449checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
4450dependencies = [
4451 "ordered-float",
4452 "serde",
4453]
4454
4455[[package]]
4456name = "serde_bytes"
4457version = "0.11.19"
4458source = "registry+https://github.com/rust-lang/crates.io-index"
4459checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
4460dependencies = [
4461 "serde",
4462 "serde_core",
4463]
4464
4465[[package]]
4466name = "serde_core"
4467version = "1.0.228"
4468source = "registry+https://github.com/rust-lang/crates.io-index"
4469checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
4470dependencies = [
4471 "serde_derive",
4472]
4473
4474[[package]]
4475name = "serde_cow"
4476version = "0.1.2"
4477source = "registry+https://github.com/rust-lang/crates.io-index"
4478checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da"
4479dependencies = [
4480 "serde",
4481]
4482
4483[[package]]
4484name = "serde_derive"
4485version = "1.0.228"
4486source = "registry+https://github.com/rust-lang/crates.io-index"
4487checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
4488dependencies = [
4489 "proc-macro2",
4490 "quote",
4491 "syn 2.0.118",
4492]
4493
4494[[package]]
4495name = "serde_json"
4496version = "1.0.150"
4497source = "registry+https://github.com/rust-lang/crates.io-index"
4498checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
4499dependencies = [
4500 "itoa",
4501 "memchr",
4502 "serde",
4503 "serde_core",
4504 "zmij",
4505]
4506
4507[[package]]
4508name = "serde_repr"
4509version = "0.1.20"
4510source = "registry+https://github.com/rust-lang/crates.io-index"
4511checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
4512dependencies = [
4513 "proc-macro2",
4514 "quote",
4515 "syn 2.0.118",
4516]
4517
4518[[package]]
4519name = "serde_spanned"
4520version = "0.6.9"
4521source = "registry+https://github.com/rust-lang/crates.io-index"
4522checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
4523dependencies = [
4524 "serde",
4525]
4526
4527[[package]]
4528name = "serde_urlencoded"
4529version = "0.7.1"
4530source = "registry+https://github.com/rust-lang/crates.io-index"
4531checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
4532dependencies = [
4533 "form_urlencoded",
4534 "itoa",
4535 "ryu",
4536 "serde",
4537]
4538
4539[[package]]
4540name = "serenity"
4541version = "0.12.5"
4542source = "registry+https://github.com/rust-lang/crates.io-index"
4543checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541"
4544dependencies = [
4545 "arrayvec",
4546 "async-trait",
4547 "base64",
4548 "bitflags 2.13.0",
4549 "bytes",
4550 "chrono",
4551 "command_attr",
4552 "dashmap 5.5.3",
4553 "flate2",
4554 "futures",
4555 "levenshtein",
4556 "mime_guess",
4557 "parking_lot 0.12.5",
4558 "percent-encoding",
4559 "reqwest",
4560 "rustc-hash",
4561 "secrecy",
4562 "serde",
4563 "serde_cow",
4564 "serde_json",
4565 "static_assertions",
4566 "time",
4567 "tokio",
4568 "tokio-tungstenite 0.21.0",
4569 "tracing",
4570 "typemap_rev",
4571 "typesize",
4572 "url",
4573 "uwl",
4574]
4575
4576[[package]]
4577name = "serenity-voice-model"
4578version = "0.3.0"
4579source = "registry+https://github.com/rust-lang/crates.io-index"
4580checksum = "910c6511e59df6fa362921e4900cdbd3192210231a2ba135594c480871422af6"
4581dependencies = [
4582 "bitflags 2.13.0",
4583 "num-traits",
4584 "serde",
4585 "serde_json",
4586 "serde_repr",
4587]
4588
4589[[package]]
4590name = "serenity_poise_macros"
4591version = "0.6.2"
4592source = "registry+https://github.com/rust-lang/crates.io-index"
4593checksum = "9bc8407893be0d973a7bb48325e10e0160a0db44f4099467c83179688220e5f5"
4594dependencies = [
4595 "darling 0.20.11",
4596 "proc-macro2",
4597 "quote",
4598 "syn 2.0.118",
4599]
4600
4601[[package]]
4602name = "servo_arc"
4603version = "0.4.3"
4604source = "registry+https://github.com/rust-lang/crates.io-index"
4605checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
4606dependencies = [
4607 "stable_deref_trait",
4608]
4609
4610[[package]]
4611name = "sha1"
4612version = "0.10.6"
4613source = "registry+https://github.com/rust-lang/crates.io-index"
4614checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
4615dependencies = [
4616 "cfg-if",
4617 "cpufeatures 0.2.17",
4618 "digest 0.10.7",
4619]
4620
4621[[package]]
4622name = "sha1"
4623version = "0.11.0"
4624source = "registry+https://github.com/rust-lang/crates.io-index"
4625checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
4626dependencies = [
4627 "cfg-if",
4628 "cpufeatures 0.3.0",
4629 "digest 0.11.3",
4630]
4631
4632[[package]]
4633name = "sha1_smol"
4634version = "1.0.1"
4635source = "registry+https://github.com/rust-lang/crates.io-index"
4636checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
4637
4638[[package]]
4639name = "sha2"
4640version = "0.10.9"
4641source = "registry+https://github.com/rust-lang/crates.io-index"
4642checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
4643dependencies = [
4644 "cfg-if",
4645 "cpufeatures 0.2.17",
4646 "digest 0.10.7",
4647]
4648
4649[[package]]
4650name = "sha2"
4651version = "0.11.0"
4652source = "registry+https://github.com/rust-lang/crates.io-index"
4653checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
4654dependencies = [
4655 "cfg-if",
4656 "cpufeatures 0.3.0",
4657 "digest 0.11.3",
4658]
4659
4660[[package]]
4661name = "sharded-slab"
4662version = "0.1.7"
4663source = "registry+https://github.com/rust-lang/crates.io-index"
4664checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
4665dependencies = [
4666 "lazy_static",
4667]
4668
4669[[package]]
4670name = "shlex"
4671version = "2.0.1"
4672source = "registry+https://github.com/rust-lang/crates.io-index"
4673checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
4674
4675[[package]]
4676name = "shorthand"
4677version = "0.1.1"
4678source = "registry+https://github.com/rust-lang/crates.io-index"
4679checksum = "474f77f985d8212610f170332eaf173e768404c0c1d4deb041f32c297cf18931"
4680dependencies = [
4681 "from_map",
4682 "proc-macro2",
4683 "quote",
4684 "syn 1.0.109",
4685]
4686
4687[[package]]
4688name = "signal-hook-registry"
4689version = "1.4.8"
4690source = "registry+https://github.com/rust-lang/crates.io-index"
4691checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
4692dependencies = [
4693 "errno",
4694 "libc",
4695]
4696
4697[[package]]
4698name = "signature"
4699version = "2.2.0"
4700source = "registry+https://github.com/rust-lang/crates.io-index"
4701checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
4702dependencies = [
4703 "digest 0.10.7",
4704 "rand_core 0.6.4",
4705]
4706
4707[[package]]
4708name = "simba"
4709version = "0.8.1"
4710source = "registry+https://github.com/rust-lang/crates.io-index"
4711checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
4712dependencies = [
4713 "approx",
4714 "num-complex",
4715 "num-traits",
4716 "paste",
4717 "wide",
4718]
4719
4720[[package]]
4721name = "simd-adler32"
4722version = "0.3.9"
4723source = "registry+https://github.com/rust-lang/crates.io-index"
4724checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
4725
4726[[package]]
4727name = "simd_helpers"
4728version = "0.1.0"
4729source = "registry+https://github.com/rust-lang/crates.io-index"
4730checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
4731dependencies = [
4732 "quote",
4733]
4734
4735[[package]]
4736name = "simdutf8"
4737version = "0.1.5"
4738source = "registry+https://github.com/rust-lang/crates.io-index"
4739checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
4740
4741[[package]]
4742name = "siphasher"
4743version = "1.0.3"
4744source = "registry+https://github.com/rust-lang/crates.io-index"
4745checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
4746
4747[[package]]
4748name = "skeptic"
4749version = "0.13.7"
4750source = "registry+https://github.com/rust-lang/crates.io-index"
4751checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8"
4752dependencies = [
4753 "bytecount",
4754 "cargo_metadata",
4755 "error-chain",
4756 "glob",
4757 "pulldown-cmark",
4758 "tempfile",
4759 "walkdir",
4760]
4761
4762[[package]]
4763name = "slab"
4764version = "0.4.12"
4765source = "registry+https://github.com/rust-lang/crates.io-index"
4766checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
4767
4768[[package]]
4769name = "smallvec"
4770version = "1.15.2"
4771source = "registry+https://github.com/rust-lang/crates.io-index"
4772checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
4773dependencies = [
4774 "serde",
4775]
4776
4777[[package]]
4778name = "socket2"
4779version = "0.5.10"
4780source = "registry+https://github.com/rust-lang/crates.io-index"
4781checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
4782dependencies = [
4783 "libc",
4784 "windows-sys 0.52.0",
4785]
4786
4787[[package]]
4788name = "socket2"
4789version = "0.6.4"
4790source = "registry+https://github.com/rust-lang/crates.io-index"
4791checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
4792dependencies = [
4793 "libc",
4794 "windows-sys 0.61.2",
4795]
4796
4797[[package]]
4798name = "songbird"
4799version = "0.6.0"
4800source = "registry+https://github.com/rust-lang/crates.io-index"
4801checksum = "5b9bf4ecb5083685b79a8fc60222f12e4f036e2817c5c341cd2b9ff2b429d237"
4802dependencies = [
4803 "aead",
4804 "aes-gcm",
4805 "async-trait",
4806 "byteorder",
4807 "bytes",
4808 "chacha20poly1305",
4809 "crypto-common 0.1.7",
4810 "dashmap 6.2.1",
4811 "davey",
4812 "derivative",
4813 "discortp",
4814 "either",
4815 "enum-map",
4816 "flume 0.11.1",
4817 "futures",
4818 "nohash-hasher",
4819 "nonmax",
4820 "opus2",
4821 "parking_lot 0.12.5",
4822 "pin-project",
4823 "rand 0.9.4",
4824 "reqwest",
4825 "ringbuf",
4826 "rubato",
4827 "rusty_pool",
4828 "serde",
4829 "serde-aux",
4830 "serde_json",
4831 "serenity",
4832 "serenity-voice-model",
4833 "socket2 0.5.10",
4834 "stream_lib",
4835 "streamcatcher",
4836 "symphonia 0.5.5",
4837 "symphonia-core 0.5.5",
4838 "tokio",
4839 "tokio-tungstenite 0.26.2",
4840 "tokio-util",
4841 "tokio-websockets",
4842 "tracing",
4843 "tracing-futures",
4844 "twilight-gateway",
4845 "typenum",
4846 "url",
4847 "uuid",
4848]
4849
4850[[package]]
4851name = "spin"
4852version = "0.9.8"
4853source = "registry+https://github.com/rust-lang/crates.io-index"
4854checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
4855dependencies = [
4856 "lock_api",
4857]
4858
4859[[package]]
4860name = "spki"
4861version = "0.7.3"
4862source = "registry+https://github.com/rust-lang/crates.io-index"
4863checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
4864dependencies = [
4865 "base64ct",
4866 "der",
4867]
4868
4869[[package]]
4870name = "sqlx"
4871version = "0.9.0"
4872source = "registry+https://github.com/rust-lang/crates.io-index"
4873checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
4874dependencies = [
4875 "sqlx-core",
4876 "sqlx-macros",
4877 "sqlx-mysql",
4878 "sqlx-postgres",
4879 "sqlx-sqlite",
4880]
4881
4882[[package]]
4883name = "sqlx-core"
4884version = "0.9.0"
4885source = "registry+https://github.com/rust-lang/crates.io-index"
4886checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
4887dependencies = [
4888 "base64",
4889 "bytes",
4890 "cfg-if",
4891 "chrono",
4892 "crc",
4893 "crossbeam-queue",
4894 "either",
4895 "event-listener",
4896 "futures-core",
4897 "futures-intrusive",
4898 "futures-io",
4899 "futures-util",
4900 "hashbrown 0.16.1",
4901 "hashlink",
4902 "indexmap",
4903 "log",
4904 "memchr",
4905 "percent-encoding",
4906 "serde",
4907 "serde_json",
4908 "sha2 0.10.9",
4909 "smallvec",
4910 "thiserror 2.0.18",
4911 "tokio",
4912 "tokio-stream",
4913 "tracing",
4914 "url",
4915]
4916
4917[[package]]
4918name = "sqlx-macros"
4919version = "0.9.0"
4920source = "registry+https://github.com/rust-lang/crates.io-index"
4921checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
4922dependencies = [
4923 "proc-macro2",
4924 "quote",
4925 "sqlx-core",
4926 "sqlx-macros-core",
4927 "syn 2.0.118",
4928]
4929
4930[[package]]
4931name = "sqlx-macros-core"
4932version = "0.9.0"
4933source = "registry+https://github.com/rust-lang/crates.io-index"
4934checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
4935dependencies = [
4936 "cfg-if",
4937 "dotenvy",
4938 "either",
4939 "heck 0.5.0",
4940 "hex",
4941 "proc-macro2",
4942 "quote",
4943 "serde",
4944 "serde_json",
4945 "sha2 0.10.9",
4946 "sqlx-core",
4947 "sqlx-mysql",
4948 "sqlx-postgres",
4949 "sqlx-sqlite",
4950 "syn 2.0.118",
4951 "thiserror 2.0.18",
4952 "tokio",
4953 "url",
4954]
4955
4956[[package]]
4957name = "sqlx-mysql"
4958version = "0.9.0"
4959source = "registry+https://github.com/rust-lang/crates.io-index"
4960checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
4961dependencies = [
4962 "bitflags 2.13.0",
4963 "byteorder",
4964 "bytes",
4965 "chrono",
4966 "crc",
4967 "digest 0.11.3",
4968 "dotenvy",
4969 "either",
4970 "futures-core",
4971 "futures-util",
4972 "generic-array",
4973 "log",
4974 "percent-encoding",
4975 "serde",
4976 "sha1 0.11.0",
4977 "sha2 0.11.0",
4978 "sqlx-core",
4979 "thiserror 2.0.18",
4980 "tracing",
4981]
4982
4983[[package]]
4984name = "sqlx-postgres"
4985version = "0.9.0"
4986source = "registry+https://github.com/rust-lang/crates.io-index"
4987checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
4988dependencies = [
4989 "atoi",
4990 "base64",
4991 "bitflags 2.13.0",
4992 "byteorder",
4993 "chrono",
4994 "crc",
4995 "dotenvy",
4996 "etcetera",
4997 "futures-channel",
4998 "futures-core",
4999 "futures-util",
5000 "hex",
5001 "hkdf 0.13.0",
5002 "hmac 0.13.0",
5003 "itoa",
5004 "log",
5005 "md-5",
5006 "memchr",
5007 "rand 0.10.1",
5008 "serde",
5009 "serde_json",
5010 "sha2 0.11.0",
5011 "smallvec",
5012 "sqlx-core",
5013 "stringprep",
5014 "thiserror 2.0.18",
5015 "tracing",
5016 "whoami",
5017]
5018
5019[[package]]
5020name = "sqlx-sqlite"
5021version = "0.9.0"
5022source = "registry+https://github.com/rust-lang/crates.io-index"
5023checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
5024dependencies = [
5025 "atoi",
5026 "chrono",
5027 "flume 0.12.0",
5028 "form_urlencoded",
5029 "futures-channel",
5030 "futures-core",
5031 "futures-executor",
5032 "futures-intrusive",
5033 "futures-util",
5034 "libsqlite3-sys",
5035 "log",
5036 "percent-encoding",
5037 "serde",
5038 "sqlx-core",
5039 "thiserror 2.0.18",
5040 "tracing",
5041 "url",
5042]
5043
5044[[package]]
5045name = "stable-vec"
5046version = "0.4.2"
5047source = "registry+https://github.com/rust-lang/crates.io-index"
5048checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e"
5049
5050[[package]]
5051name = "stable_deref_trait"
5052version = "1.2.1"
5053source = "registry+https://github.com/rust-lang/crates.io-index"
5054checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
5055
5056[[package]]
5057name = "static_assertions"
5058version = "1.1.0"
5059source = "registry+https://github.com/rust-lang/crates.io-index"
5060checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
5061
5062[[package]]
5063name = "stream_lib"
5064version = "0.5.2"
5065source = "registry+https://github.com/rust-lang/crates.io-index"
5066checksum = "e106dd009a0dfd2cf57777c39cad08f852debd366df6e841b250d956cec3277e"
5067dependencies = [
5068 "bytes",
5069 "futures-core",
5070 "futures-util",
5071 "hls_m3u8",
5072 "patricia_tree",
5073 "reqwest",
5074 "tokio",
5075 "tracing",
5076 "url",
5077]
5078
5079[[package]]
5080name = "streamcatcher"
5081version = "1.0.1"
5082source = "registry+https://github.com/rust-lang/crates.io-index"
5083checksum = "71664755c349abb0758fda6218fb2d2391ca2a73f9302c03b145491db4fcea29"
5084dependencies = [
5085 "crossbeam-utils",
5086 "futures-util",
5087 "loom",
5088]
5089
5090[[package]]
5091name = "strength_reduce"
5092version = "0.2.4"
5093source = "registry+https://github.com/rust-lang/crates.io-index"
5094checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
5095
5096[[package]]
5097name = "string_cache"
5098version = "0.9.0"
5099source = "registry+https://github.com/rust-lang/crates.io-index"
5100checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
5101dependencies = [
5102 "new_debug_unreachable",
5103 "parking_lot 0.12.5",
5104 "phf_shared 0.13.1",
5105 "precomputed-hash",
5106]
5107
5108[[package]]
5109name = "string_cache_codegen"
5110version = "0.6.1"
5111source = "registry+https://github.com/rust-lang/crates.io-index"
5112checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
5113dependencies = [
5114 "phf_generator 0.13.1",
5115 "phf_shared 0.13.1",
5116 "proc-macro2",
5117 "quote",
5118]
5119
5120[[package]]
5121name = "stringprep"
5122version = "0.1.5"
5123source = "registry+https://github.com/rust-lang/crates.io-index"
5124checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
5125dependencies = [
5126 "unicode-bidi",
5127 "unicode-normalization",
5128 "unicode-properties",
5129]
5130
5131[[package]]
5132name = "strsim"
5133version = "0.9.3"
5134source = "registry+https://github.com/rust-lang/crates.io-index"
5135checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c"
5136
5137[[package]]
5138name = "strsim"
5139version = "0.11.1"
5140source = "registry+https://github.com/rust-lang/crates.io-index"
5141checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
5142
5143[[package]]
5144name = "strum"
5145version = "0.17.1"
5146source = "registry+https://github.com/rust-lang/crates.io-index"
5147checksum = "530efb820d53b712f4e347916c5e7ed20deb76a4f0457943b3182fb889b06d2c"
5148dependencies = [
5149 "strum_macros",
5150]
5151
5152[[package]]
5153name = "strum_macros"
5154version = "0.17.1"
5155source = "registry+https://github.com/rust-lang/crates.io-index"
5156checksum = "5e6e163a520367c465f59e0a61a23cfae3b10b6546d78b6f672a382be79f7110"
5157dependencies = [
5158 "heck 0.3.3",
5159 "proc-macro2",
5160 "quote",
5161 "syn 1.0.109",
5162]
5163
5164[[package]]
5165name = "subtle"
5166version = "2.6.1"
5167source = "registry+https://github.com/rust-lang/crates.io-index"
5168checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
5169
5170[[package]]
5171name = "symphonia"
5172version = "0.5.5"
5173source = "registry+https://github.com/rust-lang/crates.io-index"
5174checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
5175dependencies = [
5176 "lazy_static",
5177 "symphonia-bundle-flac 0.5.5",
5178 "symphonia-codec-adpcm",
5179 "symphonia-codec-pcm",
5180 "symphonia-codec-vorbis",
5181 "symphonia-core 0.5.5",
5182 "symphonia-format-mkv",
5183 "symphonia-format-ogg 0.5.5",
5184 "symphonia-format-riff 0.5.5",
5185 "symphonia-metadata 0.5.5",
5186]
5187
5188[[package]]
5189name = "symphonia"
5190version = "0.6.0"
5191source = "registry+https://github.com/rust-lang/crates.io-index"
5192checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
5193dependencies = [
5194 "lazy_static",
5195 "symphonia-bundle-flac 0.6.0",
5196 "symphonia-bundle-mp3",
5197 "symphonia-codec-aac",
5198 "symphonia-codec-alac",
5199 "symphonia-core 0.6.0",
5200 "symphonia-format-isomp4",
5201 "symphonia-format-ogg 0.6.0",
5202 "symphonia-format-riff 0.6.0",
5203 "symphonia-metadata 0.6.0",
5204]
5205
5206[[package]]
5207name = "symphonia-bundle-flac"
5208version = "0.5.5"
5209source = "registry+https://github.com/rust-lang/crates.io-index"
5210checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
5211dependencies = [
5212 "log",
5213 "symphonia-core 0.5.5",
5214 "symphonia-metadata 0.5.5",
5215 "symphonia-utils-xiph",
5216]
5217
5218[[package]]
5219name = "symphonia-bundle-flac"
5220version = "0.6.0"
5221source = "registry+https://github.com/rust-lang/crates.io-index"
5222checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
5223dependencies = [
5224 "log",
5225 "symphonia-common",
5226 "symphonia-core 0.6.0",
5227 "symphonia-metadata 0.6.0",
5228]
5229
5230[[package]]
5231name = "symphonia-bundle-mp3"
5232version = "0.6.0"
5233source = "registry+https://github.com/rust-lang/crates.io-index"
5234checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
5235dependencies = [
5236 "lazy_static",
5237 "log",
5238 "symphonia-core 0.6.0",
5239]
5240
5241[[package]]
5242name = "symphonia-codec-aac"
5243version = "0.6.0"
5244source = "registry+https://github.com/rust-lang/crates.io-index"
5245checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
5246dependencies = [
5247 "lazy_static",
5248 "log",
5249 "symphonia-common",
5250 "symphonia-core 0.6.0",
5251]
5252
5253[[package]]
5254name = "symphonia-codec-adpcm"
5255version = "0.5.5"
5256source = "registry+https://github.com/rust-lang/crates.io-index"
5257checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
5258dependencies = [
5259 "log",
5260 "symphonia-core 0.5.5",
5261]
5262
5263[[package]]
5264name = "symphonia-codec-alac"
5265version = "0.6.0"
5266source = "registry+https://github.com/rust-lang/crates.io-index"
5267checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
5268dependencies = [
5269 "log",
5270 "symphonia-common",
5271 "symphonia-core 0.6.0",
5272]
5273
5274[[package]]
5275name = "symphonia-codec-pcm"
5276version = "0.5.5"
5277source = "registry+https://github.com/rust-lang/crates.io-index"
5278checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
5279dependencies = [
5280 "log",
5281 "symphonia-core 0.5.5",
5282]
5283
5284[[package]]
5285name = "symphonia-codec-vorbis"
5286version = "0.5.5"
5287source = "registry+https://github.com/rust-lang/crates.io-index"
5288checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
5289dependencies = [
5290 "log",
5291 "symphonia-core 0.5.5",
5292 "symphonia-utils-xiph",
5293]
5294
5295[[package]]
5296name = "symphonia-common"
5297version = "0.6.0"
5298source = "registry+https://github.com/rust-lang/crates.io-index"
5299checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
5300dependencies = [
5301 "log",
5302 "symphonia-core 0.6.0",
5303 "symphonia-metadata 0.6.0",
5304]
5305
5306[[package]]
5307name = "symphonia-core"
5308version = "0.5.5"
5309source = "registry+https://github.com/rust-lang/crates.io-index"
5310checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
5311dependencies = [
5312 "arrayvec",
5313 "bitflags 1.3.2",
5314 "bytemuck",
5315 "lazy_static",
5316 "log",
5317]
5318
5319[[package]]
5320name = "symphonia-core"
5321version = "0.6.0"
5322source = "registry+https://github.com/rust-lang/crates.io-index"
5323checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
5324dependencies = [
5325 "bitflags 2.13.0",
5326 "bytemuck",
5327 "lazy_static",
5328 "log",
5329 "num-complex",
5330 "smallvec",
5331]
5332
5333[[package]]
5334name = "symphonia-format-isomp4"
5335version = "0.6.0"
5336source = "registry+https://github.com/rust-lang/crates.io-index"
5337checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
5338dependencies = [
5339 "log",
5340 "symphonia-common",
5341 "symphonia-core 0.6.0",
5342 "symphonia-metadata 0.6.0",
5343]
5344
5345[[package]]
5346name = "symphonia-format-mkv"
5347version = "0.5.5"
5348source = "registry+https://github.com/rust-lang/crates.io-index"
5349checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
5350dependencies = [
5351 "lazy_static",
5352 "log",
5353 "symphonia-core 0.5.5",
5354 "symphonia-metadata 0.5.5",
5355 "symphonia-utils-xiph",
5356]
5357
5358[[package]]
5359name = "symphonia-format-ogg"
5360version = "0.5.5"
5361source = "registry+https://github.com/rust-lang/crates.io-index"
5362checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
5363dependencies = [
5364 "log",
5365 "symphonia-core 0.5.5",
5366 "symphonia-metadata 0.5.5",
5367 "symphonia-utils-xiph",
5368]
5369
5370[[package]]
5371name = "symphonia-format-ogg"
5372version = "0.6.0"
5373source = "registry+https://github.com/rust-lang/crates.io-index"
5374checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
5375dependencies = [
5376 "log",
5377 "symphonia-common",
5378 "symphonia-core 0.6.0",
5379 "symphonia-metadata 0.6.0",
5380]
5381
5382[[package]]
5383name = "symphonia-format-riff"
5384version = "0.5.5"
5385source = "registry+https://github.com/rust-lang/crates.io-index"
5386checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
5387dependencies = [
5388 "extended",
5389 "log",
5390 "symphonia-core 0.5.5",
5391 "symphonia-metadata 0.5.5",
5392]
5393
5394[[package]]
5395name = "symphonia-format-riff"
5396version = "0.6.0"
5397source = "registry+https://github.com/rust-lang/crates.io-index"
5398checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
5399dependencies = [
5400 "extended",
5401 "log",
5402 "symphonia-core 0.6.0",
5403 "symphonia-metadata 0.6.0",
5404]
5405
5406[[package]]
5407name = "symphonia-metadata"
5408version = "0.5.5"
5409source = "registry+https://github.com/rust-lang/crates.io-index"
5410checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
5411dependencies = [
5412 "encoding_rs",
5413 "lazy_static",
5414 "log",
5415 "symphonia-core 0.5.5",
5416]
5417
5418[[package]]
5419name = "symphonia-metadata"
5420version = "0.6.0"
5421source = "registry+https://github.com/rust-lang/crates.io-index"
5422checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
5423dependencies = [
5424 "lazy_static",
5425 "log",
5426 "regex-lite",
5427 "smallvec",
5428 "symphonia-core 0.6.0",
5429]
5430
5431[[package]]
5432name = "symphonia-utils-xiph"
5433version = "0.5.5"
5434source = "registry+https://github.com/rust-lang/crates.io-index"
5435checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
5436dependencies = [
5437 "symphonia-core 0.5.5",
5438 "symphonia-metadata 0.5.5",
5439]
5440
5441[[package]]
5442name = "syn"
5443version = "1.0.109"
5444source = "registry+https://github.com/rust-lang/crates.io-index"
5445checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
5446dependencies = [
5447 "proc-macro2",
5448 "quote",
5449 "unicode-ident",
5450]
5451
5452[[package]]
5453name = "syn"
5454version = "2.0.118"
5455source = "registry+https://github.com/rust-lang/crates.io-index"
5456checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
5457dependencies = [
5458 "proc-macro2",
5459 "quote",
5460 "unicode-ident",
5461]
5462
5463[[package]]
5464name = "sync_wrapper"
5465version = "1.0.2"
5466source = "registry+https://github.com/rust-lang/crates.io-index"
5467checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
5468dependencies = [
5469 "futures-core",
5470]
5471
5472[[package]]
5473name = "synstructure"
5474version = "0.13.2"
5475source = "registry+https://github.com/rust-lang/crates.io-index"
5476checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
5477dependencies = [
5478 "proc-macro2",
5479 "quote",
5480 "syn 2.0.118",
5481]
5482
5483[[package]]
5484name = "system-configuration"
5485version = "0.7.0"
5486source = "registry+https://github.com/rust-lang/crates.io-index"
5487checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
5488dependencies = [
5489 "bitflags 2.13.0",
5490 "core-foundation 0.9.4",
5491 "system-configuration-sys",
5492]
5493
5494[[package]]
5495name = "system-configuration-sys"
5496version = "0.6.0"
5497source = "registry+https://github.com/rust-lang/crates.io-index"
5498checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
5499dependencies = [
5500 "core-foundation-sys",
5501 "libc",
5502]
5503
5504[[package]]
5505name = "system-deps"
5506version = "6.2.2"
5507source = "registry+https://github.com/rust-lang/crates.io-index"
5508checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
5509dependencies = [
5510 "cfg-expr",
5511 "heck 0.5.0",
5512 "pkg-config",
5513 "toml",
5514 "version-compare",
5515]
5516
5517[[package]]
5518name = "tagptr"
5519version = "0.2.0"
5520source = "registry+https://github.com/rust-lang/crates.io-index"
5521checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
5522
5523[[package]]
5524name = "target-lexicon"
5525version = "0.12.16"
5526source = "registry+https://github.com/rust-lang/crates.io-index"
5527checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
5528
5529[[package]]
5530name = "tempfile"
5531version = "3.27.0"
5532source = "registry+https://github.com/rust-lang/crates.io-index"
5533checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
5534dependencies = [
5535 "fastrand",
5536 "getrandom 0.4.3",
5537 "once_cell",
5538 "rustix",
5539 "windows-sys 0.61.2",
5540]
5541
5542[[package]]
5543name = "tendril"
5544version = "0.5.0"
5545source = "registry+https://github.com/rust-lang/crates.io-index"
5546checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
5547dependencies = [
5548 "new_debug_unreachable",
5549 "utf-8",
5550]
5551
5552[[package]]
5553name = "thiserror"
5554version = "1.0.69"
5555source = "registry+https://github.com/rust-lang/crates.io-index"
5556checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
5557dependencies = [
5558 "thiserror-impl 1.0.69",
5559]
5560
5561[[package]]
5562name = "thiserror"
5563version = "2.0.18"
5564source = "registry+https://github.com/rust-lang/crates.io-index"
5565checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
5566dependencies = [
5567 "thiserror-impl 2.0.18",
5568]
5569
5570[[package]]
5571name = "thiserror-impl"
5572version = "1.0.69"
5573source = "registry+https://github.com/rust-lang/crates.io-index"
5574checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
5575dependencies = [
5576 "proc-macro2",
5577 "quote",
5578 "syn 2.0.118",
5579]
5580
5581[[package]]
5582name = "thiserror-impl"
5583version = "2.0.18"
5584source = "registry+https://github.com/rust-lang/crates.io-index"
5585checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
5586dependencies = [
5587 "proc-macro2",
5588 "quote",
5589 "syn 2.0.118",
5590]
5591
5592[[package]]
5593name = "thread_local"
5594version = "1.1.9"
5595source = "registry+https://github.com/rust-lang/crates.io-index"
5596checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
5597dependencies = [
5598 "cfg-if",
5599]
5600
5601[[package]]
5602name = "tiff"
5603version = "0.10.3"
5604source = "registry+https://github.com/rust-lang/crates.io-index"
5605checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
5606dependencies = [
5607 "fax",
5608 "flate2",
5609 "half",
5610 "quick-error",
5611 "weezl",
5612 "zune-jpeg",
5613]
5614
5615[[package]]
5616name = "time"
5617version = "0.3.49"
5618source = "registry+https://github.com/rust-lang/crates.io-index"
5619checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
5620dependencies = [
5621 "deranged",
5622 "num-conv",
5623 "powerfmt",
5624 "serde_core",
5625 "time-core",
5626 "time-macros",
5627]
5628
5629[[package]]
5630name = "time-core"
5631version = "0.1.9"
5632source = "registry+https://github.com/rust-lang/crates.io-index"
5633checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
5634
5635[[package]]
5636name = "time-macros"
5637version = "0.2.29"
5638source = "registry+https://github.com/rust-lang/crates.io-index"
5639checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
5640dependencies = [
5641 "num-conv",
5642 "time-core",
5643]
5644
5645[[package]]
5646name = "tinystr"
5647version = "0.8.3"
5648source = "registry+https://github.com/rust-lang/crates.io-index"
5649checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
5650dependencies = [
5651 "displaydoc",
5652 "zerovec",
5653]
5654
5655[[package]]
5656name = "tinyvec"
5657version = "1.11.0"
5658source = "registry+https://github.com/rust-lang/crates.io-index"
5659checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
5660dependencies = [
5661 "tinyvec_macros",
5662]
5663
5664[[package]]
5665name = "tinyvec_macros"
5666version = "0.1.1"
5667source = "registry+https://github.com/rust-lang/crates.io-index"
5668checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
5669
5670[[package]]
5671name = "tls_codec"
5672version = "0.4.2"
5673source = "registry+https://github.com/rust-lang/crates.io-index"
5674checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
5675dependencies = [
5676 "serde",
5677 "tls_codec_derive",
5678 "zeroize",
5679]
5680
5681[[package]]
5682name = "tls_codec_derive"
5683version = "0.4.2"
5684source = "registry+https://github.com/rust-lang/crates.io-index"
5685checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
5686dependencies = [
5687 "proc-macro2",
5688 "quote",
5689 "syn 2.0.118",
5690]
5691
5692[[package]]
5693name = "tokio"
5694version = "1.52.3"
5695source = "registry+https://github.com/rust-lang/crates.io-index"
5696checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
5697dependencies = [
5698 "bytes",
5699 "libc",
5700 "mio",
5701 "pin-project-lite",
5702 "signal-hook-registry",
5703 "socket2 0.6.4",
5704 "tokio-macros",
5705 "windows-sys 0.61.2",
5706]
5707
5708[[package]]
5709name = "tokio-macros"
5710version = "2.7.0"
5711source = "registry+https://github.com/rust-lang/crates.io-index"
5712checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
5713dependencies = [
5714 "proc-macro2",
5715 "quote",
5716 "syn 2.0.118",
5717]
5718
5719[[package]]
5720name = "tokio-native-tls"
5721version = "0.3.1"
5722source = "registry+https://github.com/rust-lang/crates.io-index"
5723checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
5724dependencies = [
5725 "native-tls",
5726 "tokio",
5727]
5728
5729[[package]]
5730name = "tokio-rustls"
5731version = "0.25.0"
5732source = "registry+https://github.com/rust-lang/crates.io-index"
5733checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
5734dependencies = [
5735 "rustls 0.22.4",
5736 "rustls-pki-types",
5737 "tokio",
5738]
5739
5740[[package]]
5741name = "tokio-rustls"
5742version = "0.26.4"
5743source = "registry+https://github.com/rust-lang/crates.io-index"
5744checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
5745dependencies = [
5746 "rustls 0.23.40",
5747 "tokio",
5748]
5749
5750[[package]]
5751name = "tokio-stream"
5752version = "0.1.18"
5753source = "registry+https://github.com/rust-lang/crates.io-index"
5754checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
5755dependencies = [
5756 "futures-core",
5757 "pin-project-lite",
5758 "tokio",
5759]
5760
5761[[package]]
5762name = "tokio-tungstenite"
5763version = "0.21.0"
5764source = "registry+https://github.com/rust-lang/crates.io-index"
5765checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
5766dependencies = [
5767 "futures-util",
5768 "log",
5769 "rustls 0.22.4",
5770 "rustls-pki-types",
5771 "tokio",
5772 "tokio-rustls 0.25.0",
5773 "tungstenite 0.21.0",
5774 "webpki-roots 0.26.11",
5775]
5776
5777[[package]]
5778name = "tokio-tungstenite"
5779version = "0.26.2"
5780source = "registry+https://github.com/rust-lang/crates.io-index"
5781checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
5782dependencies = [
5783 "futures-util",
5784 "log",
5785 "rustls 0.23.40",
5786 "rustls-pki-types",
5787 "tokio",
5788 "tokio-rustls 0.26.4",
5789 "tungstenite 0.26.2",
5790 "webpki-roots 0.26.11",
5791]
5792
5793[[package]]
5794name = "tokio-util"
5795version = "0.7.18"
5796source = "registry+https://github.com/rust-lang/crates.io-index"
5797checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
5798dependencies = [
5799 "bytes",
5800 "futures-core",
5801 "futures-sink",
5802 "pin-project-lite",
5803 "tokio",
5804]
5805
5806[[package]]
5807name = "tokio-websockets"
5808version = "0.11.4"
5809source = "registry+https://github.com/rust-lang/crates.io-index"
5810checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e"
5811dependencies = [
5812 "base64",
5813 "bytes",
5814 "fastrand",
5815 "futures-core",
5816 "futures-sink",
5817 "http",
5818 "httparse",
5819 "ring",
5820 "rustls-native-certs",
5821 "rustls-pki-types",
5822 "sha1_smol",
5823 "simdutf8",
5824 "tokio",
5825 "tokio-rustls 0.26.4",
5826 "tokio-util",
5827]
5828
5829[[package]]
5830name = "toml"
5831version = "0.8.23"
5832source = "registry+https://github.com/rust-lang/crates.io-index"
5833checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
5834dependencies = [
5835 "serde",
5836 "serde_spanned",
5837 "toml_datetime",
5838 "toml_edit",
5839]
5840
5841[[package]]
5842name = "toml_datetime"
5843version = "0.6.11"
5844source = "registry+https://github.com/rust-lang/crates.io-index"
5845checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
5846dependencies = [
5847 "serde",
5848]
5849
5850[[package]]
5851name = "toml_edit"
5852version = "0.22.27"
5853source = "registry+https://github.com/rust-lang/crates.io-index"
5854checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
5855dependencies = [
5856 "indexmap",
5857 "serde",
5858 "serde_spanned",
5859 "toml_datetime",
5860 "winnow",
5861]
5862
5863[[package]]
5864name = "tower"
5865version = "0.5.3"
5866source = "registry+https://github.com/rust-lang/crates.io-index"
5867checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
5868dependencies = [
5869 "futures-core",
5870 "futures-util",
5871 "pin-project-lite",
5872 "sync_wrapper",
5873 "tokio",
5874 "tower-layer",
5875 "tower-service",
5876]
5877
5878[[package]]
5879name = "tower-http"
5880version = "0.6.11"
5881source = "registry+https://github.com/rust-lang/crates.io-index"
5882checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
5883dependencies = [
5884 "bitflags 2.13.0",
5885 "bytes",
5886 "futures-util",
5887 "http",
5888 "http-body",
5889 "pin-project-lite",
5890 "tower",
5891 "tower-layer",
5892 "tower-service",
5893 "url",
5894]
5895
5896[[package]]
5897name = "tower-layer"
5898version = "0.3.3"
5899source = "registry+https://github.com/rust-lang/crates.io-index"
5900checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
5901
5902[[package]]
5903name = "tower-service"
5904version = "0.3.3"
5905source = "registry+https://github.com/rust-lang/crates.io-index"
5906checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
5907
5908[[package]]
5909name = "tracing"
5910version = "0.1.44"
5911source = "registry+https://github.com/rust-lang/crates.io-index"
5912checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
5913dependencies = [
5914 "log",
5915 "pin-project-lite",
5916 "tracing-attributes",
5917 "tracing-core",
5918]
5919
5920[[package]]
5921name = "tracing-attributes"
5922version = "0.1.31"
5923source = "registry+https://github.com/rust-lang/crates.io-index"
5924checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
5925dependencies = [
5926 "proc-macro2",
5927 "quote",
5928 "syn 2.0.118",
5929]
5930
5931[[package]]
5932name = "tracing-core"
5933version = "0.1.36"
5934source = "registry+https://github.com/rust-lang/crates.io-index"
5935checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
5936dependencies = [
5937 "once_cell",
5938 "valuable",
5939]
5940
5941[[package]]
5942name = "tracing-futures"
5943version = "0.2.5"
5944source = "registry+https://github.com/rust-lang/crates.io-index"
5945checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
5946dependencies = [
5947 "pin-project",
5948 "tracing",
5949]
5950
5951[[package]]
5952name = "tracing-log"
5953version = "0.2.0"
5954source = "registry+https://github.com/rust-lang/crates.io-index"
5955checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
5956dependencies = [
5957 "log",
5958 "once_cell",
5959 "tracing-core",
5960]
5961
5962[[package]]
5963name = "tracing-subscriber"
5964version = "0.3.23"
5965source = "registry+https://github.com/rust-lang/crates.io-index"
5966checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
5967dependencies = [
5968 "matchers",
5969 "nu-ansi-term",
5970 "once_cell",
5971 "regex-automata",
5972 "sharded-slab",
5973 "smallvec",
5974 "thread_local",
5975 "tracing",
5976 "tracing-core",
5977 "tracing-log",
5978]
5979
5980[[package]]
5981name = "transpose"
5982version = "0.2.3"
5983source = "registry+https://github.com/rust-lang/crates.io-index"
5984checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
5985dependencies = [
5986 "num-integer",
5987 "strength_reduce",
5988]
5989
5990[[package]]
5991name = "trim-in-place"
5992version = "0.1.7"
5993source = "registry+https://github.com/rust-lang/crates.io-index"
5994checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
5995
5996[[package]]
5997name = "triomphe"
5998version = "0.1.15"
5999source = "registry+https://github.com/rust-lang/crates.io-index"
6000checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
6001
6002[[package]]
6003name = "try-lock"
6004version = "0.2.5"
6005source = "registry+https://github.com/rust-lang/crates.io-index"
6006checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
6007
6008[[package]]
6009name = "ttf-parser"
6010version = "0.25.1"
6011source = "registry+https://github.com/rust-lang/crates.io-index"
6012checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
6013
6014[[package]]
6015name = "tungstenite"
6016version = "0.21.0"
6017source = "registry+https://github.com/rust-lang/crates.io-index"
6018checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
6019dependencies = [
6020 "byteorder",
6021 "bytes",
6022 "data-encoding",
6023 "http",
6024 "httparse",
6025 "log",
6026 "rand 0.8.6",
6027 "rustls 0.22.4",
6028 "rustls-pki-types",
6029 "sha1 0.10.6",
6030 "thiserror 1.0.69",
6031 "url",
6032 "utf-8",
6033]
6034
6035[[package]]
6036name = "tungstenite"
6037version = "0.26.2"
6038source = "registry+https://github.com/rust-lang/crates.io-index"
6039checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
6040dependencies = [
6041 "bytes",
6042 "data-encoding",
6043 "http",
6044 "httparse",
6045 "log",
6046 "rand 0.9.4",
6047 "rustls 0.23.40",
6048 "rustls-pki-types",
6049 "sha1 0.10.6",
6050 "thiserror 2.0.18",
6051 "url",
6052 "utf-8",
6053]
6054
6055[[package]]
6056name = "twilight-gateway"
6057version = "0.16.0"
6058source = "registry+https://github.com/rust-lang/crates.io-index"
6059checksum = "863ef55467bcf6a2958162766fd0b72f33112e36971b37f9ec09b86d4fd0f7d1"
6060dependencies = [
6061 "bitflags 2.13.0",
6062 "fastrand",
6063 "futures-core",
6064 "futures-sink",
6065 "serde",
6066 "serde_json",
6067 "tokio",
6068 "tokio-websockets",
6069 "tracing",
6070 "twilight-gateway-queue",
6071 "twilight-model",
6072]
6073
6074[[package]]
6075name = "twilight-gateway-queue"
6076version = "0.16.0"
6077source = "registry+https://github.com/rust-lang/crates.io-index"
6078checksum = "27c962bd4693da0a215abe6b431fd73eb87111f49c431b87951780f9f7985002"
6079dependencies = [
6080 "tokio",
6081 "tracing",
6082]
6083
6084[[package]]
6085name = "twilight-model"
6086version = "0.16.0"
6087source = "registry+https://github.com/rust-lang/crates.io-index"
6088checksum = "191e2efa051dfbd9bed4c9f6bd3f5e007bda909c687a1db2760371a3d566617d"
6089dependencies = [
6090 "bitflags 2.13.0",
6091 "serde",
6092 "serde-value",
6093 "serde_repr",
6094 "time",
6095]
6096
6097[[package]]
6098name = "typemap_rev"
6099version = "0.3.0"
6100source = "registry+https://github.com/rust-lang/crates.io-index"
6101checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998"
6102
6103[[package]]
6104name = "typenum"
6105version = "1.20.1"
6106source = "registry+https://github.com/rust-lang/crates.io-index"
6107checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
6108
6109[[package]]
6110name = "typesize"
6111version = "0.1.15"
6112source = "registry+https://github.com/rust-lang/crates.io-index"
6113checksum = "a313188364f7e10138257cb288c64e9b0e282510abefc91c796799559fa14762"
6114dependencies = [
6115 "chrono",
6116 "dashmap 5.5.3",
6117 "hashbrown 0.14.5",
6118 "mini-moka",
6119 "parking_lot 0.12.5",
6120 "secrecy",
6121 "serde_json",
6122 "time",
6123 "typesize-derive",
6124 "url",
6125]
6126
6127[[package]]
6128name = "typesize-derive"
6129version = "0.1.11"
6130source = "registry+https://github.com/rust-lang/crates.io-index"
6131checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6"
6132dependencies = [
6133 "proc-macro2",
6134 "quote",
6135 "syn 2.0.118",
6136]
6137
6138[[package]]
6139name = "unicase"
6140version = "2.9.0"
6141source = "registry+https://github.com/rust-lang/crates.io-index"
6142checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
6143
6144[[package]]
6145name = "unicode-bidi"
6146version = "0.3.18"
6147source = "registry+https://github.com/rust-lang/crates.io-index"
6148checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
6149
6150[[package]]
6151name = "unicode-ident"
6152version = "1.0.24"
6153source = "registry+https://github.com/rust-lang/crates.io-index"
6154checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
6155
6156[[package]]
6157name = "unicode-normalization"
6158version = "0.1.25"
6159source = "registry+https://github.com/rust-lang/crates.io-index"
6160checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
6161dependencies = [
6162 "tinyvec",
6163]
6164
6165[[package]]
6166name = "unicode-properties"
6167version = "0.1.4"
6168source = "registry+https://github.com/rust-lang/crates.io-index"
6169checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
6170
6171[[package]]
6172name = "unicode-segmentation"
6173version = "1.13.3"
6174source = "registry+https://github.com/rust-lang/crates.io-index"
6175checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
6176
6177[[package]]
6178name = "unicode-width"
6179version = "0.2.2"
6180source = "registry+https://github.com/rust-lang/crates.io-index"
6181checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
6182
6183[[package]]
6184name = "universal-hash"
6185version = "0.5.1"
6186source = "registry+https://github.com/rust-lang/crates.io-index"
6187checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
6188dependencies = [
6189 "crypto-common 0.1.7",
6190 "subtle",
6191]
6192
6193[[package]]
6194name = "untrusted"
6195version = "0.9.0"
6196source = "registry+https://github.com/rust-lang/crates.io-index"
6197checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
6198
6199[[package]]
6200name = "uriparse"
6201version = "0.6.4"
6202source = "registry+https://github.com/rust-lang/crates.io-index"
6203checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff"
6204dependencies = [
6205 "fnv",
6206 "lazy_static",
6207]
6208
6209[[package]]
6210name = "url"
6211version = "2.5.8"
6212source = "registry+https://github.com/rust-lang/crates.io-index"
6213checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
6214dependencies = [
6215 "form_urlencoded",
6216 "idna",
6217 "percent-encoding",
6218 "serde",
6219 "serde_derive",
6220]
6221
6222[[package]]
6223name = "urlencoding"
6224version = "2.1.3"
6225source = "registry+https://github.com/rust-lang/crates.io-index"
6226checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
6227
6228[[package]]
6229name = "utf-8"
6230version = "0.7.6"
6231source = "registry+https://github.com/rust-lang/crates.io-index"
6232checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
6233
6234[[package]]
6235name = "utf8_iter"
6236version = "1.0.4"
6237source = "registry+https://github.com/rust-lang/crates.io-index"
6238checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
6239
6240[[package]]
6241name = "uuid"
6242version = "1.23.3"
6243source = "registry+https://github.com/rust-lang/crates.io-index"
6244checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
6245dependencies = [
6246 "getrandom 0.4.3",
6247 "js-sys",
6248 "wasm-bindgen",
6249]
6250
6251[[package]]
6252name = "uwl"
6253version = "0.6.0"
6254source = "registry+https://github.com/rust-lang/crates.io-index"
6255checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0"
6256
6257[[package]]
6258name = "v_frame"
6259version = "0.3.9"
6260source = "registry+https://github.com/rust-lang/crates.io-index"
6261checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
6262dependencies = [
6263 "aligned-vec",
6264 "num-traits",
6265 "wasm-bindgen",
6266]
6267
6268[[package]]
6269name = "valuable"
6270version = "0.1.1"
6271source = "registry+https://github.com/rust-lang/crates.io-index"
6272checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
6273
6274[[package]]
6275name = "vcpkg"
6276version = "0.2.15"
6277source = "registry+https://github.com/rust-lang/crates.io-index"
6278checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
6279
6280[[package]]
6281name = "version-compare"
6282version = "0.2.1"
6283source = "registry+https://github.com/rust-lang/crates.io-index"
6284checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
6285
6286[[package]]
6287name = "version_check"
6288version = "0.9.5"
6289source = "registry+https://github.com/rust-lang/crates.io-index"
6290checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
6291
6292[[package]]
6293name = "walkdir"
6294version = "2.5.0"
6295source = "registry+https://github.com/rust-lang/crates.io-index"
6296checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
6297dependencies = [
6298 "same-file",
6299 "winapi-util",
6300]
6301
6302[[package]]
6303name = "want"
6304version = "0.3.1"
6305source = "registry+https://github.com/rust-lang/crates.io-index"
6306checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
6307dependencies = [
6308 "try-lock",
6309]
6310
6311[[package]]
6312name = "wasi"
6313version = "0.11.1+wasi-snapshot-preview1"
6314source = "registry+https://github.com/rust-lang/crates.io-index"
6315checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
6316
6317[[package]]
6318name = "wasip2"
6319version = "1.0.4+wasi-0.2.12"
6320source = "registry+https://github.com/rust-lang/crates.io-index"
6321checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
6322dependencies = [
6323 "wit-bindgen",
6324]
6325
6326[[package]]
6327name = "wasm-bindgen"
6328version = "0.2.125"
6329source = "registry+https://github.com/rust-lang/crates.io-index"
6330checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
6331dependencies = [
6332 "cfg-if",
6333 "once_cell",
6334 "rustversion",
6335 "wasm-bindgen-macro",
6336 "wasm-bindgen-shared",
6337]
6338
6339[[package]]
6340name = "wasm-bindgen-futures"
6341version = "0.4.75"
6342source = "registry+https://github.com/rust-lang/crates.io-index"
6343checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
6344dependencies = [
6345 "js-sys",
6346 "wasm-bindgen",
6347]
6348
6349[[package]]
6350name = "wasm-bindgen-macro"
6351version = "0.2.125"
6352source = "registry+https://github.com/rust-lang/crates.io-index"
6353checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
6354dependencies = [
6355 "quote",
6356 "wasm-bindgen-macro-support",
6357]
6358
6359[[package]]
6360name = "wasm-bindgen-macro-support"
6361version = "0.2.125"
6362source = "registry+https://github.com/rust-lang/crates.io-index"
6363checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
6364dependencies = [
6365 "bumpalo",
6366 "proc-macro2",
6367 "quote",
6368 "syn 2.0.118",
6369 "wasm-bindgen-shared",
6370]
6371
6372[[package]]
6373name = "wasm-bindgen-shared"
6374version = "0.2.125"
6375source = "registry+https://github.com/rust-lang/crates.io-index"
6376checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
6377dependencies = [
6378 "unicode-ident",
6379]
6380
6381[[package]]
6382name = "wasm-streams"
6383version = "0.4.2"
6384source = "registry+https://github.com/rust-lang/crates.io-index"
6385checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
6386dependencies = [
6387 "futures-util",
6388 "js-sys",
6389 "wasm-bindgen",
6390 "wasm-bindgen-futures",
6391 "web-sys",
6392]
6393
6394[[package]]
6395name = "web-sys"
6396version = "0.3.102"
6397source = "registry+https://github.com/rust-lang/crates.io-index"
6398checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
6399dependencies = [
6400 "js-sys",
6401 "wasm-bindgen",
6402]
6403
6404[[package]]
6405name = "web-time"
6406version = "1.1.0"
6407source = "registry+https://github.com/rust-lang/crates.io-index"
6408checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
6409dependencies = [
6410 "js-sys",
6411 "wasm-bindgen",
6412]
6413
6414[[package]]
6415name = "web_atoms"
6416version = "0.2.5"
6417source = "registry+https://github.com/rust-lang/crates.io-index"
6418checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
6419dependencies = [
6420 "phf 0.13.1",
6421 "phf_codegen",
6422 "string_cache",
6423 "string_cache_codegen",
6424]
6425
6426[[package]]
6427name = "webpki-roots"
6428version = "0.26.11"
6429source = "registry+https://github.com/rust-lang/crates.io-index"
6430checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
6431dependencies = [
6432 "webpki-roots 1.0.8",
6433]
6434
6435[[package]]
6436name = "webpki-roots"
6437version = "1.0.8"
6438source = "registry+https://github.com/rust-lang/crates.io-index"
6439checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
6440dependencies = [
6441 "rustls-pki-types",
6442]
6443
6444[[package]]
6445name = "weezl"
6446version = "0.1.12"
6447source = "registry+https://github.com/rust-lang/crates.io-index"
6448checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
6449
6450[[package]]
6451name = "whoami"
6452version = "2.1.2"
6453source = "registry+https://github.com/rust-lang/crates.io-index"
6454checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
6455
6456[[package]]
6457name = "wide"
6458version = "0.7.33"
6459source = "registry+https://github.com/rust-lang/crates.io-index"
6460checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
6461dependencies = [
6462 "bytemuck",
6463 "safe_arch",
6464]
6465
6466[[package]]
6467name = "winapi"
6468version = "0.3.9"
6469source = "registry+https://github.com/rust-lang/crates.io-index"
6470checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
6471dependencies = [
6472 "winapi-i686-pc-windows-gnu",
6473 "winapi-x86_64-pc-windows-gnu",
6474]
6475
6476[[package]]
6477name = "winapi-i686-pc-windows-gnu"
6478version = "0.4.0"
6479source = "registry+https://github.com/rust-lang/crates.io-index"
6480checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
6481
6482[[package]]
6483name = "winapi-util"
6484version = "0.1.11"
6485source = "registry+https://github.com/rust-lang/crates.io-index"
6486checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
6487dependencies = [
6488 "windows-sys 0.61.2",
6489]
6490
6491[[package]]
6492name = "winapi-x86_64-pc-windows-gnu"
6493version = "0.4.0"
6494source = "registry+https://github.com/rust-lang/crates.io-index"
6495checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
6496
6497[[package]]
6498name = "windows"
6499version = "0.48.0"
6500source = "registry+https://github.com/rust-lang/crates.io-index"
6501checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
6502dependencies = [
6503 "windows-targets 0.48.5",
6504]
6505
6506[[package]]
6507name = "windows-core"
6508version = "0.62.2"
6509source = "registry+https://github.com/rust-lang/crates.io-index"
6510checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
6511dependencies = [
6512 "windows-implement",
6513 "windows-interface",
6514 "windows-link",
6515 "windows-result",
6516 "windows-strings",
6517]
6518
6519[[package]]
6520name = "windows-implement"
6521version = "0.60.2"
6522source = "registry+https://github.com/rust-lang/crates.io-index"
6523checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
6524dependencies = [
6525 "proc-macro2",
6526 "quote",
6527 "syn 2.0.118",
6528]
6529
6530[[package]]
6531name = "windows-interface"
6532version = "0.59.3"
6533source = "registry+https://github.com/rust-lang/crates.io-index"
6534checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
6535dependencies = [
6536 "proc-macro2",
6537 "quote",
6538 "syn 2.0.118",
6539]
6540
6541[[package]]
6542name = "windows-link"
6543version = "0.2.1"
6544source = "registry+https://github.com/rust-lang/crates.io-index"
6545checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
6546
6547[[package]]
6548name = "windows-registry"
6549version = "0.6.1"
6550source = "registry+https://github.com/rust-lang/crates.io-index"
6551checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
6552dependencies = [
6553 "windows-link",
6554 "windows-result",
6555 "windows-strings",
6556]
6557
6558[[package]]
6559name = "windows-result"
6560version = "0.4.1"
6561source = "registry+https://github.com/rust-lang/crates.io-index"
6562checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
6563dependencies = [
6564 "windows-link",
6565]
6566
6567[[package]]
6568name = "windows-strings"
6569version = "0.5.1"
6570source = "registry+https://github.com/rust-lang/crates.io-index"
6571checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
6572dependencies = [
6573 "windows-link",
6574]
6575
6576[[package]]
6577name = "windows-sys"
6578version = "0.52.0"
6579source = "registry+https://github.com/rust-lang/crates.io-index"
6580checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
6581dependencies = [
6582 "windows-targets 0.52.6",
6583]
6584
6585[[package]]
6586name = "windows-sys"
6587version = "0.60.2"
6588source = "registry+https://github.com/rust-lang/crates.io-index"
6589checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
6590dependencies = [
6591 "windows-targets 0.53.5",
6592]
6593
6594[[package]]
6595name = "windows-sys"
6596version = "0.61.2"
6597source = "registry+https://github.com/rust-lang/crates.io-index"
6598checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
6599dependencies = [
6600 "windows-link",
6601]
6602
6603[[package]]
6604name = "windows-targets"
6605version = "0.48.5"
6606source = "registry+https://github.com/rust-lang/crates.io-index"
6607checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
6608dependencies = [
6609 "windows_aarch64_gnullvm 0.48.5",
6610 "windows_aarch64_msvc 0.48.5",
6611 "windows_i686_gnu 0.48.5",
6612 "windows_i686_msvc 0.48.5",
6613 "windows_x86_64_gnu 0.48.5",
6614 "windows_x86_64_gnullvm 0.48.5",
6615 "windows_x86_64_msvc 0.48.5",
6616]
6617
6618[[package]]
6619name = "windows-targets"
6620version = "0.52.6"
6621source = "registry+https://github.com/rust-lang/crates.io-index"
6622checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
6623dependencies = [
6624 "windows_aarch64_gnullvm 0.52.6",
6625 "windows_aarch64_msvc 0.52.6",
6626 "windows_i686_gnu 0.52.6",
6627 "windows_i686_gnullvm 0.52.6",
6628 "windows_i686_msvc 0.52.6",
6629 "windows_x86_64_gnu 0.52.6",
6630 "windows_x86_64_gnullvm 0.52.6",
6631 "windows_x86_64_msvc 0.52.6",
6632]
6633
6634[[package]]
6635name = "windows-targets"
6636version = "0.53.5"
6637source = "registry+https://github.com/rust-lang/crates.io-index"
6638checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
6639dependencies = [
6640 "windows-link",
6641 "windows_aarch64_gnullvm 0.53.1",
6642 "windows_aarch64_msvc 0.53.1",
6643 "windows_i686_gnu 0.53.1",
6644 "windows_i686_gnullvm 0.53.1",
6645 "windows_i686_msvc 0.53.1",
6646 "windows_x86_64_gnu 0.53.1",
6647 "windows_x86_64_gnullvm 0.53.1",
6648 "windows_x86_64_msvc 0.53.1",
6649]
6650
6651[[package]]
6652name = "windows_aarch64_gnullvm"
6653version = "0.48.5"
6654source = "registry+https://github.com/rust-lang/crates.io-index"
6655checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
6656
6657[[package]]
6658name = "windows_aarch64_gnullvm"
6659version = "0.52.6"
6660source = "registry+https://github.com/rust-lang/crates.io-index"
6661checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
6662
6663[[package]]
6664name = "windows_aarch64_gnullvm"
6665version = "0.53.1"
6666source = "registry+https://github.com/rust-lang/crates.io-index"
6667checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
6668
6669[[package]]
6670name = "windows_aarch64_msvc"
6671version = "0.48.5"
6672source = "registry+https://github.com/rust-lang/crates.io-index"
6673checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
6674
6675[[package]]
6676name = "windows_aarch64_msvc"
6677version = "0.52.6"
6678source = "registry+https://github.com/rust-lang/crates.io-index"
6679checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
6680
6681[[package]]
6682name = "windows_aarch64_msvc"
6683version = "0.53.1"
6684source = "registry+https://github.com/rust-lang/crates.io-index"
6685checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
6686
6687[[package]]
6688name = "windows_i686_gnu"
6689version = "0.48.5"
6690source = "registry+https://github.com/rust-lang/crates.io-index"
6691checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
6692
6693[[package]]
6694name = "windows_i686_gnu"
6695version = "0.52.6"
6696source = "registry+https://github.com/rust-lang/crates.io-index"
6697checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
6698
6699[[package]]
6700name = "windows_i686_gnu"
6701version = "0.53.1"
6702source = "registry+https://github.com/rust-lang/crates.io-index"
6703checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
6704
6705[[package]]
6706name = "windows_i686_gnullvm"
6707version = "0.52.6"
6708source = "registry+https://github.com/rust-lang/crates.io-index"
6709checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
6710
6711[[package]]
6712name = "windows_i686_gnullvm"
6713version = "0.53.1"
6714source = "registry+https://github.com/rust-lang/crates.io-index"
6715checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
6716
6717[[package]]
6718name = "windows_i686_msvc"
6719version = "0.48.5"
6720source = "registry+https://github.com/rust-lang/crates.io-index"
6721checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
6722
6723[[package]]
6724name = "windows_i686_msvc"
6725version = "0.52.6"
6726source = "registry+https://github.com/rust-lang/crates.io-index"
6727checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
6728
6729[[package]]
6730name = "windows_i686_msvc"
6731version = "0.53.1"
6732source = "registry+https://github.com/rust-lang/crates.io-index"
6733checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
6734
6735[[package]]
6736name = "windows_x86_64_gnu"
6737version = "0.48.5"
6738source = "registry+https://github.com/rust-lang/crates.io-index"
6739checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
6740
6741[[package]]
6742name = "windows_x86_64_gnu"
6743version = "0.52.6"
6744source = "registry+https://github.com/rust-lang/crates.io-index"
6745checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
6746
6747[[package]]
6748name = "windows_x86_64_gnu"
6749version = "0.53.1"
6750source = "registry+https://github.com/rust-lang/crates.io-index"
6751checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
6752
6753[[package]]
6754name = "windows_x86_64_gnullvm"
6755version = "0.48.5"
6756source = "registry+https://github.com/rust-lang/crates.io-index"
6757checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
6758
6759[[package]]
6760name = "windows_x86_64_gnullvm"
6761version = "0.52.6"
6762source = "registry+https://github.com/rust-lang/crates.io-index"
6763checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
6764
6765[[package]]
6766name = "windows_x86_64_gnullvm"
6767version = "0.53.1"
6768source = "registry+https://github.com/rust-lang/crates.io-index"
6769checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
6770
6771[[package]]
6772name = "windows_x86_64_msvc"
6773version = "0.48.5"
6774source = "registry+https://github.com/rust-lang/crates.io-index"
6775checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
6776
6777[[package]]
6778name = "windows_x86_64_msvc"
6779version = "0.52.6"
6780source = "registry+https://github.com/rust-lang/crates.io-index"
6781checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
6782
6783[[package]]
6784name = "windows_x86_64_msvc"
6785version = "0.53.1"
6786source = "registry+https://github.com/rust-lang/crates.io-index"
6787checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
6788
6789[[package]]
6790name = "winnow"
6791version = "0.7.15"
6792source = "registry+https://github.com/rust-lang/crates.io-index"
6793checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
6794dependencies = [
6795 "memchr",
6796]
6797
6798[[package]]
6799name = "wit-bindgen"
6800version = "0.57.1"
6801source = "registry+https://github.com/rust-lang/crates.io-index"
6802checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
6803
6804[[package]]
6805name = "writeable"
6806version = "0.6.3"
6807source = "registry+https://github.com/rust-lang/crates.io-index"
6808checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
6809
6810[[package]]
6811name = "x25519-dalek"
6812version = "2.0.1"
6813source = "registry+https://github.com/rust-lang/crates.io-index"
6814checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
6815dependencies = [
6816 "curve25519-dalek",
6817 "rand_core 0.6.4",
6818 "serde",
6819 "zeroize",
6820]
6821
6822[[package]]
6823name = "yoke"
6824version = "0.8.3"
6825source = "registry+https://github.com/rust-lang/crates.io-index"
6826checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
6827dependencies = [
6828 "stable_deref_trait",
6829 "yoke-derive",
6830 "zerofrom",
6831]
6832
6833[[package]]
6834name = "yoke-derive"
6835version = "0.8.2"
6836source = "registry+https://github.com/rust-lang/crates.io-index"
6837checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
6838dependencies = [
6839 "proc-macro2",
6840 "quote",
6841 "syn 2.0.118",
6842 "synstructure",
6843]
6844
6845[[package]]
6846name = "zerocopy"
6847version = "0.8.52"
6848source = "registry+https://github.com/rust-lang/crates.io-index"
6849checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
6850dependencies = [
6851 "zerocopy-derive",
6852]
6853
6854[[package]]
6855name = "zerocopy-derive"
6856version = "0.8.52"
6857source = "registry+https://github.com/rust-lang/crates.io-index"
6858checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
6859dependencies = [
6860 "proc-macro2",
6861 "quote",
6862 "syn 2.0.118",
6863]
6864
6865[[package]]
6866name = "zerofrom"
6867version = "0.1.8"
6868source = "registry+https://github.com/rust-lang/crates.io-index"
6869checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
6870dependencies = [
6871 "zerofrom-derive",
6872]
6873
6874[[package]]
6875name = "zerofrom-derive"
6876version = "0.1.7"
6877source = "registry+https://github.com/rust-lang/crates.io-index"
6878checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
6879dependencies = [
6880 "proc-macro2",
6881 "quote",
6882 "syn 2.0.118",
6883 "synstructure",
6884]
6885
6886[[package]]
6887name = "zeroize"
6888version = "1.9.0"
6889source = "registry+https://github.com/rust-lang/crates.io-index"
6890checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
6891dependencies = [
6892 "zeroize_derive",
6893]
6894
6895[[package]]
6896name = "zeroize_derive"
6897version = "1.5.0"
6898source = "registry+https://github.com/rust-lang/crates.io-index"
6899checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
6900dependencies = [
6901 "proc-macro2",
6902 "quote",
6903 "syn 2.0.118",
6904]
6905
6906[[package]]
6907name = "zerotrie"
6908version = "0.2.4"
6909source = "registry+https://github.com/rust-lang/crates.io-index"
6910checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
6911dependencies = [
6912 "displaydoc",
6913 "yoke",
6914 "zerofrom",
6915]
6916
6917[[package]]
6918name = "zerovec"
6919version = "0.11.6"
6920source = "registry+https://github.com/rust-lang/crates.io-index"
6921checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
6922dependencies = [
6923 "yoke",
6924 "zerofrom",
6925 "zerovec-derive",
6926]
6927
6928[[package]]
6929name = "zerovec-derive"
6930version = "0.11.3"
6931source = "registry+https://github.com/rust-lang/crates.io-index"
6932checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
6933dependencies = [
6934 "proc-macro2",
6935 "quote",
6936 "syn 2.0.118",
6937]
6938
6939[[package]]
6940name = "zmij"
6941version = "1.0.21"
6942source = "registry+https://github.com/rust-lang/crates.io-index"
6943checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
6944
6945[[package]]
6946name = "zune-core"
6947version = "0.4.12"
6948source = "registry+https://github.com/rust-lang/crates.io-index"
6949checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
6950
6951[[package]]
6952name = "zune-inflate"
6953version = "0.2.54"
6954source = "registry+https://github.com/rust-lang/crates.io-index"
6955checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
6956dependencies = [
6957 "simd-adler32",
6958]
6959
6960[[package]]
6961name = "zune-jpeg"
6962version = "0.4.21"
6963source = "registry+https://github.com/rust-lang/crates.io-index"
6964checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
6965dependencies = [
6966 "zune-core",
6967]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..6edc36f
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,58 @@
1[package]
2name = "senator-sperma-rust"
3version = "0.2.0"
4edition = "2024"
5
6[profile.dev]
7debug = 1
8
9[dependencies]
10# Discord
11poise = "0.6.2"
12serenity = "0.12.5"
13songbird = "0.6"
14
15# Async runtime
16tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "process", "time", "io-util"] }
17
18# Database
19sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "chrono"] }
20
21# HTTP
22reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
23rustls = { version = "0.23", features = ["ring"] }
24
25# Serialization
26serde = { version = "1.0.228", features = ["derive"] }
27serde_json = "1.0.150"
28
29# Config
30dotenvy = "0.15.7"
31
32# Logging
33tracing = "0.1.44"
34tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
35
36# Time & Cron
37chrono = { version = "0.4.45", features = ["serde"] }
38cron = "0.17"
39
40# QR
41qrcode = "0.14.1"
42image = "=0.25.8"
43rxing = { version = "0.8", default-features = false, features = ["image", "encoding_rs"] }
44
45# HTML parsing
46scraper = "0.27"
47
48# Audio metadata
49symphonia = { version = "0.6", default-features = false, features = ["mp3", "aac", "alac", "flac", "ogg", "wav", "isomp4"] }
50# Enable pcm codec on songbird's internal symphonia 0.5 for RawReader support
51songbird-codec-pcm = { package = "symphonia", version = "0.5", features = ["pcm"] }
52
53# Utilities
54rand = "0.10.1"
55uuid = { version = "1.23.3", features = ["v4"] }
56glob = "0.3.3"
57tempfile = "3.27"
58futures-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 @@
1# Senator Sperma 1# Senator Sperma (Rust)
2 2
3A Discord bot with music playback, TTS, web search, and utility commands. 3A Discord bot rebuilt in Rust from the original Python implementation. Music
4playback via songbird, AI chat via OpenRouter or anything OpenAI compatible (ollama.cpp and such), TTS, web/file search
5scheduled jobs, per-server triggers, QR codes, media conversion, and misc utilities.
4 6
5Requires Python 3.14. 7Built on poise 0.6 + serenity 0.12 + songbird 0.6, with async SQLite (sqlx 0.9)
8for persistence.
6 9
7## Setup 10## Setup
8 11
9### 1. Clone & Create Virtual Environment 12### 1. Clone
10 13
11```bash 14```bash
12git clone https://codeberg.org/salmonik/senator-sperma 15git clone https://codeberg.org/salmonik/senator-sperma
13cd senator-sperma 16cd senator-sperma
14python -m venv .venv
15source .venv/bin/activate # Linux/macOS
16# or .venv\Scripts\activate # Windows
17``` 17```
18 18
19### 2. Install Dependencies 19### 2. System Dependencies
20 20
21```bash 21The following binaries must be available on `PATH`:
22pip install -r requirements.txt 22
23``` 23- `ffmpeg` — audio decoding/encoding for playback, conversion, and compression
24- `qalc` (Qalculate) — used by `/math` to evaluate expressions
25- `yt-dlp` (optional) — used by `/download`
24 26
25You also need `ffmpeg` and `zbar` library and installed system-wide 27Unlike the Python version, QR encode/decode is handled by the pure-Rust
28`qrcode`/`rxing` crates — no `zbar` system library required.
26 29
27### 3. Create .env File 30### 3. Build
28 31
29Create a `.env` file in the project root: 32Requires a recent Rust toolchain (edition 2024).
30 33
31```bash 34```bash
32cp .env.example .env 35cargo build --release
33``` 36```
34 37
35Edit `.env` with your credentials: 38### 4. Configure Environment
36 39
37```env 40Create a `.env` in the project root
38# Required 41look at `.env.example` on what to put there
39TOKEN=your_discord_bot_token
40 42
41# Optional - Bot Configuration 43On launch, missing variables are reported via tracing (`error!` for required,
42STATUS_CHANNEL_ID=123456789012345678 # Channel for startup message 44`warn!` for optional with defaults). The database and schema migrations are
43ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant 45created automatically on first run.
44 46
45# Optional - File Server (for music /search commands) 47### 5. Run
46COPYPARTY_URL=http://localhost:8080
47COPYPARTY_PASSWORD=your_password
48
49# Optional - AI commands
50OPENROUTER_API_KEY=
51```
52
53### 6. Run the Bot
54 48
55```bash 49```bash
56python main.py 50cargo run --release
57``` 51```
58 52
59## Configuration
60
61All settings are optional - defaults are provided if not set.
62
63| Variable | Description | Default |
64|---------|-------------|---------|
65| `TOKEN` | Discord bot token | (required) |
66| `STATUS_CHANNEL_ID` | Channel for startup message | none |
67| `ASSISTANT_KEYWORD` | Keyword to trigger AI assistant | `jarvis` |
68| `COPYPARTY_URL` | File server base URL | `http://localhost:8080` |
69| `COPYPARTY_PASSWORD` | File server password | none |
70| `OPENROUTER_API_KEY` | AI API key (for /senator command) | none |
71
72## Commands 53## Commands
73 54
74### Music 55Slash commands are autoregistered globally on startup. Some are registered
75- `/play` - Play music from file server or an internet audio stream 56under their original Polish verb names to match the UI expected by existing
76- `/stop` - Stop playback 57servers.
77- `/join` - Join voice channel 58
78- `/loop` - Loop current track 59### AI
79 60- `/senator` — chat with the OpenRouter model
80### Utility 61
81- `/search` - Search files on server 62### Music (songbird)
82- `/web` - DuckDuckGo search 63- `/grajcuj` — play from Copyparty search, a direct URL, or an uploaded file
83- `/download` - Download video/audio 64- `/stop` — stop playback and leave voice
84- `/tts` - Text to speech 65- `/join` — join the caller's voice channel
85- `/qr_koduj` / `qr_dekoduj` - QR encode/decode 66
86- `/kompresja` - Compress images 67### TTS
87- `/konwersja` - Convert audio/video 68- `/tts` — Google Translate TTS (Polish), sent as a file or played in voice
88 69
89## Dependencies 70### Search & Web
90 71- `/web` — DuckDuckGo HTML scrape (soon to use 4get)
91- `ffmpeg` - System-wide audio processing 72- `/search` — search Copyparty and return a direct download URL
92- `py-cord` - Discord library (due to instable nature of discord's voice features, py-cord has to be installed directly from git) 73- `/download` — fetch media from any yt-dlp-supported URL
74
75### Media Tools
76- `/compress` (alias `kompresja`) — compress a media attachment to low quality
77- `/convert` (alias `konwersja`) — convert media between formats
78
79### Utilities
80- `/upload` — push Discord attachments into Copyparty
81- `/say` — repeat the given text as the bot
82- `/math` — evaluate an ASCII math expression through `qalc`
83- `/qr generowanie` / `/qr dekodowanie` — QR encode / decode
84
85### Triggers
86- `/trigger create` — new trigger group
87- `/trigger add` — add keywords/responses to a group
88- `/trigger remove` — remove a keyword/response
89- `/trigger delete` — delete a group
90- `/trigger list` — list a group's keywords and responses
91- `/trigger help` — usage info
92
93### Cron
94- `/cron dodaj_audio` — schedule audio playback
95- `/cron dodaj_wiadomość` — schedule a periodic message
96- `/cron pokaż` — list all cron jobs
97- `/cron usuń` — remove a cron job
98- `/cron pauza` — pause a job
99- `/cron wznów` — resume a job
100
101### Message Context Menu
102- "Wyślij załączniki" (right-click message) — upload attachments to Copyparty
103- "kompresja" (right-click message) — compress all attachments
104
105## Architecture
106
107The runtime is organised around a shared `Data` struct (poise `user_data`)
108that holds the DB pool, HTTP client, Copyparty client, voice manager, the
109in-memory trigger cache, autocomplete cache, and per-guild music track handles.
110
111- `src/main.rs` — boot: tracing, dotenvy, Config, DB pool, framework, cron task
112- `src/config.rs` — env-driven `Config` + variable validation
113- `src/commands/` — one module per command group
114- `src/copyparty/` — Copyparty search/upload client
115- `src/openrouter/` — OpenRouter chat client
116- `src/database/` — sqlx pool, migrations, query helpers (6 tables)
117- `src/voice/` — `VoiceManager`, mp3 → songbird input
118- `src/events/` — `handle_ready`, voice-state, message (trigger matching)
119- `src/tasks/` — 30s cron scheduler poll loop
120- `src/lang.rs` — user-facing Polish strings
121
122All temporary files go through `crate::config::TEMP_PATH`
123(`/tmp/senator_sperma`).
diff --git a/cogs/ai.py b/cogs/ai.py
deleted file mode 100644
index ef2ba7c..0000000
--- a/cogs/ai.py
+++ /dev/null
@@ -1,78 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import discord
8from discord.ext import commands
9import logging
10import httpx
11from gtts import gTTS
12import io
13
14logger = logging.getLogger(__name__)
15
16class AI(commands.Cog):
17 def __init__(self, bot):
18 self.bot = bot
19
20 @commands.slash_command(
21 name="senator",
22 description="SEENATOR",
23 )
24 async def senator(
25 self,
26 ctx: discord.ApplicationContext,
27 text: str = discord.Option(str, "message", required=True),
28 tts: str = discord.Option(bool, "face?", default=False)
29 ):
30 await ctx.defer()
31
32
33 url = "https://openrouter.ai/api/v1/chat/completions"
34 headers = {
35 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
36 "Content-Type": "application/json",
37 }
38 payload = {
39 "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
40 "messages": [
41 {
42 "role": "system",
43 "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \
44 You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \
45 If the message is in Polish, respond in Polish. Respond in 100 words."
46 },
47 {
48 "role": "user",
49 "content": text
50 }
51 ],
52
53 }
54 async with httpx.AsyncClient() as client:
55 response = await client.post(url=url, headers=headers, json=payload)
56 response_data = response.json()
57 logger.info(response_data)
58 if not response_data["choices"]:
59 await ctx.send_followup("bot siem zesroł")
60 else:
61 response_text = response_data["choices"][0]["message"]["content"]
62 if tts:
63 tts_obj = gTTS(text=response_text, lang='pl')
64 tts_buffer = io.BytesIO()
65 tts_obj.write_to_fp(tts_buffer)
66 tts_buffer.seek(0)
67 from cogs.voice import get_voice_manager
68 vm = get_voice_manager(self.bot)
69 channel = ctx.author.voice.channel
70 await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False)
71
72 else:
73 await ctx.send_followup(response_text)
74
75
76
77def setup(bot):
78 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 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import discord
7from discord.ext import commands
8import httpx
9import logging
10import os
11
12logger = logging.getLogger(__name__)
13
14class Jarvis(commands.Cog):
15 def __init__(self, bot):
16 self.bot = bot
17 self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower()
18
19 @commands.Cog.listener()
20 async def on_message(self, message):
21 if message.author == self.bot.user:
22 return
23
24 logger.info(message.content)
25
26 if message.mentions:
27 if message.mentions[0].id == self.bot.user.id:
28 await self.assistant(message)
29
30 elif self.keyword in message.content.lower():
31 await self.assistant(message)
32
33 @staticmethod
34 async def assistant(message):
35 headers = {
36 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
37 "Content-Type": "application/json"
38 }
39
40 chain = await Jarvis._build_message_chain(message)
41 messages = Jarvis._build_messages(message, chain)
42 payload = Jarvis._build_payload(messages)
43
44 response_data = await Jarvis._safe_api_call(payload, headers)
45 if response_data is None:
46 await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.")
47 return
48
49 response_message = Jarvis._parse_response(response_data)
50
51 channel = message.channel
52 await message.reply(response_message)
53
54 @staticmethod
55 async def _build_message_chain(message):
56 chain = []
57 current = message
58 max_depth = 5
59
60 while current and len(chain) < max_depth:
61 chain.append(current)
62 if not current.reference:
63 break
64 try:
65 if current.reference.resolved:
66 current = current.reference.resolved
67 else:
68 current = await current.channel.fetch_message(current.reference.message_id)
69 except discord.NotFound:
70 break
71
72 return list(reversed(chain))
73
74 @staticmethod
75 def _build_messages(message, chain=None):
76 messages = [{
77 "role": "system",
78 "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis."
79 "Twoim zadaniem jest asystowanie użytkownika"
80 "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon"
81 "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał"
82 "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia"
83 }]
84
85 if chain is None:
86 chain = [message]
87
88 for msg in chain:
89 messages.append({
90 "role": "user",
91 "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}"
92 })
93
94 return messages
95
96 @staticmethod
97 def _build_payload(messages):
98 return {
99 "model": os.getenv("OPENROUTER_MODEL"),
100 "messages": messages,
101 "plugins": [{"id": "response-healing"}]
102 }
103
104 @staticmethod
105 async def _make_api_request(payload, headers):
106 async with httpx.AsyncClient(timeout=30.0) as client:
107 url = "https://openrouter.ai/api/v1/chat/completions"
108 response = await client.post(url=url, headers=headers, json=payload)
109 response_data = response.json()
110 logger.info(response_data)
111 return response_data
112
113 @staticmethod
114 async def _safe_api_call(payload, headers):
115 try:
116 return await Jarvis._make_api_request(payload, headers)
117 except httpx.HTTPError as e:
118 logger.error(f"API request failed: {e}")
119 return None
120
121 @staticmethod
122 def _parse_response(response_data):
123 content = response_data['choices'][0]['message']['content']
124
125 logger.info(content)
126
127 return content
128
129def setup(bot):
130 bot.add_cog(Jarvis(bot))
diff --git a/cogs/compression.py b/cogs/compression.py
deleted file mode 100644
index 71348dc..0000000
--- a/cogs/compression.py
+++ /dev/null
@@ -1,306 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import mimetypes
7import os
8import asyncio
9import aiofiles
10import requests
11import urllib.parse
12from typing import List, Optional
13from os import PathLike
14import discord
15from discord.ext import commands
16from discord import AutocompleteContext
17from cogs.utils import file_search as search_utils
18import shlex
19import logging
20
21logger = logging.getLogger(__name__)
22
23def filetype(filename):
24 mime_type, _ = mimetypes.guess_type(filename)
25 if mime_type:
26 if mime_type.startswith('video/'):
27 return "Video"
28 elif mime_type.startswith('audio/'):
29 return "Audio"
30 elif mime_type.startswith('image/'):
31 return "Image"
32 return "Unknown"
33
34class Compression(commands.Cog):
35 def __init__(self, bot):
36 from cogs.utils.temp import get_temp_dir
37 from cogs.utils.settings import get_baseurl
38 self.bot = bot
39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {}
41 self._baseurl = get_baseurl()
42
43 async def _file_autocomplete(self, ctx: AutocompleteContext):
44 query = ctx.value.lower() if ctx.value else ""
45 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
46
47 user_id = ctx.interaction.user.id
48 self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
49
50 choices = []
51 for url in results[:25]:
52 filename = urllib.parse.unquote(url.split('/')[-1])
53 display_name = filename[:99] + "…" if len(filename) > 100 else filename
54 choices.append(discord.OptionChoice(name=display_name, value=filename))
55 return choices
56
57 async def _download_from_url(self, url: str) -> Optional[str]:
58 """Download file from copyparty URL and return local path"""
59 try:
60 response = requests.get(url)
61 if response.status_code != 200:
62 return None
63
64 filename = url.split('/')[-1].split('?')[0]
65 filename = urllib.parse.unquote(filename)
66 file_path = os.path.join(self.temp_dir, filename)
67
68 async with aiofiles.open(file_path, 'wb') as f:
69 await f.write(response.content)
70 return file_path
71 except Exception as e:
72 logger.error(f"Error downloading from {url}: {e}")
73 return None
74
75 async def _upload_to_copyparty(self, file_path: str) -> Optional[str]:
76 """Upload file to copyparty and return download URL"""
77 try:
78 async with aiofiles.open(file_path, 'rb') as f:
79 file_content = await f.read()
80
81 files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')}
82 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
83 params = {'j': '1', 'ck': 'b2'}
84
85 response = requests.post(self._baseurl, files=files, headers=headers, params=params)
86
87 if response.status_code in [200, 201]:
88 result = response.json()
89 return result.get('fileurl')
90 except Exception as e:
91 logger.error(f"Upload error: {e}")
92 return None
93
94 def _process_video(self, file_path: str, output_path: str) -> str:
95 """Process video compression"""
96 command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:v libx264 -crf 51 -b:v 100 -vf 'fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2' -preset ultrafast -c:a libopus -b:a 500 {shlex.quote(output_path)}"
97 return command
98
99 def _process_audio(self, file_path: str, output_path: str) -> str:
100 """Process audio compression"""
101 if output_path.lower().endswith('.mp3'):
102 output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '')
103 if not output_path.lower().endswith('.ogg'):
104 output_path = os.path.splitext(output_path)[0] + '.ogg'
105 command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}"
106 return command
107
108 def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]:
109 """Process image compression"""
110 ext = os.path.splitext(file_path)[1].lower()
111 if ext in [".jpg", ".jpeg"]:
112 command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}"
113 elif ext == ".gif":
114 command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}"
115 else:
116 output_path = os.path.splitext(output_path)[0] + ".jpg"
117 command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}"
118 return command, output_path
119
120 async def _execute_compression(self, command: str) -> bool:
121 """Execute compression command"""
122 proc = await asyncio.create_subprocess_exec(
123 *shlex.split(command),
124 stdout=asyncio.subprocess.PIPE,
125 stderr=asyncio.subprocess.PIPE
126 )
127 await proc.wait()
128 return proc.returncode == 0
129
130 async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]:
131 """Compress a single attachment and return the compressed file"""
132 try:
133 file_path: str = os.path.join(self.temp_dir, attachment.filename)
134 output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}")
135
136 # Save the original file
137 await attachment.save(file_path)
138
139 # Process the file
140 file_type = filetype(file_path)
141 logger.info(f"Compression started for: {file_type}")
142
143 command = self._get_compression_command(file_type, file_path, output_path)
144 if command is None:
145 logger.info(f"Unsupported file type: {file_type}")
146 os.remove(file_path)
147 return None
148
149 # Update output_path if it was modified
150 if file_type == "Image":
151 command, output_path = command
152 elif file_type == "Audio":
153 # output_path is already modified in _process_audio
154 pass
155
156 # Run the compression
157 success = await self._execute_compression(command)
158
159 if success and os.path.exists(output_path):
160 logger.info(f"Successfully compressed: {attachment.filename}")
161 return discord.File(output_path, filename=f"compressed_{attachment.filename}")
162 return None
163
164 except Exception as e:
165 logger.error(f"Error processing {attachment.filename}: {str(e)}")
166 return None
167
168 finally:
169 # Clean up temporary files
170 if 'file_path' in locals() and os.path.exists(file_path):
171 os.remove(file_path)
172
173 def _get_compression_command(self, file_type: str, file_path: str, output_path: str):
174 """Get the appropriate compression command based on file type"""
175 if file_type == "Video":
176 return self._process_video(file_path, output_path)
177 elif file_type == "Audio":
178 return self._process_audio(file_path, output_path)
179 elif file_type == "Image":
180 return self._process_image(file_path, output_path)
181 else:
182 return None
183
184 async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]:
185 """Compress multiple attachments and return list of compressed files"""
186 compressed_files = []
187 for attachment in attachments:
188 compressed_file = await self._compress_attachment(attachment)
189 if compressed_file:
190 compressed_files.append(compressed_file)
191 return compressed_files
192
193 @commands.message_command(
194 name="kompresja",
195 description="skompresuj wideło albo dźwienk :)",
196 )
197 async def message_compression(self, ctx, message: discord.Message):
198 if not message.attachments:
199 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
200 return
201
202 await ctx.defer()
203 compressed_files = await self._compress_attachments(message.attachments)
204
205 # Send all compressed files in one message
206 if compressed_files:
207 await ctx.respond(
208 f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:",
209 files=compressed_files
210 )
211
212 # Clean up compressed files
213 for file in compressed_files:
214 if os.path.exists(file.fp.name): # type: ignore[attr-defined]
215 os.remove(file.fp.name) # type: ignore[attr-defined]
216 else:
217 await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True)
218
219 @commands.slash_command(
220 name="kompresja",
221 description="skompresuj wideło albo dźwienk :)",
222 )
223 @discord.option(
224 name="plik",
225 description="plik z copyparty",
226 required=False,
227 autocomplete=_file_autocomplete
228 )
229 @discord.option(
230 name="zalacznik",
231 description="plik z discorda",
232 required=False,
233 type=discord.Attachment
234 )
235 @discord.option(
236 name="serwer?",
237 description="wysłać plik na serwer?",
238 required=False,
239 default=False,
240 type=bool
241 )
242 async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False):
243 await ctx.defer()
244
245 file_path = None
246 original_filename = None
247
248 if plik:
249 url = self._cached_urls.get(ctx.author.id, {}).get(plik)
250 if not url:
251 await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True)
252 return
253 file_path = await self._download_from_url(url)
254 original_filename = plik
255 elif zalacznik:
256 file_path = os.path.join(self.temp_dir, zalacznik.filename)
257 original_filename = zalacznik.filename
258 await zalacznik.save(file_path)
259 else:
260 await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True)
261 return
262
263 if not file_path or not os.path.exists(file_path):
264 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
265 return
266
267 try:
268 file_type = filetype(file_path)
269 output_path = os.path.join(self.temp_dir, f"better_{original_filename}")
270
271 command = self._get_compression_command(file_type, file_path, output_path)
272 if command is None:
273 await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True)
274 return
275
276 if file_type == "Image":
277 command, output_path = command
278 elif file_type == "Audio":
279 # Audio output path remains unchanged - already processed in _process_audio
280 pass
281
282 success = await self._execute_compression(command)
283
284 if not success or not os.path.exists(output_path):
285 await ctx.respond("Kompresja nie powiodła się", ephemeral=True)
286 return
287
288 file_size = os.path.getsize(output_path)
289 if file_size > 1024 * 1024 * 10 or server:
290 dl_url = await self._upload_to_copyparty(output_path)
291 if dl_url:
292 await ctx.respond(f"Skompresowano\n{dl_url}")
293 else:
294 await ctx.respond("Nie udało wysłać na serwer", ephemeral=True)
295 else:
296 await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}"))
297
298 finally:
299 if file_path and os.path.exists(file_path):
300 os.remove(file_path)
301 if 'output_path' in locals() and os.path.exists(output_path):
302 os.remove(output_path)
303
304
305def setup(bot):
306 bot.add_cog(Compression(bot))
diff --git a/cogs/conversion.py b/cogs/conversion.py
deleted file mode 100644
index f9e4c84..0000000
--- a/cogs/conversion.py
+++ /dev/null
@@ -1,245 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import discord
7import requests
8from discord.ext import commands
9import logging
10import os
11import asyncio
12import aiofiles
13import mimetypes
14import subprocess
15import urllib.parse
16import shlex
17from typing import Optional
18from discord import AutocompleteContext
19from cogs.utils import file_search as search_utils
20
21logger = logging.getLogger(__name__)
22
23def filetype(filename):
24 mime_type, _ = mimetypes.guess_type(filename)
25 if mime_type:
26 if mime_type.startswith('video/'):
27 return "Video"
28 elif mime_type.startswith('audio/'):
29 return "Audio"
30 elif mime_type.startswith('image/'):
31 return "Image"
32 return "Unknown"
33
34class Conversion(commands.Cog):
35 def __init__(self, bot):
36 self.bot = bot
37 from cogs.utils.temp import get_temp_dir
38 from cogs.utils.settings import get_baseurl, get_uploadurl
39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {}
41 self._baseurl = get_baseurl()
42 self._uploadurl = get_uploadurl()
43
44 async def _file_autocomplete(self, ctx: AutocompleteContext):
45 query = ctx.value.lower() if ctx.value else ""
46 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
47
48 user_id = ctx.interaction.user.id
49 self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
50
51 choices = []
52 for url in results[:25]:
53 filename = urllib.parse.unquote(url.split('/')[-1])
54 display_name = filename[:99] + "…" if len(filename) > 100 else filename
55 choices.append(discord.OptionChoice(name=display_name, value=filename))
56 return choices
57
58 async def _download_from_url(self, url: str) -> Optional[str]:
59 """Download file from copyparty URL and return local path"""
60 try:
61 response = requests.get(url)
62 if response.status_code != 200:
63 return None
64
65 filename = url.split('/')[-1].split('?')[0]
66 filename = urllib.parse.unquote(filename)
67 file_path = os.path.join(self.temp_dir, filename)
68
69 async with aiofiles.open(file_path, 'wb') as f:
70 await f.write(response.content)
71 return file_path
72 except Exception as e:
73 logger.error(f"Error downloading from {url}: {e}")
74 return None
75
76 async def _setup_file_paths(self, plik, output_format):
77 """Setup input and output file paths"""
78 from cogs.utils.temp import get_temp_dir
79
80 temp_dir = get_temp_dir()
81 file_path = os.path.join(temp_dir, plik.filename)
82 filename_noext = os.path.splitext(plik.filename)[0]
83 await plik.save(file_path)
84
85 input_path = file_path
86 output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}")
87 return input_path, output_path
88
89 def _build_conversion_command(self, file_type, input_path, output_path, args):
90 """Build the appropriate conversion command based on file type"""
91 if file_type in ["Video", "Audio"]:
92 return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}"
93 elif file_type == "Image":
94 return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}"
95 else:
96 return None
97
98 async def _upload_to_copyparty(self, output_path):
99 """Upload file to copyparty and return download URL"""
100 try:
101 async with aiofiles.open(output_path, 'rb') as f:
102 file_content = await f.read()
103
104 files = {
105 'f': (os.path.basename(output_path), file_content, 'application/octet-stream')
106 }
107 headers = {
108 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'
109 }
110 params = {
111 'j': '1',
112 'ck': 'b2'
113 }
114
115 response = requests.post(self._uploadurl, files=files, headers=headers, params=params)
116
117 if response.status_code in [200, 201]:
118 result = response.json()
119 if 'fileurl' in result:
120 return result['fileurl']
121 else:
122 raise ValueError(f"Server response missing fileurl: {result}")
123 else:
124 error_msg = response.text if response.text else 'Unknown error'
125 raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}")
126
127 except Exception as e:
128 logger.error(f"copyparty upload error: {e}", exc_info=True)
129 raise
130
131 async def _handle_file_output(self, ctx, output_path):
132 """Handle file output based on size"""
133 if os.path.getsize(output_path) > 1024*1024*10:
134 try:
135 dl_url = await self._upload_to_copyparty(output_path)
136 await ctx.followup.send(f"wysyłon\n{dl_url}\n")
137 except Exception as upload_error:
138 await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}")
139 else:
140 await ctx.respond(file=discord.File(output_path))
141
142 def _cleanup_files(self, *file_paths):
143 """Clean up temporary files"""
144 for path in file_paths:
145 if os.path.exists(path):
146 os.remove(path)
147
148 @commands.slash_command(name="konwersja", description="konwertuj plik na inny format")
149 @discord.option(
150 name="plik",
151 description="plik z copyparty",
152 required=False,
153 autocomplete=_file_autocomplete
154 )
155 @discord.option(
156 name="zalacznik",
157 description="plik z discorda",
158 required=False,
159 type=discord.Attachment
160 )
161 @discord.option(
162 name="serwer?",
163 description="wysłać plik na serwer?",
164 required=False,
165 type=bool,
166 default=False
167 )
168 @discord.option(
169 name="format",
170 description="format konwersji",
171 required=True,
172 type=str
173 )
174 async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None):
175 await ctx.defer()
176
177 input_path = None
178 original_filename = None
179
180 try:
181 if plik:
182 url = self._cached_urls.get(ctx.author.id, {}).get(plik)
183 if not url:
184 await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True)
185 return
186 input_path = await self._download_from_url(url)
187 original_filename = plik
188 elif zalacznik:
189 input_path = os.path.join(self.temp_dir, zalacznik.filename)
190 original_filename = zalacznik.filename
191 await zalacznik.save(input_path)
192 else:
193 await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True)
194 return
195
196 if not input_path or not os.path.exists(input_path):
197 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
198 return
199
200 output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}")
201
202 file_type = filetype(input_path)
203 logger.info(f"Conversion started for: {file_type}")
204
205 command = self._build_conversion_command(file_type, input_path, output_path, "")
206 logger.info(f"Running: {command}")
207 if not command:
208 await ctx.respond("Plik jest nieobsługiwany", ephemeral=True)
209 return
210
211 proc = await asyncio.create_subprocess_exec(
212 *shlex.split(command),
213 stdout=asyncio.subprocess.PIPE,
214 stderr=asyncio.subprocess.PIPE
215 )
216 stdout, stderr = await proc.communicate()
217
218 if proc.returncode != 0:
219 logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}")
220 await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True)
221 return
222
223 if os.path.getsize(output_path) > 1024 * 1024 * 10 or server:
224 dl_url = await self._upload_to_copyparty(output_path)
225 if dl_url:
226 await ctx.respond(f"Skonwertowano\n{dl_url}")
227 else:
228 await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True)
229 else:
230 await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}"))
231
232 logger.info(f"Conversion completed for: {file_type}")
233
234 except subprocess.CalledProcessError as e:
235 await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True)
236 except Exception as e:
237 await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True)
238 finally:
239 if input_path and os.path.exists(input_path):
240 os.remove(input_path)
241 if 'output_path' in locals() and os.path.exists(output_path):
242 os.remove(output_path)
243
244def setup(bot):
245 bot.add_cog(Conversion(bot))
diff --git a/cogs/cron.py b/cogs/cron.py
deleted file mode 100644
index ee88085..0000000
--- a/cogs/cron.py
+++ /dev/null
@@ -1,403 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import asyncio
7import logging
8from datetime import datetime
9from venv import logger
10
11import discord
12from croniter import croniter
13from discord.ext import commands
14
15from cogs.utils import file_search as search_utils
16from cogs.voice import get_voice_manager
17from cogs.utils.database import get_connection, init_database
18
19logger = logging.getLogger(__name__)
20
21init_database()
22
23
24class Cron(commands.Cog):
25 def __init__(self, bot):
26 self.bot = bot
27 self.scheduled_tasks = []
28 self.load_tasks()
29 self.task = bot.loop.create_task(self.schedule_checker())
30
31 def load_tasks(self):
32 try:
33 with get_connection() as conn:
34 c = conn.cursor()
35 c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs")
36 rows = c.fetchall()
37
38 self.scheduled_tasks = [
39 {
40 "id": row[0],
41 "cron_expression": row[1],
42 "type": row[2],
43 "channel_id": row[3],
44 "active": bool(row[4]),
45 "audio_filename": row[5],
46 "message": row[6],
47 }
48 for row in rows
49 ]
50 logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database")
51 except Exception as e:
52 logger.error(f"Error loading cron jobs: {e}")
53 self.scheduled_tasks = []
54
55 def save_tasks(self):
56 try:
57 with get_connection() as conn:
58 c = conn.cursor()
59 c.execute("PRAGMA foreign_keys = ON")
60
61 c.execute("DELETE FROM cron_jobs")
62
63 for task in self.scheduled_tasks:
64 c.execute(
65 """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message)
66 VALUES (?, ?, ?, ?, ?, ?, ?)""",
67 (
68 task.get("id"),
69 task["cron_expression"],
70 task["type"],
71 task["channel_id"],
72 int(task.get("active", True)),
73 task.get("audio_filename"),
74 task.get("message"),
75 ),
76 )
77 conn.commit()
78 except Exception as e:
79 logger.error(f"Error saving cron jobs: {e}")
80
81 async def schedule_checker(self):
82 """Background task to check and execute scheduled jobs"""
83 await self.bot.wait_until_ready()
84 while not self.bot.is_closed():
85 try:
86 now = datetime.now()
87 for task in self.scheduled_tasks:
88 if not task.get("active", True):
89 continue
90 if self.should_run_task(task, now):
91 await self.execute_task(task)
92 await asyncio.sleep(30)
93 except Exception as e:
94 logger.error(f"Error in schedule checker: {e}")
95 await asyncio.sleep(30)
96
97 def should_run_task(self, task, now):
98 """Check if a task should run now based on its cron expression"""
99 try:
100 cron_expr = task["cron_expression"]
101 cron = croniter(cron_expr, now)
102 prev_time = cron.get_prev(datetime)
103 time_diff = abs((now - prev_time).total_seconds())
104 result = time_diff <= 30
105 logger.debug(
106 f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}"
107 )
108 return result
109 except Exception as e:
110 logger.error(
111 f"Error checking cron expression {task.get('cron_expression')}: {e}"
112 )
113 return False
114
115 async def execute_task(self, task):
116 """Execute a scheduled task"""
117 try:
118 task_type = task["type"]
119 channel_id = task["channel_id"]
120
121 channel = self.bot.get_channel(channel_id)
122 if not channel:
123 logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}")
124 return
125
126 logger.info(f"Found channel {channel} (type: {type(channel).__name__})")
127
128 perms = channel.permissions_for(channel.guild.me)
129 logger.debug(
130 f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}"
131 )
132
133 if task_type in ("zagraj_audio", "audio"):
134 await self.play_audio_task(task, channel)
135 elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"):
136 await self.send_message_task(task, channel)
137
138 logger.info(f"Executed task {task['id']} of type {task_type}")
139 except Exception as e:
140 logger.error(f"Error executing task {task.get('id')}: {e}")
141
142 async def play_audio_task(self, task, channel):
143 """Play audio task implementation using voice manager"""
144 audio_url = task["audio_filename"]
145
146 if isinstance(channel, discord.VoiceChannel):
147 voice_mgr = get_voice_manager(self.bot)
148
149 try:
150 audio_source = discord.FFmpegPCMAudio(audio_url)
151 success = await voice_mgr.play_source(channel, audio_source)
152
153 if success:
154 logger.info(f"Finished playing audio: {audio_url}")
155 else:
156 raise Exception("Playback failed")
157
158 await voice_mgr.disconnect(channel.guild)
159 except Exception as e:
160 logger.error(f"Error playing audio: {e}")
161 try:
162 await voice_mgr.disconnect(channel.guild)
163 except Exception:
164 pass
165 if channel.guild.system_channel:
166 await channel.guild.system_channel.send(
167 f"Błąd podczas odgrywania audio: {audio_url}"
168 )
169 else:
170 if hasattr(channel, "send"):
171 await channel.send("Odtwarzanie audio wymaga kanału głosowego")
172
173 async def send_message_task(self, task, channel):
174 """Send message task implementation"""
175 message = task["message"]
176 logger.info(f"Message to send: '{message}' | Channel: {channel.id}")
177 await channel.send(message)
178 logger.debug("Message sent successfully")
179
180 async def audio_autocomplete(self, ctx: discord.AutocompleteContext):
181 """Autocomplete for audio files from file_search"""
182 query = ctx.value.lower() if ctx.value else ""
183 if not query:
184 return ""
185
186 audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"]
187 results = await search_utils.UniversalSearcher.search_copyparty(
188 query, max_results=25, file_extensions=audio_extensions
189 )
190
191 choices = []
192 for url in results[:25]:
193 filename = url.split("/")[-1]
194 display_name = filename[:98] + "…" if len(filename) > 100 else filename
195 choices.append(discord.OptionChoice(name=display_name, value=url))
196
197 return (
198 choices
199 if choices
200 else [discord.OptionChoice(name="Brak wyników", value="")]
201 )
202
203 def _get_baseurl(self):
204 """Get base URL from settings"""
205 try:
206 from cogs.utils.settings import get_baseurl
207
208 return get_baseurl()
209 except Exception:
210 return ""
211
212 trigger_group = discord.SlashCommandGroup("cron", "komendy cron")
213
214 @trigger_group.command(
215 name="dodaj_audio", description="Dodaj nowe zadanie cron audio"
216 )
217 async def cron_add_audio(
218 self,
219 ctx,
220 cron_expression: str = discord.Option(
221 str,
222 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
223 required=True,
224 ),
225 channel: discord.VoiceChannel = discord.Option(
226 discord.VoiceChannel,
227 "Kanał głosowy na którym będzie wykonywana akcja",
228 required=True,
229 ),
230 audio_filename: str = discord.Option(
231 str,
232 "Plik audio z serwera copyparty",
233 required=True,
234 autocomplete=audio_autocomplete,
235 ),
236 ):
237 try:
238 croniter(cron_expression)
239 except Exception:
240 await ctx.respond(
241 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
242 ephemeral=True,
243 )
244 return
245
246 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
247
248 task = {
249 "id": task_id,
250 "cron_expression": cron_expression,
251 "type": "audio",
252 "channel_id": channel.id,
253 "active": True,
254 }
255
256 task["audio_filename"] = audio_filename
257
258 self.scheduled_tasks.append(task)
259 self.save_tasks()
260
261 await ctx.respond(
262 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>"
263 )
264
265 @trigger_group.command(
266 name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość"
267 )
268 async def cron_add_message(
269 self,
270 ctx,
271 cron_expression: str = discord.Option(
272 str,
273 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
274 required=True,
275 ),
276 channel: discord.TextChannel = discord.Option(
277 discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True
278 ),
279 message: str = discord.Option(str, "Wiadomość do wysłania", required=True),
280 ):
281 """Add a new cron job"""
282
283 try:
284 croniter(cron_expression)
285 except Exception:
286 await ctx.respond(
287 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
288 ephemeral=True,
289 )
290 return
291
292 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
293
294 task = {
295 "id": task_id,
296 "cron_expression": cron_expression,
297 "type": "wiadomość",
298 "channel_id": channel.id,
299 "active": True,
300 "message": message,
301 }
302
303 self.scheduled_tasks.append(task)
304 self.save_tasks()
305
306 await ctx.respond(
307 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>"
308 )
309
310 @trigger_group.command(
311 name="pokaż", description="Pokaż wszystkie zadania cron"
312 )
313 async def cron_list(self, ctx):
314 """List all cron jobs"""
315 if not self.scheduled_tasks:
316 await ctx.respond("Brak zadań cron")
317 return
318
319 embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00)
320
321 for task in self.scheduled_tasks:
322 channel = self.bot.get_channel(task["channel_id"])
323 channel_name = f"#{channel}"
324 status = "AKTYWNA" if task.get("active", True) else "PAUZA"
325
326 if task["type"] in ("zagraj_audio", "audio"):
327 action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`"
328 else:
329 msg = task.get("message", "")
330 action_desc = (
331 f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`"
332 )
333
334 embed.add_field(
335 name=f"Zadanie #{task['id']} ({status})",
336 value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}",
337 inline=False,
338 )
339
340 await ctx.respond(embed=embed)
341
342 @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID")
343 async def cron_remove(
344 self,
345 ctx,
346 job_id: int = discord.Option(
347 int, "ID zadania cron do usunięcia", required=True
348 ),
349 ):
350 """Remove a cron job by ID"""
351 for i, task in enumerate(self.scheduled_tasks):
352 if task["id"] == job_id:
353 self.scheduled_tasks.pop(i)
354 self.save_tasks()
355 await ctx.respond(f"Usunięto zadanie cron #{job_id}")
356 return
357
358 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
359
360 @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID")
361 async def cron_pause(
362 self,
363 ctx,
364 job_id: int = discord.Option(
365 int, "ID zadania cron do zatrzymania", required=True
366 ),
367 ):
368 """Pause a cron job by ID"""
369 for task in self.scheduled_tasks:
370 if task["id"] == job_id:
371 task["active"] = False
372 self.save_tasks()
373 await ctx.respond(f"Zapauzowano zadanie cron #{job_id}")
374 return
375
376 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
377
378 @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID")
379 async def cron_resume(
380 self,
381 ctx,
382 job_id: int = discord.Option(
383 int, "ID zadania cron do wznowienia", required=True
384 ),
385 ):
386 """Resume a paused cron job by ID"""
387 for task in self.scheduled_tasks:
388 if task["id"] == job_id:
389 task["active"] = True
390 self.save_tasks()
391 await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}")
392 return
393
394 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
395
396 def cog_unload(self):
397 """Clean up when cog is unloaded"""
398 if hasattr(self, "task"):
399 self.task.cancel()
400
401
402def setup(bot):
403 bot.add_cog(Cron(bot))
diff --git a/cogs/downloader.py b/cogs/downloader.py
deleted file mode 100644
index 900bafb..0000000
--- a/cogs/downloader.py
+++ /dev/null
@@ -1,234 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import tempfile
8import logging
9import requests
10import discord
11import re
12import yt_dlp
13from discord.ext import commands
14import aiofiles
15from cogs.utils.url_helper import clean_url_for_ytdlp
16
17logger = logging.getLogger(__name__)
18
19
20class Downloader(commands.Cog):
21 def __init__(self, bot):
22 self.bot = bot
23
24 URL_PATTERN = re.compile(
25 r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*'
26 )
27
28 @commands.message_command(
29 name="download",
30 description="Download and convert video/audio from internet"
31 )
32 async def message_download(self, ctx, message: discord.Message):
33 # check if message has a valid url
34 url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)]
35 if url:
36 for url in url:
37 await self.download(ctx=ctx, url=url, fformat="mp4")
38 else:
39 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
40
41 @commands.slash_command(
42 name="download",
43 description="Download and convert video/audio from internet"
44 )
45 async def download(
46 self,
47 ctx: discord.ApplicationContext,
48 url: str = discord.Option(str, description="Link media", required=True),
49 catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False),
50 fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"),
51 filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False)
52 ):
53 """Download and convert media from a URL to the specified format"""
54 await ctx.defer()
55 # Call the function and get the response
56 response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user)
57 if isinstance(response, dict):
58 if response["status"] == "success":
59 if response["type"] == "url":
60 await ctx.respond(f"pobioron i wysyłon: {response['content']}")
61 elif response["type"] == "file":
62 file_data = response["content"]
63 await ctx.respond(file=discord.File(file_data))
64 else:
65 error_msg = response["content"]
66 if 'details' in response:
67 error_msg += f"\n{response['details']}"
68 await ctx.respond(f"Błąd: {error_msg}", ephemeral=True)
69 logger.error(f"Error: {response['content']}")
70 if 'details' in response:
71 logger.error(f"Details: {response['details']}")
72
73 @staticmethod
74 async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None):
75 try:
76 url = clean_url_for_ytdlp(url)
77 logger.info(f"Downloading {url} as {fformat}")
78
79 with tempfile.TemporaryDirectory(delete=False) as temp_dir:
80 ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir)
81
82 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
83 ydl.download([url])
84
85 actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user)
86
87 if Downloader._should_upload_to_server(actual_file, catbox):
88 return await Downloader._upload_to_server(actual_file, filename, temp_dir)
89 else:
90 return Downloader._return_file_response(actual_file, filename, temp_dir)
91
92 except Exception as e:
93 return Downloader._create_error_response("Download failed", str(e), None)
94
95 @staticmethod
96 def _sanitize_filename(name: str) -> str:
97 invalid = '<>:"/\\|?*'
98 for c in invalid:
99 name = name.replace(c, '')
100 return name.strip()
101
102 @staticmethod
103 def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict:
104 if filename:
105 filename = Downloader._sanitize_filename(filename)[:100]
106 ydl_opts = {
107 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'),
108 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
109 'merge_output_format': 'mp4',
110 'postprocessors': [],
111 'remote-components': 'ejs:github',
112 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}},
113 }
114
115 if fformat in ['mp3', 'wav', 'm4a']:
116 ydl_opts['postprocessors'].append({
117 'key': 'FFmpegExtractAudio',
118 'preferredcodec': fformat,
119 'preferredquality': '192',
120 })
121 elif fformat != 'mp4':
122 ydl_opts['postprocessors'].append({
123 'key': 'FFmpegVideoConvertor',
124 'preferedformat': fformat,
125 })
126
127 return ydl_opts
128
129 @staticmethod
130 def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple:
131 files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))]
132 if not files:
133 raise FileNotFoundError("No file was downloaded")
134
135 actual_file = os.path.join(temp_dir, files[0])
136 extension = actual_file.split('.')[-1]
137 extension = Downloader._sanitize_filename(extension)
138
139 if not filename_user:
140 filename = os.path.basename(actual_file)
141 filename = Downloader._sanitize_filename(filename)
142 if len(filename) > 100:
143 filename = filename[:100]
144 else:
145 filename_user = Downloader._sanitize_filename(filename_user)[:100]
146 filename = filename_user + '.' + extension
147 actual_file = os.path.join(temp_dir, filename)
148
149 if not os.path.exists(actual_file):
150 raise FileNotFoundError(f"Downloaded file not found: {actual_file}")
151
152 return actual_file, filename
153
154 @staticmethod
155 def _should_upload_to_server(actual_file: str, catbox: bool) -> bool:
156 return os.path.getsize(actual_file) > 1024*1024*10 or catbox
157
158 @staticmethod
159 async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict:
160 try:
161 async with aiofiles.open(actual_file, 'rb') as f:
162 content = await f.read()
163 files = {'f': (filename, content, 'application/octet-stream')}
164 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
165 params = {'j': '1', 'ck': 'b2'}
166 base_url = os.getenv("COPYPARTY_URL_UPLOAD")
167
168 response = requests.post(base_url, files=files, headers=headers, params=params)
169
170 if response.status_code in [200, 201]:
171 return Downloader._handle_upload_success(response, filename, temp_dir)
172 elif response.status_code == 413:
173 return Downloader._create_error_response(
174 "File too large for upload",
175 "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)",
176 temp_dir
177 )
178 else:
179 return Downloader._create_error_response(
180 f"Server returned {response.status_code}",
181 response.text or 'Unknown error',
182 temp_dir
183 )
184 except Exception as upload_error:
185 return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir)
186
187 @staticmethod
188 def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict:
189 try:
190 result = response.json()
191 if 'fileurl' in result:
192 return {
193 "status": "success",
194 "type": "url",
195 "content": result['fileurl'],
196 "filename": filename,
197 "temp_dir": temp_dir
198 }
199 else:
200 return Downloader._create_error_response(
201 "Invalid response from file server",
202 str(result),
203 temp_dir
204 )
205 except ValueError:
206 return Downloader._create_error_response(
207 "Failed to parse server response",
208 response.text[:200],
209 temp_dir
210 )
211
212 @staticmethod
213 def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict:
214 return {
215 "status": "success",
216 "type": "file",
217 "content": actual_file,
218 "filename": filename,
219 "temp_dir": temp_dir
220 }
221
222 @staticmethod
223 def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict:
224 return {
225 "status": "error",
226 "type": "error",
227 "content": content,
228 "details": details,
229 "temp_dir": temp_dir
230 }
231
232
233def setup(bot):
234 bot.add_cog(Downloader(bot))
diff --git a/cogs/math_commands.py b/cogs/math_commands.py
deleted file mode 100644
index e30c92b..0000000
--- a/cogs/math_commands.py
+++ /dev/null
@@ -1,83 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import logging
7import asyncio
8import re
9
10from discord import ApplicationContext
11from discord.ext import commands
12
13logger = logging.getLogger(__name__)
14
15MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$')
16
17
18def sanitize_math_input(equation: str) -> str | None:
19 if not equation or len(equation) > 1000:
20 return None
21 equation = equation.strip()
22 if not equation:
23 return None
24 if not MATH_SANITIZE_PATTERN.match(equation):
25 return None
26 for char in equation:
27 if ord(char) < 32 or ord(char) > 126:
28 return None
29 if equation.count('(') != equation.count(')'):
30 return None
31 if equation.count('[') != equation.count(']'):
32 return None
33 if equation.count('{') != equation.count('}'):
34 return None
35 dangerous_patterns = [
36 r'eval', r'exec', r'__import__', r'os\.', r'sys\.',
37 r'subprocess', r'open(', r'file', r'shutil',
38 r'lambda:', r'compile', r'dir(', r'vars(',
39 r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r',
40 r'\x', r'\0', r'\a', r'\b', r'\f', r'\v',
41 ]
42 for pattern in dangerous_patterns:
43 if re.search(pattern, equation, re.IGNORECASE):
44 return None
45 return equation
46
47
48class Math(commands.Cog):
49 def __init__(self, bot):
50 self.bot = bot
51
52 @commands.slash_command(
53 name="math",
54 description="wrug szyskih matfisuf"
55 )
56 async def math(self, ctx: ApplicationContext, equation: str):
57 sanitized = sanitize_math_input(equation)
58 if sanitized is None:
59 await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True)
60 logger.warning(f"Math command rejected invalid input: {equation}")
61 return
62 try:
63 math_proc = await asyncio.create_subprocess_exec(
64 "qalc", "-e", sanitized,
65 stdout=asyncio.subprocess.PIPE,
66 stderr=asyncio.subprocess.PIPE
67 )
68 stdout, stderr = await math_proc.communicate()
69 if math_proc.returncode != 0:
70 error_output = stderr.decode('utf-8', errors='replace').strip()
71 await ctx.respond(f"Calculation error: {error_output}", ephemeral=True)
72 logger.error(f"qalc error: {error_output}")
73 return
74 result = stdout.decode('utf-8', errors='replace').strip()
75 await ctx.respond(f"{sanitized} = {result}")
76 logger.info(f"Math command executed: {sanitized}")
77 except Exception as e:
78 await ctx.respond(f"Error: {str(e)}", ephemeral=True)
79 logger.error(f"Error in math command: {e}", exc_info=True)
80
81
82def setup(bot):
83 bot.add_cog(Math(bot))
diff --git a/cogs/music/join.py b/cogs/music/join.py
deleted file mode 100644
index d68ca79..0000000
--- a/cogs/music/join.py
+++ /dev/null
@@ -1,29 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import logging
7
8from discord.ext import commands
9from cogs.voice import get_voice_manager
10
11logger = logging.getLogger(__name__)
12
13
14class Join(commands.Cog):
15 def __init__(self, bot):
16 self.bot = bot
17
18 @commands.slash_command(name="join", description="dołącza do kanalu")
19 async def join(self, ctx):
20 voice_mgr = get_voice_manager(self.bot)
21 channel = ctx.author.voice.channel
22 await voice_mgr.connect(channel)
23 await ctx.respond(content="\u200b", ephemeral=True)
24 await ctx.interaction.delete_original_response()
25 logger.info("Bot connected to voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Join(bot))
diff --git a/cogs/music/loop.py b/cogs/music/loop.py
deleted file mode 100644
index 6e2c838..0000000
--- a/cogs/music/loop.py
+++ /dev/null
@@ -1,40 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import logging
7
8from discord.ext import commands
9
10logger = logging.getLogger(__name__)
11
12
13class Loop(commands.Cog):
14 def __init__(self, bot):
15 self.bot = bot
16 self.looping = {}
17
18 def is_looping(self, guild_id):
19 return self.looping.get(guild_id, False)
20
21 def set_looping(self, guild_id, value):
22 self.looping[guild_id] = value
23
24 @commands.slash_command(
25 name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji"
26 )
27 async def loop(self, ctx):
28 guild_id = ctx.guild.id
29 looping = not self.is_looping(guild_id)
30 self.set_looping(guild_id, looping)
31 await ctx.respond(
32 f"looping jest teraz {'włączony' if looping else 'wyłączony'}."
33 )
34 logger.info(
35 f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}"
36 )
37
38
39def setup(bot):
40 bot.add_cog(Loop(bot))
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py
deleted file mode 100644
index 91bd406..0000000
--- a/cogs/music/media_processing.py
+++ /dev/null
@@ -1,135 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import asyncio
7import logging
8import os
9
10import yt_dlp
11from youtubesearchpython import VideosSearch
12from cogs.utils.url_helper import clean_url_for_ytdlp
13
14logger = logging.getLogger(__name__)
15
16
17class MediaDownloader:
18 """Handles downloading media from various sources."""
19
20 @staticmethod
21 async def download(ctx, url):
22 """Download audio from YouTube URL."""
23 from cogs.utils.temp import get_temp_dir
24
25 temp_dir = get_temp_dir("senator_sperma_music")
26
27 ytdl_opts = {
28 "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"),
29 "postprocessors": [
30 {
31 "key": "FFmpegExtractAudio",
32 "preferredcodec": "mp3",
33 "preferredquality": "192",
34 }
35 ],
36 "force_ipv4": True,
37 }
38
39 try:
40 url = clean_url_for_ytdlp(url)
41 with yt_dlp.YoutubeDL(ytdl_opts) as ydl:
42 info_dict = ydl.extract_info(url, download=True)
43 # Get the base filename without extension
44 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
45 # Add .mp3 extension since we're extracting audio
46 filename = f"{base_filename}.mp3"
47 logger.info(f"Downloaded file: {filename}")
48 return filename
49 except Exception as e:
50 logger.error(f"Error downloading video: {e}")
51 await ctx.respond("eror pobirajync wideło", ephemeral=True)
52
53 @staticmethod
54 async def get_url(ctx, query):
55 """Search YouTube and get URL for a query."""
56 search = VideosSearch(query, limit=1)
57 if search.result()["result"]:
58 title = search.result()["result"][0]["title"]
59 url = search.result()["result"][0]["link"]
60 logger.info(f"Found video: {title}")
61 return url, title
62 else:
63 logger.warning("No results found for the query.")
64 await ctx.respond("nic ni ma", ephemeral=True)
65 return None
66
67
68class AudioCompressor:
69 """Handles audio compression using FFmpeg."""
70
71 @staticmethod
72 async def compress(ctx, filename):
73 """Compress audio file to opus format."""
74 from cogs.utils.temp import get_temp_dir
75
76 if not filename:
77 await ctx.respond("dzie wideło", ephemeral=True)
78 return None
79
80 temp_dir = get_temp_dir("senator_sperma_compressed")
81
82 # Create a temporary output filename in the temp directory
83 base_name = os.path.basename(filename)
84 output_filename = os.path.join(
85 temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus"
86 )
87
88 # Ensure the output directory exists
89 os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True)
90
91 # Use proper quoting for filenames with spaces
92 ffmpeg_command = [
93 "ffmpeg",
94 "-y", # Overwrite output file if it exists
95 "-i",
96 filename,
97 "-ac",
98 "1", # Mono audio
99 "-b:a",
100 "500", # 500k bitrate
101 "-acodec",
102 "libopus",
103 output_filename,
104 ]
105
106 try:
107 logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}")
108 process = await asyncio.create_subprocess_exec(
109 *ffmpeg_command,
110 stdout=asyncio.subprocess.PIPE,
111 stderr=asyncio.subprocess.PIPE,
112 )
113
114 _, stderr = await process.communicate()
115
116 if process.returncode != 0:
117 error_msg = stderr.decode()
118 logger.error(f"FFmpeg error ({process.returncode}): {error_msg}")
119 await ctx.respond("eror kompresujync pilk", ephemeral=True)
120 return None
121
122 if not os.path.exists(output_filename):
123 logger.error(f"Output file was not created: {output_filename}")
124 await ctx.respond(
125 "eror kompresujync pilk - output file not created", ephemeral=True
126 )
127 return None
128
129 logger.info(f"Successfully compressed file: {output_filename}")
130 return output_filename
131
132 except Exception as e:
133 logger.error(f"Error compressing file: {str(e)}", exc_info=True)
134 await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True)
135 return None
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
deleted file mode 100644
index c6a55b0..0000000
--- a/cogs/music/play_commands.py
+++ /dev/null
@@ -1,320 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import logging
7import os
8import urllib.parse
9import asyncio
10from os import PathLike
11from typing import Awaitable, Callable, List, Tuple
12import re
13import requests
14from yt_dlp import YoutubeDL
15
16import discord
17from discord.commands import OptionChoice
18from discord.ext import commands
19
20from cogs.music import media_processing
21from cogs.utils.file_search import UniversalSearcher
22from cogs.utils.settings import get_uploadurl
23from cogs.utils.url_helper import clean_url_for_ytdlp
24from cogs.voice import get_voice_manager
25
26logger = logging.getLogger(__name__)
27
28EXTERNAL_URL_PATTERN = re.compile(
29 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
30)
31
32
33class PlayCommands(commands.Cog):
34 """Discord slash commands for music playback."""
35
36 def __init__(self, bot):
37 from cogs.utils.temp import get_temp_dir
38
39 self.bot = bot
40 self.temp_dir = get_temp_dir()
41 self.path_mapping = {} # Maps display names to full paths
42
43 @staticmethod
44 async def _create_search_embed(
45 candidates: List[str],
46 ) -> Tuple[discord.Embed, discord.ui.View]:
47 """Create an embed and view for search results."""
48 embed = discord.Embed(
49 title="Wyniki wyszukiwania",
50 description="Wybierz plik do odtworzenia:",
51 color=discord.Color.blue(),
52 )
53
54 view = discord.ui.View(disable_on_timeout=True)
55
56 for i, candidate in enumerate(candidates, start=1):
57 filename = candidate.split("/")[-1].split("?")[0]
58 display_name = os.path.splitext(filename)[0]
59 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False)
60
61 return embed, view
62
63 async def _handle_search_results(
64 self,
65 ctx: discord.ApplicationContext,
66 candidates: List[str],
67 play_callback: Callable[[discord.Interaction, str], Awaitable[None]],
68 ) -> None:
69 """Handle the display of search results and button interactions."""
70 if not candidates:
71 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True)
72 return
73
74 embed, view = await self._create_search_embed(candidates)
75
76 # Add buttons for each candidate
77 for i, candidate in enumerate(candidates, start=1):
78 button = discord.ui.Button(label=f"Play {i}")
79
80 async def button_callback(
81 interaction: discord.Interaction, candidate_path=candidate
82 ):
83 await interaction.response.defer(ephemeral=True)
84 await ctx.interaction.delete_original_response()
85 try:
86 await play_callback(interaction, candidate_path)
87 except Exception as e:
88 logger.error(f"Error in play callback: {str(e)}", exc_info=True)
89 await interaction.followup.send(
90 f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True
91 )
92
93 button.callback = button_callback
94 view.add_item(button)
95
96 await ctx.respond(embed=embed, view=view, ephemeral=True)
97
98 async def _search_autocomplete(
99 self, ctx: discord.AutocompleteContext
100 ) -> List[OptionChoice]:
101 """Autocomplete handler for search queries."""
102 if ctx.value.lower().startswith(("http://", "https://")):
103 return []
104
105 query = ctx.value.lower()
106 try:
107 results = await UniversalSearcher.search_copyparty_autocomplete_audio(
108 query, max_results=25
109 )
110 choices = []
111 self.path_mapping = {} # Reset the mapping for each new query
112
113 for url in results:
114 filename = url.split("/")[-1]
115 filename = filename.split("?")[0] # Remove query params if any
116 filename = urllib.parse.unquote(filename) # Decode URL encoding
117 display = os.path.splitext(filename)[0] # Remove extension
118
119 display = display.strip()
120
121 if len(display) > 100:
122 display = display[:99] + "…"
123
124 self.path_mapping[display] = url
125 choices.append(OptionChoice(display, display))
126
127 if len(choices) >= 25:
128 break
129
130 return choices
131
132 except Exception as e:
133 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True)
134 return []
135
136 async def _handle_compressed_file_play(
137 self, ctx: discord.ApplicationContext, url: str
138 ) -> None:
139 """Download from copyparty, compress, then play locally."""
140 import aiohttp
141
142 local_path = None
143 compressed_path = None
144 try:
145 async with aiohttp.ClientSession() as session:
146 async with session.get(url) as response:
147 if response.status != 200:
148 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
149 return
150
151 filename = url.split("/")[-1].split("?")[0]
152 filename = urllib.parse.unquote(filename)
153 local_path = os.path.join(self.temp_dir, filename)
154 os.makedirs(self.temp_dir, exist_ok=True)
155
156 loop = asyncio.get_event_loop()
157 with open(local_path, "wb") as f:
158 async for chunk in response.content.iter_chunked(8192):
159 await loop.run_in_executor(None, f.write, chunk)
160
161 compressed_path = await media_processing.AudioCompressor.compress(
162 ctx, local_path
163 )
164 if not compressed_path:
165 return
166
167 await get_voice_manager(self.bot).play(
168 ctx, compressed_path, delete_after_play=True
169 )
170 except Exception as e:
171 logger.error(f"Error in compressed playback: {str(e)}", exc_info=True)
172 await ctx.respond(f"Błąd: {str(e)}", ephemeral=True)
173 finally:
174 if local_path and os.path.exists(local_path):
175 try:
176 os.remove(local_path)
177 except Exception:
178 pass
179
180 async def _handle_external_url_play(
181 self, ctx: discord.ApplicationContext, url: str
182 ) -> None:
183 """Download from external URL, upload to copyparty, then play."""
184 local_path = None
185 try:
186 ytdl_opts = {
187 "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"),
188 "postprocessors": [
189 {
190 "key": "FFmpegExtractAudio",
191 "preferredcodec": "mp3",
192 "preferredquality": "192",
193 }
194 ],
195 "force_ipv4": True,
196 }
197
198 loop = asyncio.get_event_loop()
199 url = clean_url_for_ytdlp(url)
200 with YoutubeDL(ytdl_opts) as ydl:
201 info_dict = await loop.run_in_executor(
202 None, lambda: ydl.extract_info(url, download=True)
203 )
204 title = info_dict.get("title", "nieznany")
205 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
206 local_path = f"{base_filename}.mp3"
207
208 if not os.path.exists(local_path):
209 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
210 return
211
212 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path))
213
214 await get_voice_manager(self.bot).play(
215 ctx, local_path, delete_after_play=True
216 )
217
218 try:
219 await asyncio.wait_for(upload_task, timeout=30.0)
220 except asyncio.TimeoutError:
221 logger.warning("Upload timed out")
222 except Exception as e:
223 logger.error(f"Error downloading external URL: {str(e)}", exc_info=True)
224 if local_path and os.path.exists(local_path):
225 try:
226 os.remove(local_path)
227 except Exception:
228 pass
229 await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True)
230
231 async def _upload_to_copyparty_async(self, file_path: str) -> None:
232 """Upload file to copyparty asynchronously (fire and forget)."""
233 try:
234 logger.info(f"Starting async upload: {file_path}")
235 baseurl = get_uploadurl()
236 password = os.getenv("COPYPARTY_PASSWORD", "")
237
238 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}")
239
240 if baseurl == "http://localhost:3939":
241 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
242
243 with open(file_path, "rb") as f:
244 content = f.read()
245
246 files = {"f": (os.path.basename(file_path), content, "application/octet-stream")}
247 headers = {"Cookie": f"cppwd={password}"}
248 params = {"j": "1", "ck": "b2"}
249
250 logger.info(f"Posting to {baseurl}")
251 response = requests.post(baseurl, files=files, headers=headers, params=params)
252 text = response.text
253 logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}")
254
255 if response.status_code in [200, 201]:
256 result = response.json()
257 logger.info(f"Uploaded to copyparty: {result.get('fileurl')}")
258 except Exception as e:
259 logger.error(f"Async upload failed: {e}", exc_info=True)
260
261 async def _handle_local_file_play(
262 self, ctx: discord.ApplicationContext, file: str, compressed: bool
263 ) -> None:
264 """Handle file playback from copyparty URL."""
265 url = self.path_mapping.get(file, file) # Look up URL from display name
266
267 if compressed:
268 await self._handle_compressed_file_play(ctx, url)
269 else:
270 await get_voice_manager(self.bot).play(ctx, url)
271
272 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
273 async def play(
274 self,
275 ctx: discord.ApplicationContext,
276 melodia: str = discord.Option(
277 str,
278 "odtwórz plik z serwera bądź strumienia",
279 autocomplete=_search_autocomplete,
280 required=False,
281 ),
282 file: discord.Attachment = discord.Option(
283 discord.Attachment, "plik do zagrania", required=False
284 ),
285 compressed: bool = discord.Option(
286 bool, "Czy skompresować dźwięk?", default=False
287 ),
288 ):
289 """Odtwarza wybrany plik dźwiękowy lub stream internetowy."""
290 await ctx.defer()
291
292 if melodia and file:
293 await ctx.respond(
294 "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True
295 )
296 return
297
298 if not melodia and not file:
299 await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True)
300 return
301
302 if file:
303 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename)
304 await file.save(file_path)
305 await get_voice_manager(self.bot).play(ctx, file_path)
306 else:
307 try:
308 if EXTERNAL_URL_PATTERN.match(melodia.lower()):
309 await self._handle_external_url_play(ctx, melodia)
310 else:
311 await self._handle_local_file_play(ctx, melodia, compressed)
312 except Exception as e:
313 logger.error(f"Error playing: {str(e)}", exc_info=True)
314 await ctx.respond(
315 f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True
316 )
317
318
319def setup(bot):
320 bot.add_cog(PlayCommands(bot))
diff --git a/cogs/music/stop.py b/cogs/music/stop.py
deleted file mode 100644
index 9c8d5cf..0000000
--- a/cogs/music/stop.py
+++ /dev/null
@@ -1,29 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6from discord.ext import commands
7
8from cogs.voice import get_voice_manager
9
10import logging
11
12logger = logging.getLogger(__name__)
13
14
15class Stop(commands.Cog):
16 def __init__(self, bot):
17 self.bot = bot
18
19 @commands.slash_command(name="stop", description="stopuj bota")
20 async def stop(self, ctx):
21 await ctx.respond(content="\u200b", ephemeral=True)
22 await ctx.interaction.delete_original_response()
23 voice_mgr = get_voice_manager(self.bot)
24 await voice_mgr.disconnect(ctx.guild)
25 logger.info("Bot disconnected from voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Stop(bot))
diff --git a/cogs/qr.py b/cogs/qr.py
deleted file mode 100644
index 32c574e..0000000
--- a/cogs/qr.py
+++ /dev/null
@@ -1,154 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import asyncio
7import logging
8import os
9import tempfile
10
11import aiohttp
12import discord
13import qrcode
14from discord.ext import commands
15
16logger = logging.getLogger(__name__)
17
18_pil_image = None
19_decode_func = None
20_decode_available = False
21
22
23def _ensure_qr():
24 global _pil_image, _decode_func, _decode_available
25
26 if _pil_image is not None:
27 return _decode_available
28
29 try:
30 from PIL import Image
31 from pyzbar.pyzbar import decode as _decode_func
32 _pil_image = Image
33 _decode_available = True
34 except ImportError:
35 _decode_available = False
36 logger.warning("pyzbar or PIL not available, decode command will be disabled")
37 return _decode_available
38
39
40class QR(commands.Cog):
41 def __init__(self, bot):
42 self.bot = bot
43
44 async def _download_image(self, url):
45 """Download image from URL to temporary file"""
46 async with aiohttp.ClientSession() as session:
47 async with session.get(url) as response:
48 if response.status == 200:
49 content = await response.read()
50 with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
51 await asyncio.get_event_loop().run_in_executor(None, f.write, content)
52 return f.name
53 return None
54
55 @commands.slash_command(
56 name="qr_generuj", description="Generuje kod QR z podanego tekstu"
57 )
58 async def qr(
59 self,
60 ctx,
61 text: str = discord.Option(str, "Tekst do zakodowania", required=True),
62 ):
63 await ctx.defer()
64
65 from cogs.utils.temp import get_temp_dir
66 temp_path = os.path.join(
67 get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png"
68 )
69
70 try:
71 loop = asyncio.get_event_loop()
72 await loop.run_in_executor(None, self._generate_qr, text, temp_path)
73 await ctx.respond(file=discord.File(temp_path, filename="qr.png"))
74 logger.info(f"Generated QR for text: {text}")
75 except Exception as e:
76 logger.error(f"QR generation error: {e}")
77 await ctx.respond("Error generating QR code", ephemeral=True)
78 finally:
79 try:
80 if os.path.exists(temp_path):
81 os.unlink(temp_path)
82 except Exception as e:
83 logger.error(f"Error cleaning up temp file: {e}")
84
85 def _generate_qr(self, text, output_path):
86 """Generate QR code (runs in executor)"""
87 qr = qrcode.QRCode(
88 version=1,
89 error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined]
90 box_size=10,
91 border=4,
92 )
93 qr.add_data(text)
94 qr.make(fit=True)
95 img = qr.make_image(fill_color="black", back_color="white")
96 img.save(output_path)
97
98 @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka")
99 async def decode(
100 self,
101 ctx,
102 image: discord.Attachment = discord.Option(
103 discord.Attachment, "Obrazek z kodem QR", required=True
104 ),
105 ):
106 if not _ensure_qr():
107 await ctx.respond(
108 "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True
109 )
110 return
111
112 await ctx.defer()
113
114 try:
115 temp_path = await self._download_image(image.url)
116 if not temp_path:
117 await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True)
118 return
119
120 try:
121 loop = asyncio.get_event_loop()
122 decoded = await loop.run_in_executor(None, self._decode_qr, temp_path)
123
124 if decoded:
125 await ctx.respond(f"```\n{decoded}\n```")
126 logger.info(f"Decoded QR: {decoded}")
127 else:
128 await ctx.respond(
129 "Nie znaleziono kodu QR na obrazku", ephemeral=True
130 )
131 finally:
132 if os.path.exists(temp_path):
133 os.unlink(temp_path)
134 except Exception as e:
135 logger.error(f"QR decode error: {e}")
136 await ctx.respond("Error dekodowania QR", ephemeral=True)
137
138 def _decode_qr(self, image_path):
139 """Decode QR code from image (runs in executor)"""
140 if not _ensure_qr() or _decode_func is None:
141 return None
142
143 try:
144 img = _pil_image.open(image_path)
145 decoded_list = _decode_func(img)
146 if decoded_list:
147 return decoded_list[0].data.decode("utf-8")
148 except Exception as e:
149 logger.error(f"Error decoding QR: {e}")
150 return None
151
152
153def setup(bot):
154 bot.add_cog(QR(bot))
diff --git a/cogs/say.py b/cogs/say.py
deleted file mode 100644
index 29d95fe..0000000
--- a/cogs/say.py
+++ /dev/null
@@ -1,28 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6from discord.ext import commands
7import discord
8import logging
9
10logger = logging.getLogger(__name__)
11
12class Say(commands.Cog):
13 def __init__(self, bot):
14 self.bot = bot
15
16 @discord.slash_command(name="say", description="senator, gadaj")
17 async def say(
18 self,
19 ctx: discord.ApplicationContext,
20 text: str = discord.Option(str, "Tekst do przeczytania", required=True)
21 ):
22 await ctx.defer()
23 await ctx.respond(content="\u200b", ephemeral=True)
24 await ctx.interaction.delete_original_response()
25 await ctx.send(content=text)
26
27def setup(bot):
28 bot.add_cog(Say(bot))
diff --git a/cogs/search.py b/cogs/search.py
deleted file mode 100644
index a124cbf..0000000
--- a/cogs/search.py
+++ /dev/null
@@ -1,73 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import urllib.parse
7
8import discord
9from discord.ext import commands
10import logging
11from discord import AutocompleteContext
12from cogs.utils import file_search as search_utils
13
14logger = logging.getLogger(__name__)
15
16class Search(commands.Cog):
17 def __init__(self, bot):
18 self.bot = bot
19 self._cached_results = {}
20
21 async def file_autocomplete(self, ctx: AutocompleteContext):
22 """Autocomplete function for file search."""
23 query = ctx.value.lower() if ctx.value else ""
24 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
25
26 user_id = ctx.interaction.user.id
27 self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
28
29 choices = []
30 for url in results[:25]:
31 filename = urllib.parse.unquote(url.split('/')[-1])
32 display_name = filename[:99] + "…" if len(filename) > 100 else filename
33 choices.append(discord.OptionChoice(name=display_name, value=filename))
34 return choices
35
36 @commands.slash_command(
37 name="search",
38 description="wyszukaj pliku"
39 )
40 @discord.option(
41 name="filename",
42 description="Search for a file to send",
43 autocomplete=file_autocomplete
44 )
45 async def search(self, ctx, filename: str):
46 """Send a file via URL from autocomplete selection."""
47 if not filename:
48 await ctx.reply("Please select a file from the autocomplete options.")
49 return
50
51 await ctx.defer()
52 logger.info(f"Search command called by {ctx.author} for file: {filename}")
53
54 cached = self._cached_results.get(ctx.author.id)
55 if not cached:
56 await ctx.send_followup("Search results expired. Please search again.")
57 return
58
59 matching_url = cached.get(filename)
60 if not matching_url:
61 await ctx.send_followup(f"File '{filename}' not found.")
62 return
63
64 try:
65 await ctx.send_followup(matching_url)
66 logger.info(f"Successfully sent URL: {matching_url}")
67 del self._cached_results[ctx.author.id]
68 except Exception as e:
69 logger.error(f"Error sending URL {matching_url}: {str(e)}")
70 await ctx.send_followup(str(e))
71
72def setup(bot):
73 bot.add_cog(Search(bot))
diff --git a/cogs/trigger.py b/cogs/trigger.py
deleted file mode 100644
index ce80d43..0000000
--- a/cogs/trigger.py
+++ /dev/null
@@ -1,260 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import discord
8from discord.ext import commands
9import random
10import logging
11
12from cogs.utils.database import get_connection, init_database
13
14logger = logging.getLogger(__name__)
15
16init_database()
17
18_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?"
19_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?"
20_SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?"
21_TRIGGER_NOT_FOUND_MSG = "Trigger not found."
22_TRIGGER_NAME_OPTION = "the name of the trigger"
23
24
25class Trigger(commands.Cog):
26 def __init__(self, bot):
27 self.bot = bot
28
29 trigger_group = discord.SlashCommandGroup("trigger", "trigger commands")
30
31 @commands.Cog.listener()
32 async def on_message(self, message):
33 if message.author == self.bot.user:
34 return
35
36 await self.trigger(message)
37
38 @staticmethod
39 async def trigger(message):
40 content = message.content.lower()
41 with get_connection() as conn:
42 c = conn.cursor()
43 c.execute("SELECT id, name FROM triggers")
44 for trigger_id, name in c.fetchall():
45 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
46 keywords = [row[0] for row in c.fetchall()]
47 if any(keyword in content for keyword in keywords):
48 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
49 responses = [row[0] for row in c.fetchall()]
50 if responses:
51 await message.channel.send(random.choice(responses).replace('\\n', '\n'))
52 logger.info(f"{message.author} triggered {name}")
53 break
54
55 @staticmethod
56 def get_type(name):
57 with get_connection() as conn:
58 c = conn.cursor()
59 c.execute("SELECT name FROM triggers")
60 return [row[0] for row in c.fetchall()]
61
62 @trigger_group.command(
63 description="Create a new trigger",
64 )
65 async def create(
66 self,
67 ctx: discord.ApplicationContext,
68 name = discord.Option(
69 str,
70 _TRIGGER_NAME_OPTION,
71 autocomplete= discord.utils.basic_autocomplete(get_type),
72 required=True
73 )
74 ):
75 with get_connection() as conn:
76 c = conn.cursor()
77 c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,))
78 conn.commit()
79
80 await ctx.respond("trigger added")
81
82 @trigger_group.command(
83 description="Add a trigger or response to the name",
84 )
85 async def add(
86 self,
87 ctx: discord.ApplicationContext,
88 name = discord.Option(
89 str,
90 _TRIGGER_NAME_OPTION,
91 autocomplete= discord.utils.basic_autocomplete(get_type),
92 required=True
93 ),
94 trigger = discord.Option(
95 str,
96 "the trigger to add to the name",
97 required=False
98 ),
99 response = discord.Option(
100 str,
101 "the response to add to the name",
102 required=False
103 )
104 ):
105 with get_connection() as conn:
106 c = conn.cursor()
107 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
108 row = c.fetchone()
109 if not row:
110 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
111 logger.warning(f"Trigger not found: {name}")
112 return
113 trigger_id = row[0]
114 if trigger:
115 for keyword in trigger.split('\\'):
116 keyword = keyword.strip()
117 if keyword:
118 c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword))
119 if response:
120 for resp in response.split('\\'):
121 resp = resp.strip()
122 if resp:
123 c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp))
124 conn.commit()
125
126 await ctx.respond("trigger updated")
127 logger.info(f"Trigger updated: {name}")
128
129 @trigger_group.command(
130 description="Remove a trigger or response from the name",
131 )
132 async def remove(
133 self,
134 ctx: discord.ApplicationContext,
135 name = discord.Option(
136 str,
137 _TRIGGER_NAME_OPTION,
138 autocomplete= discord.utils.basic_autocomplete(get_type),
139 required=True
140 ),
141 trigger = discord.Option(
142 str,
143 "the trigger to remove from the name",
144 required=False
145 ),
146 response = discord.Option(
147 str,
148 "the response to remove from the name",
149 required=False
150 )
151 ):
152 with get_connection() as conn:
153 c = conn.cursor()
154 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
155 row = c.fetchone()
156 if not row:
157 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
158 logger.warning(f"Trigger not found: {name}")
159 return
160 trigger_id = row[0]
161 if trigger:
162 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger))
163 if response:
164 c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response))
165 conn.commit()
166
167 await ctx.respond("trigger removed")
168 logger.info(f"Trigger removed: {name}")
169
170 @trigger_group.command(
171 description="Delete the whole trigger",
172 )
173 async def delete(
174 self,
175 ctx: discord.ApplicationContext,
176 name = discord.Option(
177 str,
178 _TRIGGER_NAME_OPTION,
179 autocomplete= discord.utils.basic_autocomplete(get_type),
180 required=True
181 )
182 ):
183 with get_connection() as conn:
184 c = conn.cursor()
185 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
186 row = c.fetchone()
187 if not row:
188 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
189 return
190 trigger_id = row[0]
191 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,))
192 c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,))
193 c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,))
194 conn.commit()
195
196 await ctx.respond("trigger deleted")
197 logger.info(f"Trigger deleted: {name}")
198
199
200 @trigger_group.command(
201 description="List all triggers",
202 )
203 async def list(
204 self,
205 ctx: discord.ApplicationContext,
206 name = discord.Option(
207 str,
208 _TRIGGER_NAME_OPTION,
209 autocomplete=discord.utils.basic_autocomplete(get_type),
210 required=True
211 )
212 ):
213 with get_connection() as conn:
214 c = conn.cursor()
215 if name:
216 c.execute("SELECT id FROM triggers WHERE name=?", (name,))
217 row = c.fetchone()
218 if not row:
219 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
220 logger.warning(f"Trigger not found: {name}")
221 return
222 trigger_id = row[0]
223 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
224 triggers = [row[0] for row in c.fetchall()]
225 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
226 responses = [row[0] for row in c.fetchall()]
227 trigger_list = "\n".join(triggers)
228 response_list = "\n".join(responses)
229 message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}"
230 await ctx.respond(message)
231 logger.info(f"Trigger listed: {name}")
232 else:
233 c.execute("SELECT id, name FROM triggers")
234 messages = []
235 for trigger_id, name in c.fetchall():
236 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
237 triggers = ", ".join([row[0] for row in c.fetchall()])
238 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
239 responses = ", ".join([row[0] for row in c.fetchall()])
240 messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}")
241 await ctx.respond("\n\n".join(messages))
242 logger.info("Triggers listed")
243
244 @trigger_group.command(
245 description="how to use trigger group?"
246 )
247 async def help(
248 self,
249 ctx: discord.ApplicationContext,
250 ):
251 await ctx.respond("Trigger create - creates a group for triggers/responses\n"
252 "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n"
253 "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n"
254 "Trigger delete - deletes the whole group\n"
255 "Trigger list - lists triggers/responses of a group\n"
256 "Trigger help - display this message\n")
257
258
259def setup(bot):
260 bot.add_cog(Trigger(bot))
diff --git a/cogs/tts.py b/cogs/tts.py
deleted file mode 100644
index ca81d3a..0000000
--- a/cogs/tts.py
+++ /dev/null
@@ -1,111 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import json
8import discord
9import logging
10import tempfile
11from discord.ext import commands
12from gtts import gTTS
13import asyncio
14from cogs.utils.settings import get_volume
15
16logger = logging.getLogger(__name__)
17
18class TTS(commands.Cog):
19 def __init__(self, bot):
20 self.bot = bot
21
22 async def _handle_file_output(self, ctx, temp_path, text):
23 """Handle TTS file output"""
24 try:
25 await ctx.respond(file=discord.File(temp_path, filename='tts.mp3'))
26 logger.info(f"Sent TTS for text: {text}")
27 except Exception as e:
28 logger.error(f"Error sending file: {e}")
29 await ctx.respond("eror :c", ephemeral=True)
30
31 def _get_voice_manager(self):
32 from cogs.voice import get_voice_manager
33 return get_voice_manager(self.bot)
34
35 async def _get_or_connect_voice_client(self, channel):
36 """Get existing voice client or connect to new channel"""
37 voice_mgr = self._get_voice_manager()
38 return await voice_mgr.join_channel(channel)
39
40 async def _handle_voice_output(self, ctx, temp_path, text, volume):
41 """Handle TTS voice channel output"""
42 await ctx.respond(content="\u200b", ephemeral=True)
43 await ctx.interaction.delete_original_response()
44
45 if not (ctx.author.voice and ctx.author.voice.channel):
46 logger.info("User is not in a voice channel")
47 return
48
49 channel = ctx.author.voice.channel
50 try:
51 voice_mgr = self._get_voice_manager()
52
53 if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild):
54 vc = await voice_mgr.get_client(channel.guild)
55 if vc and vc.is_playing():
56 logger.info("Audio already playing, skipping TTS")
57 return
58
59 audio_source = discord.FFmpegPCMAudio(
60 temp_path,
61 options=f'-filter:a "volume={volume}"'
62 )
63 logger.info(f"Playing TTS: {text}")
64 await voice_mgr.play_source(channel, audio_source)
65
66 except Exception as e:
67 logger.error(f"Error in voice channel handling: {e}")
68 await ctx.respond("eror :c", ephemeral=True)
69
70 async def _cleanup_temp_file(self, temp_path):
71 """Clean up temporary file"""
72 try:
73 if temp_path and os.path.exists(temp_path):
74 os.unlink(temp_path)
75 except Exception as e:
76 logger.error(f"Error cleaning up temp file {temp_path}: {e}")
77
78 @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu")
79 async def tts(self, ctx,
80 text: str = discord.Option(str, "Tekst do przeczytania", required=True),
81 output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc")
82 ):
83 await ctx.defer()
84
85 # Create temporary file in temp folder
86 from cogs.utils.temp import get_temp_dir
87 temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
88
89 try:
90 # Generate and save TTS to temp file
91 tts = gTTS(text=text, lang='pl')
92 loop = asyncio.get_event_loop()
93 await loop.run_in_executor(None, tts.save, temp_path)
94
95 # Load settings
96 volume = get_volume()
97
98 # Handle output based on user choice
99 if output == "file":
100 await self._handle_file_output(ctx, temp_path, text)
101 elif output == "vc":
102 await self._handle_voice_output(ctx, temp_path, text, volume)
103
104 except Exception as e:
105 logger.error(f"TTS generation error: {e}")
106 await ctx.respond("Error generating TTS", ephemeral=True)
107 finally:
108 await self._cleanup_temp_file(temp_path)
109
110def setup(bot):
111 bot.add_cog(TTS(bot))
diff --git a/cogs/uploader.py b/cogs/uploader.py
deleted file mode 100644
index a5d0e05..0000000
--- a/cogs/uploader.py
+++ /dev/null
@@ -1,156 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import aiofiles
8import requests
9import urllib.parse
10from typing import Optional
11import discord
12from discord.ext import commands
13from discord import AutocompleteContext
14from cogs.utils import file_search as search_utils
15from cogs.utils.settings import get_uploadurl
16import logging
17
18logger = logging.getLogger(__name__)
19
20
21class Uploader(commands.Cog):
22 def __init__(self, bot):
23 from cogs.utils.temp import get_temp_dir
24 self.bot = bot
25 self.temp_dir = get_temp_dir()
26 self._cached_urls = {}
27 self._uploadurl = get_uploadurl()
28
29 async def _file_autocomplete(self, ctx: AutocompleteContext):
30 query = ctx.value.lower() if ctx.value else ""
31 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
32
33 user_id = ctx.interaction.user.id
34 self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
35
36 choices = []
37 for url in results[:25]:
38 filename = urllib.parse.unquote(url.split('/')[-1])
39 display_name = filename[:99] + "…" if len(filename) > 100 else filename
40 choices.append(discord.OptionChoice(name=display_name, value=filename))
41 return choices
42
43 async def _download_from_url(self, url: str) -> Optional[str]:
44 """Download file from copyparty URL and return local path"""
45 try:
46 response = requests.get(url)
47 if response.status_code != 200:
48 return None
49
50 filename = url.split('/')[-1].split('?')[0]
51 filename = urllib.parse.unquote(filename)
52 file_path = os.path.join(self.temp_dir, filename)
53
54 async with aiofiles.open(file_path, 'wb') as f:
55 await f.write(response.content)
56 return file_path
57 except Exception as e:
58 logger.error(f"Error downloading from {url}: {e}")
59 return None
60
61 async def _upload_to_copyparty(self, file_path: str) -> Optional[str]:
62 """Upload file to copyparty and return download URL"""
63 try:
64 async with aiofiles.open(file_path, 'rb') as f:
65 file_content = await f.read()
66
67 files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')}
68 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
69 params = {'j': '1', 'ck': 'b2'}
70
71 response = requests.post(self._uploadurl, files=files, headers=headers, params=params)
72
73 if response.status_code in [200, 201]:
74 result = response.json()
75 return result.get('fileurl')
76 except Exception as e:
77 logger.error(f"Upload error: {e}")
78 return None
79
80 async def _upload_file(self, file_path: str) -> Optional[str]:
81 """Upload a file and return the URL"""
82 if self._uploadurl == "http://localhost:3939":
83 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
84 return None
85
86 return await self._upload_to_copyparty(file_path)
87
88 @commands.message_command(
89 name="upload",
90 description="wyślij załączniki na copyparty",
91 )
92 async def message_upload(self, ctx, message: discord.Message):
93 if not message.attachments:
94 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
95 return
96
97 await ctx.defer()
98
99 uploaded_urls = []
100 failed_count = 0
101
102 for attachment in message.attachments:
103 file_path = os.path.join(self.temp_dir, attachment.filename)
104 await attachment.save(file_path)
105
106 url = await self._upload_file(file_path)
107 if url:
108 uploaded_urls.append(url)
109 else:
110 failed_count += 1
111
112 if os.path.exists(file_path):
113 os.remove(file_path)
114
115 if uploaded_urls:
116 response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls)
117 if failed_count > 0:
118 response += f"\n\nNie wysłano {failed_count} plików"
119 await ctx.respond(response)
120 else:
121 await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True)
122
123 @commands.slash_command(
124 name="upload",
125 description="wyślij plik na copyparty",
126 )
127 @discord.option(
128 name="zalacznik",
129 description="plik z discorda",
130 required=True,
131 type=discord.Attachment
132 )
133 async def upload(self, ctx, zalacznik: discord.Attachment = None):
134 await ctx.defer()
135
136 file_path = None
137 file_path = os.path.join(self.temp_dir, zalacznik.filename)
138 await zalacznik.save(file_path)
139
140 if not file_path or not os.path.exists(file_path):
141 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
142 return
143
144 try:
145 dl_url = await self._upload_file(file_path)
146 if dl_url:
147 await ctx.respond(dl_url)
148 else:
149 await ctx.respond("Nie udało się wysłać pliku", ephemeral=True)
150 finally:
151 if file_path and os.path.exists(file_path):
152 os.remove(file_path)
153
154
155def setup(bot):
156 bot.add_cog(Uploader(bot))
diff --git a/cogs/utils/database.py b/cogs/utils/database.py
deleted file mode 100644
index ea7d356..0000000
--- a/cogs/utils/database.py
+++ /dev/null
@@ -1,126 +0,0 @@
1import os
2import sqlite3
3import logging
4from contextlib import contextmanager
5
6logger = logging.getLogger(__name__)
7
8DB_PATH = "data/senator.db"
9
10_initialized = False
11
12
13def get_connection():
14 conn = sqlite3.connect(get_db_path())
15 return _ConnectionContextManager(conn)
16
17
18def get_db_path():
19 return os.getenv("SENATOR_DB_PATH", DB_PATH)
20
21
22def set_db_path(path):
23 global DB_PATH
24 DB_PATH = path
25
26
27class _ConnectionContextManager:
28 """Context manager for sqlite3 connections that auto-closes."""
29 def __init__(self, conn):
30 self.conn = conn
31
32 def __enter__(self):
33 return self.conn
34
35 def __exit__(self, exc_type, exc_val, exc_tb):
36 self.conn.close()
37 return False
38
39
40def init_database():
41 global _initialized
42 if _initialized:
43 return
44
45 _initialized = True
46
47 db_path = get_db_path()
48 db_dir = os.path.dirname(db_path)
49 if db_dir and not os.path.exists(db_dir):
50 os.makedirs(db_dir)
51
52 conn = sqlite3.connect(db_path)
53 try:
54 c = conn.cursor()
55
56 c.execute("PRAGMA foreign_keys = ON")
57
58 c.execute("""
59 CREATE TABLE IF NOT EXISTS settings (
60 key TEXT PRIMARY KEY,
61 value TEXT
62 )""")
63
64 c.execute("""
65 CREATE TABLE IF NOT EXISTS cron_jobs (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 cron_expression TEXT NOT NULL,
68 type TEXT NOT NULL,
69 channel_id INTEGER NOT NULL,
70 active INTEGER DEFAULT 1,
71 audio_filename TEXT,
72 message TEXT,
73 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
74 )""")
75
76 c.execute("""
77 CREATE TABLE IF NOT EXISTS voice_activity (
78 user_id TEXT PRIMARY KEY,
79 join_time REAL,
80 total_active REAL DEFAULT 0,
81 total_afk REAL DEFAULT 0,
82 is_afk INTEGER DEFAULT 0,
83 afk_start REAL,
84 active_start REAL,
85 channel_name TEXT,
86 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
87 )""")
88
89 c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)")
90 c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)")
91
92 c.execute("""
93 CREATE TABLE IF NOT EXISTS triggers (
94 id INTEGER PRIMARY KEY AUTOINCREMENT,
95 name TEXT NOT NULL UNIQUE,
96 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
97 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
98 )""")
99
100 c.execute("""
101 CREATE TABLE IF NOT EXISTS trigger_keywords (
102 id INTEGER PRIMARY KEY AUTOINCREMENT,
103 trigger_id INTEGER NOT NULL,
104 keyword TEXT NOT NULL,
105 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
106 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE,
107 UNIQUE(trigger_id, keyword)
108 )""")
109
110 c.execute("""
111 CREATE TABLE IF NOT EXISTS trigger_responses (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 trigger_id INTEGER NOT NULL,
114 response_text TEXT NOT NULL,
115 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
116 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
117 )""")
118
119 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)")
120 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)")
121 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)")
122
123 conn.commit()
124 logger.info("Database initialized successfully")
125 finally:
126 conn.close()
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py
deleted file mode 100644
index 788f50c..0000000
--- a/cogs/utils/file_search.py
+++ /dev/null
@@ -1,167 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import json
7import logging
8import aiohttp
9import os
10from typing import List
11from cogs.utils.settings import get_baseurl
12
13logger = logging.getLogger(__name__)
14
15
16class UniversalSearcher:
17 """Handles searching for files via copyparty API."""
18
19 @staticmethod
20 async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None):
21 """Search for files using copyparty API (jPOST method).
22
23 Args:
24 query: Search query string
25 max_results: Maximum number of results to return
26 file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav'])
27
28 Returns:
29 List of matching file URLs
30 """
31 if not query:
32 return []
33
34 query = query.strip()
35 logger.info(f"Starting copyparty search for query: '{query}'")
36
37 tokens = query.split()
38 if not tokens:
39 return []
40
41 token_queries = []
42 for token in tokens:
43 path_query = f'path like "*{token}*"'
44 name_query = f'name like "*{token}*"'
45 token_queries.append(f"({path_query} or {name_query})")
46
47 if len(token_queries) == 1:
48 name_query = token_queries[0]
49 else:
50 name_query = " and ".join(token_queries)
51
52 if file_extensions:
53 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions]
54 ext_query = " or ".join(ext_filters)
55 query = f"{name_query} and ({ext_query})"
56 else:
57 query = name_query
58
59 try:
60 baseurl = get_baseurl()
61 password = os.getenv("COPYPARTY_PASSWORD")
62
63 async with aiohttp.ClientSession() as session:
64 headers = {"Content-Type": "application/json"}
65 if password:
66 headers["PW"] = password
67 async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response:
68 if response.status == 200:
69 data = await response.json()
70 files = UniversalSearcher._extract_files_from_response(data)
71 results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results)
72
73 logger.info(f"Copyparty search complete. Found {len(results)} matches.")
74 return results
75 else:
76 logger.error(f"Copyparty API returned status {response.status}")
77 return []
78
79 except aiohttp.ClientError as e:
80 logger.error(f"HTTP error during copyparty search: {e}")
81 return []
82 except Exception as e:
83 logger.error(f"Error during copyparty search: {str(e)}", exc_info=True)
84 return []
85
86 @staticmethod
87 def _extract_files_from_response(data):
88 """Extract files list from copyparty API response."""
89 if isinstance(data, dict):
90 if 'hits' in data:
91 return data['hits']
92 elif 'files' in data:
93 return data['files']
94 elif 'results' in data:
95 return data['results']
96 else:
97 return data if isinstance(data, list) else []
98 elif isinstance(data, list):
99 return data
100 else:
101 return []
102
103 @staticmethod
104 def _convert_files_to_urls(files, baseurl, max_results):
105 """Convert file info objects to URLs."""
106 results = []
107
108 for file_info in files[:max_results]:
109 if isinstance(file_info, dict):
110 file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl)
111 if file_url:
112 results.append(file_url)
113 elif isinstance(file_info, str):
114 if file_info.startswith('http'):
115 results.append(file_info)
116 else:
117 results.append(f"{baseurl}/{file_info.lstrip('/')}")
118
119 return results
120
121 @staticmethod
122 def _extract_url_from_dict(file_info, baseurl):
123 """Extract URL from file info dictionary."""
124 if 'url' in file_info:
125 return file_info['url']
126 elif 'rp' in file_info:
127 return f"{baseurl}/{file_info['rp']}"
128 elif 'path' in file_info:
129 return f"{baseurl}/{file_info['path'].lstrip('/')}"
130 elif 'name' in file_info:
131 return f"{baseurl}/{file_info['name'].lstrip('/')}"
132 else:
133 return None
134
135 @staticmethod
136 async def search_copyparty_autocomplete(query: str, max_results: int = 10):
137 """Wrapper for autocomplete searches using copyparty (general search - no file type filtering).
138
139 Args:
140 query: Search query string
141 max_results: Maximum number of results to return
142
143 Returns:
144 List of matching file URLs
145 """
146 if not query:
147 return []
148
149 return await UniversalSearcher.search_copyparty(query, max_results)
150
151 @staticmethod
152 async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10):
153 """Wrapper for autocomplete searches using copyparty (audio files only).
154
155 Args:
156 query: Search query string
157 max_results: Maximum number of results to return
158
159 Returns:
160 List of matching file URLs (audio files only)
161 """
162 if not query:
163 return []
164
165 # Audio file extensions for music commands
166 audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"]
167 return await UniversalSearcher.search_copyparty(query, max_results, audio_extensions)
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py
deleted file mode 100644
index b1a585c..0000000
--- a/cogs/utils/settings.py
+++ /dev/null
@@ -1,114 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import logging
7import sqlite3
8import os
9from dataclasses import dataclass, field
10
11from cogs.utils.database import get_connection, init_database
12
13logger = logging.getLogger(__name__)
14
15DEFAULT_BASEURL = "http://localhost:3939"
16
17init_database()
18
19
20@dataclass
21class Settings:
22 volume: float = 1.0
23 baseurl: str = DEFAULT_BASEURL
24 _data: dict = field(default_factory=dict, init=False)
25
26 async def load(self) -> "Settings":
27 default_settings = {
28 "volume": 1.0,
29 "baseurl": DEFAULT_BASEURL,
30 }
31
32 try:
33 with get_connection() as conn:
34 c = conn.cursor()
35 c.execute("SELECT key, value FROM settings")
36 rows = c.fetchall()
37
38 if rows:
39 self._data = {row[0]: row[1] for row in rows}
40 self._sync_from_data()
41 logger.info("Settings loaded from database")
42 else:
43 self._data = default_settings.copy()
44 self._sync_from_data()
45 await self.save()
46 logger.info("Created default settings in database")
47 except Exception as e:
48 logger.error(f"Error loading settings: {e}")
49 self._data = default_settings.copy()
50 self._sync_from_data()
51
52 return self
53
54 def _sync_from_data(self):
55 try:
56 self.volume = float(self._data.get("volume", 1.0))
57 except (ValueError, TypeError):
58 self.volume = 1.0
59 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL)
60
61 def _sync_to_data(self):
62 self._data["volume"] = str(self.volume)
63 self._data["baseurl"] = self.baseurl
64
65 async def save(self):
66 self._sync_to_data()
67 with get_connection() as conn:
68 c = conn.cursor()
69 c.execute("PRAGMA foreign_keys = ON")
70 for key, value in self._data.items():
71 c.execute(
72 "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
73 (key, value),
74 )
75 conn.commit()
76
77
78_settings_instance: Settings | None = None
79
80
81async def load_settings() -> Settings:
82 global _settings_instance
83 _settings_instance = Settings()
84 await _settings_instance.load()
85 return _settings_instance
86
87
88async def save_settings():
89 if _settings_instance:
90 await _settings_instance.save()
91
92
93def get_settings() -> Settings | None:
94 return _settings_instance
95
96
97def get_volume() -> float:
98 return _settings_instance.volume if _settings_instance else 1.0
99
100
101def get_baseurl() -> str:
102 url = os.getenv("COPYPARTY_URL")
103 if not url:
104 logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939")
105 return DEFAULT_BASEURL
106 return url.rstrip("/")
107
108
109def get_uploadurl() -> str:
110 url = os.getenv("COPYPARTY_URL_UPLOAD")
111 if not url:
112 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939")
113 return DEFAULT_BASEURL
114 return url.rstrip("/")
diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py
deleted file mode 100644
index 0b2c8d7..0000000
--- a/cogs/utils/temp.py
+++ /dev/null
@@ -1,18 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import tempfile
8
9def get_temp_dir(subdir: str = "senator_sperma") -> str:
10 """Get or create a temp subdirectory.
11
12 Uses system temp directory (/tmp on Linux, %TEMP% on Windows).
13 OS handles cleanup automatically.
14 """
15 base_temp = tempfile.gettempdir()
16 temp_path = os.path.join(base_temp, subdir)
17 os.makedirs(temp_path, exist_ok=True)
18 return temp_path
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py
deleted file mode 100644
index eac06f1..0000000
--- a/cogs/utils/url_helper.py
+++ /dev/null
@@ -1,21 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6
7def clean_url_for_ytdlp(url: str) -> str:
8 """Clean URL by removing everything after the first '&'.
9
10 This prevents yt-dlp from interpreting additional query parameters
11 (like &list=...) as playlist URLs.
12
13 Example:
14 Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM&
15 Output: https://www.youtube.com/watch?v=lPGipwoJiOM
16 """
17 # Find the first '&' and truncate
18 first_ampersand = url.find('&')
19 if first_ampersand != -1:
20 return url[:first_ampersand]
21 return url
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
deleted file mode 100644
index 35826d4..0000000
--- a/cogs/vclogs.py
+++ /dev/null
@@ -1,316 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import datetime
7import logging
8import os
9import asyncio
10import discord
11from discord.ext import commands
12from gtts import gTTS
13from cogs.voice import get_voice_manager
14from cogs.utils.settings import get_volume, save_settings
15from cogs.utils.database import get_connection, init_database
16
17import tempfile
18
19logger = logging.getLogger(__name__)
20
21init_database()
22
23
24def load_activity_data():
25 try:
26 with get_connection() as conn:
27 c = conn.cursor()
28 c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity")
29 rows = c.fetchall()
30
31 return {
32 row[0]: {
33 "join_time": row[1],
34 "total_active": row[2],
35 "total_afk": row[3],
36 "is_afk": bool(row[4]),
37 "afk_start": row[5],
38 "active_start": row[6],
39 "channel_name": row[7],
40 }
41 for row in rows
42 }
43 except Exception as e:
44 logger.error(f"Error loading voice activity: {e}")
45 return {}
46
47
48def save_activity_data(data):
49 try:
50 with get_connection() as conn:
51 c = conn.cursor()
52 c.execute("PRAGMA foreign_keys = ON")
53
54 c.execute("DELETE FROM voice_activity")
55
56 for user_id, user_data in data.items():
57 c.execute(
58 """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
59 VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
60 (
61 user_id,
62 user_data.get("join_time"),
63 user_data.get("total_active", 0),
64 user_data.get("total_afk", 0),
65 int(user_data.get("is_afk", False)),
66 user_data.get("afk_start"),
67 user_data.get("active_start"),
68 user_data.get("channel_name"),
69 ),
70 )
71 conn.commit()
72 logger.info(f"Saved voice activity for {len(data)} users")
73 except Exception as e:
74 logger.error(f"Error saving voice activity: {e}")
75
76
77def format_duration(seconds):
78 hours, remainder = divmod(int(seconds), 3600)
79 minutes, seconds = divmod(remainder, 60)
80 if hours > 0:
81 return f"{hours}h {minutes}m {seconds}s"
82 elif minutes > 0:
83 return f"{minutes}m {seconds}s"
84 return f"{seconds}s"
85
86
87class VcLogs(commands.Cog):
88 def __init__(self, bot):
89 self.bot = bot
90 self._voice_manager = None
91
92 def get_voice_manager(self):
93 if self._voice_manager is None:
94 self._voice_manager = get_voice_manager(self.bot)
95 return self._voice_manager
96
97 @staticmethod
98 async def handle_state_change(member, channel, state_type, is_enabled, log_channel):
99 """Helper method to handle voice state changes"""
100 state_messages = {
101 'deaf': {
102 True: f"{member.display_name} jest bity przez ojca",
103 False: f"{member.display_name} uciek od ojca"
104 },
105 'mute': {
106 True: f"{member.display_name} idzie robić jajecznice",
107 False: f"{member.display_name} zrobił jajecznice"
108 }
109 }
110
111 tts_text = state_messages[state_type][is_enabled]
112 return tts_text, channel
113
114 async def play_tts(self, channel, text):
115 from cogs.utils.temp import get_temp_dir
116
117 voice_mgr = self.get_voice_manager()
118 temp_dir = get_temp_dir()
119 tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
120 tts = gTTS(text=text, lang='pl')
121 logger.info("saving file")
122
123 loop = asyncio.get_event_loop()
124 await loop.run_in_executor(None, tts.save, tts_file)
125
126 try:
127 voice_status = await voice_mgr.get_voice_status(channel.guild)
128 voice_client = voice_status[0] if voice_status else None
129 is_connected = voice_status[1] if voice_status else False
130 is_playing = voice_status[2] if voice_status else False
131
132 if is_connected:
133 from cogs.voice import is_voice_client_alive
134 if not is_voice_client_alive(voice_client, channel.guild):
135 logger.warning("Stale voice client in play_tts, reconnecting")
136 try:
137 await voice_client.disconnect()
138 except Exception:
139 pass
140 is_connected = False
141
142 if not is_connected:
143 voice_client = await voice_mgr.join_channel(channel)
144 voice_status = await voice_mgr.get_voice_status(channel.guild)
145 voice_client = voice_status[0] if voice_status else None
146 is_connected = voice_status[1] if voice_status else False
147 is_playing = voice_status[2] if voice_status else False
148
149 if not is_connected or voice_client is None:
150 logger.error("Failed to join voice channel")
151 return
152
153 volume = get_volume()
154 logger.info(f"connected: {voice_client}")
155
156 if voice_client:
157 if is_playing:
158 logger.info("voice already playing, skipping TTS")
159 return
160
161 logger.info("preparing to play audio")
162 audio_source = discord.FFmpegPCMAudio(tts_file)
163 await voice_mgr.play_source(channel, audio_source, volume=volume)
164 except Exception as e:
165 logger.error(f"TTS playback error: {e}")
166 finally:
167 if os.path.exists(tts_file):
168 os.remove(tts_file)
169
170 @commands.slash_command(
171 name="log_volume"
172 )
173 async def volume(self, ctx: discord.ApplicationContext, volume: int):
174 from cogs.utils.settings import get_settings
175 settings = get_settings()
176 settings.volume = volume
177 await save_settings()
178 await ctx.followup.send(f"Volume has been set to: {volume}")
179 logger.info(f"Volume changed to: {volume}")
180
181 @commands.Cog.listener()
182 async def on_voice_state_update(self, member, before, after):
183 log_channel_id = os.getenv("STATUS_CHANNEL_ID")
184 if not log_channel_id:
185 logger.warning("STATUS_CHANNEL_ID not set in environment")
186 return
187
188 log_channel = self.bot.get_channel(int(log_channel_id))
189 if log_channel is None:
190 logger.warning("Log channel not found or not accessible")
191 return
192
193 current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
194 current_ts = datetime.datetime.now().timestamp()
195 tts_text = None
196 tts_channel = None
197
198 if member.bot:
199 return
200
201 user_id = str(member.id)
202 activity_data = load_activity_data()
203
204 if before.channel is None and after.channel is not None:
205 is_afk = after.self_mute or after.self_deaf
206 activity_data[user_id] = {
207 'join_time': current_ts,
208 'total_active': 0,
209 'total_afk': 0,
210 'is_afk': is_afk,
211 'afk_start': current_ts if is_afk else None,
212 'active_start': current_ts if not is_afk else None,
213 'channel_name': after.channel.name
214 }
215 save_activity_data(activity_data)
216
217 message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>"
218 tts_text = f"{member.display_name} sie zespawnił"
219 tts_channel = after.channel
220 await log_channel.send(message)
221 logger.info(f"User joined voice channel: {message}")
222
223 elif before.channel is not None and after.channel is None:
224 if user_id in activity_data:
225 data = activity_data[user_id]
226
227 if data.get('is_afk') and data.get('afk_start'):
228 afk_duration = current_ts - data['afk_start']
229 data['total_afk'] += afk_duration
230 elif data.get('active_start'):
231 active_since_start = current_ts - data['active_start']
232 data['total_active'] += active_since_start
233
234 active_str = format_duration(data['total_active'])
235 afk_str = format_duration(data['total_afk'])
236
237 message = (
238 f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n"
239 f"Aktywny: {active_str} | AFK: {afk_str}"
240 )
241 tts_text = f"{member.display_name} jest chujem"
242 tts_channel = before.channel
243 await log_channel.send(message)
244 logger.info(f"User left voice channel: {message}")
245
246 del activity_data[user_id]
247 save_activity_data(activity_data)
248 else:
249 message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>"
250 tts_text = f"{member.display_name} jest chujem"
251 tts_channel = before.channel
252 await log_channel.send(message)
253 logger.info(f"User left voice channel: {message}")
254
255 elif user_id in activity_data:
256 data = activity_data[user_id]
257
258 if data.get('is_afk') and not (after.self_mute or after.self_deaf):
259 afk_duration = current_ts - data['afk_start']
260 data['total_afk'] += afk_duration
261 data['afk_start'] = None
262 data['is_afk'] = False
263 data['active_start'] = current_ts
264 save_activity_data(activity_data)
265
266 afk_str = format_duration(afk_duration)
267
268 if after.self_mute:
269 activity = "odciszył"
270 else:
271 activity = "odgłuchnął"
272
273 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
274 f"AFK: {afk_str}" )
275 await log_channel.send(message)
276 logger.info(f"User returned from AFK: {message}")
277 elif not data.get('is_afk') and (after.self_mute or after.self_deaf):
278 active_since_start = 0
279 if data.get('active_start'):
280 active_since_start = current_ts - data['active_start']
281 data['total_active'] += active_since_start
282 data['afk_start'] = current_ts
283 data['is_afk'] = True
284 data['active_start'] = None
285 save_activity_data(activity_data)
286
287 if after.self_mute:
288 activity = "wyciszył"
289 else:
290 activity = "ogłuchnął"
291
292 active_str = format_duration(active_since_start)
293 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
294 f"Aktywny: {active_str}" )
295 await log_channel.send(message)
296 logger.info(f"User went AFK: {message}")
297
298 if before.self_deaf != after.self_deaf:
299 tts_text, tts_channel = await self.handle_state_change(
300 member, after.channel, 'deaf', after.self_deaf, log_channel
301 )
302 if tts_text and tts_channel:
303 await self.play_tts(tts_channel, tts_text)
304 return
305
306 if before.self_mute != after.self_mute:
307 tts_text, tts_channel = await self.handle_state_change(
308 member, after.channel, 'mute', after.self_mute, log_channel
309 )
310
311 if tts_text and tts_channel:
312 await self.play_tts(tts_channel, tts_text)
313
314
315def setup(bot):
316 bot.add_cog(VcLogs(bot))
diff --git a/cogs/voice.py b/cogs/voice.py
deleted file mode 100644
index a224336..0000000
--- a/cogs/voice.py
+++ /dev/null
@@ -1,444 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import discord
7import logging
8import asyncio
9import os
10import urllib.parse
11from dataclasses import dataclass, field
12from typing import Any
13
14import mutagen
15
16logger = logging.getLogger(__name__)
17
18
19_voice_managers: dict[int, "VoiceManager"] = {}
20
21
22def get_voice_manager(bot) -> "VoiceManager":
23 """Get or create VoiceManager for a specific bot instance."""
24 bot_id = bot.user.id if bot.user else id(bot)
25 if bot_id not in _voice_managers:
26 _voice_managers[bot_id] = VoiceManager(bot)
27 return _voice_managers[bot_id]
28
29
30def is_voice_client_alive(voice_client, guild):
31 """Check if a voice client connection is healthy."""
32 if voice_client is None:
33 return False
34 if not voice_client.is_connected():
35 return False
36 if voice_client.channel is None:
37 return False
38 if guild.get_channel(voice_client.channel.id) is None:
39 return False
40
41 try:
42 ws = voice_client._connection
43 s = ws.socket
44 if s is None or s.fileno() == -1:
45 return False
46 except Exception:
47 return False
48
49 return True
50
51
52@dataclass
53class VoiceManager:
54 bot: Any
55 _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
56 _connecting: set[int] = field(default_factory=set, init=False)
57
58 # --- Connection -----------------------------------------------------------
59
60 async def _ensure_voice_client(self, channel):
61 """Unified connect/move/reconnect logic.
62
63 Returns a healthy VoiceClient connected to *channel*, or None on failure.
64 """
65 guild = channel.guild
66 guild_id = guild.id
67
68 async with self._lock:
69 if guild_id in self._connecting:
70 logger.info(f"Connection already in progress for guild {guild_id}, skipping")
71 return None
72
73 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
74
75 # --- resolve existing client --------------------------------------
76 if vc is not None:
77 if vc.is_connected():
78 if vc.channel == channel:
79 if is_voice_client_alive(vc, guild):
80 logger.info(f"Already connected to {channel}")
81 return vc
82 logger.warning(f"Stale connection to {channel}, reconnecting")
83 # Disconnect resets the state machine to 'disconnected'
84 # but keeps the VoiceClient alive. Reusing it avoids a
85 # race where Discord sends VOICE_SERVER_UPDATE between
86 # the destroy and the fresh-connect below, causing the
87 # new client to timeout waiting for the event (since the
88 # old one already consumed it).
89 try:
90 await vc.disconnect()
91 except Exception:
92 pass
93 try:
94 await vc.connect(
95 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
96 )
97 logger.info(f"Reconnected to voice channel: {channel}")
98 return vc
99 except Exception as e:
100 logger.error(f"Failed to reconnect stale voice client: {e}")
101 try:
102 channel._state._remove_voice_client(guild.id)
103 except Exception:
104 pass
105 vc = None
106 else:
107 try:
108 await vc.move_to(channel)
109 logger.info(f"Moved to voice channel: {channel}")
110 return vc
111 except Exception as e:
112 logger.error(f"Failed to move voice client: {e}")
113 try:
114 await vc.disconnect()
115 except Exception:
116 pass
117 try:
118 await vc.connect(
119 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
120 )
121 logger.info(f"Reconnected after failed move: {channel}")
122 return vc
123 except Exception:
124 pass
125 try:
126 channel._state._remove_voice_client(guild.id)
127 except Exception:
128 pass
129 vc = None
130 else:
131 logger.info("Voice client exists but disconnected, reconnecting")
132 try:
133 await vc.disconnect()
134 except Exception:
135 pass
136 try:
137 await vc.connect(
138 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
139 )
140 logger.info(f"Reconnected disconnected client: {channel}")
141 return vc
142 except Exception as e:
143 logger.error(f"Failed to reconnect disconnected client: {e}")
144 try:
145 channel._state._remove_voice_client(guild.id)
146 except Exception:
147 pass
148 vc = None
149
150 # --- create fresh connection --------------------------------------
151 self._connecting.add(guild_id)
152 try:
153 await asyncio.sleep(0.3)
154 try:
155 vc = await channel.connect(timeout=10.0, reconnect=False)
156 logger.info(f"Connected to voice channel: {channel}")
157 return vc
158 except discord.ClientException as e:
159 if "Already connected" in str(e):
160 logger.warning(f"Stale voice state detected, forcing reconnect: {e}")
161 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
162 if vc:
163 try:
164 await vc.disconnect()
165 except Exception:
166 pass
167 else:
168 try:
169 channel._state._remove_voice_client(guild.id)
170 except Exception:
171 pass
172 await asyncio.sleep(0.3)
173 try:
174 vc = await channel.connect(timeout=10.0, reconnect=False)
175 logger.info(f"Reconnected to voice channel: {channel}")
176 return vc
177 except Exception as e2:
178 logger.error(f"Failed to reconnect after stale cleanup: {e2}")
179 return None
180 logger.error(f"Failed to connect to voice channel: {e}")
181 return None
182 except Exception as e:
183 logger.error(f"Failed to connect to voice channel: {e}")
184 return None
185 finally:
186 self._connecting.discard(guild_id)
187
188 async def connect(self, channel):
189 """Join a voice channel (public alias)."""
190 return await self._ensure_voice_client(channel)
191
192 async def join_channel(self, channel):
193 """Backward-compatible alias for connect()."""
194 return await self.connect(channel)
195
196 async def join_by_channel_id(self, channel_id):
197 """Join a voice channel by channel ID."""
198 channel = self.bot.get_channel(channel_id)
199 if not channel:
200 logger.error(f"Channel {channel_id} not found")
201 return None
202 if not isinstance(channel, discord.VoiceChannel):
203 logger.error(f"Channel {channel_id} is not a voice channel")
204 return None
205 return await self.connect(channel)
206
207 async def get_client(self, guild):
208 """Get existing voice client for a guild."""
209 return discord.utils.get(self.bot.voice_clients, guild=guild)
210
211 async def get_voice_client(self, guild):
212 """Backward-compatible alias for get_client()."""
213 return await self.get_client(guild)
214
215 async def get_status(self, guild):
216 """Get voice client status.
217
218 Returns:
219 tuple: (voice_client, is_connected, is_playing) or (None, False, False)
220 """
221 vc = await self.get_client(guild)
222 if vc is None:
223 return None, False, False
224 return vc, vc.is_connected(), vc.is_playing()
225
226 async def get_voice_status(self, guild):
227 """Backward-compatible alias for get_status()."""
228 return await self.get_status(guild)
229
230 async def disconnect(self, guild):
231 """Stop playback and disconnect from voice."""
232 vc = await self.get_client(guild)
233 if vc:
234 vc.stop()
235 logger.info("Disconnecting from voice")
236 await vc.disconnect()
237 logger.info(f"Disconnected from guild {guild}")
238
239 async def stop(self, guild):
240 """Backward-compatible alias for disconnect()."""
241 await self.disconnect(guild)
242
243 # --- Playback ------------------------------------------------------------
244
245 async def _play_await(self, vc, audio_source):
246 """Play an audio source and wait for completion using an asyncio.Future.
247
248 Returns True on success, False on error. No polling, no magic sleeps.
249 """
250 loop = asyncio.get_event_loop()
251 future = loop.create_future()
252
253 def after(error):
254 if not future.done():
255 if error:
256 future.set_exception(Exception(str(error)))
257 else:
258 future.set_result(True)
259
260 vc.play(audio_source, after=after)
261
262 try:
263 await future
264 return True
265 except Exception as e:
266 logger.error(f"Playback error: {e}")
267 return False
268
269 async def play_source(self, channel, audio_source, interrupt=True, volume=1.0):
270 """Connect to *channel* and play a pre-made audio source.
271
272 Returns True on success, False on failure.
273 """
274 vc = await self.connect(channel)
275 if not vc:
276 logger.error("play_source: could not connect to voice channel")
277 return False
278
279 if interrupt and (vc.is_playing() or vc.is_paused()):
280 vc.stop()
281
282 if abs(volume - 1.0) > 1e-9:
283 audio_source = discord.PCMVolumeTransformer(audio_source)
284 audio_source.volume = volume
285
286 logger.info("Playing via play_source")
287 return await self._play_await(vc, audio_source)
288
289 # --- Source preparation (folded from AudioPlaybackManager) ---------------
290
291 @staticmethod
292 async def validate_voice_channel(ctx):
293 """Validate user is in a voice channel. Returns the channel or None."""
294 if not ctx.author.voice or not ctx.author.voice.channel:
295 await ctx.respond(
296 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
297 )
298 return None
299 return ctx.author.voice.channel
300
301 @staticmethod
302 async def prepare_file_source(source):
303 """Validate a local file and return (display_name, absolute_path) or (None, None)."""
304 logger.info(f"Attempting to play local file: {source}")
305
306 if not os.path.isabs(source):
307 source = os.path.abspath(source)
308 logger.debug(f"Converted to absolute path: {source}")
309
310 if not os.path.exists(source):
311 logger.error(f"File not found: {source}")
312 return None, None
313
314 filename = os.path.basename(source)
315 display_name = os.path.splitext(filename)[0]
316 return display_name, source
317
318 @staticmethod
319 async def prepare_audio_source(source, is_url, display_name):
320 """Prepare FFmpegPCMAudio from a URL or local file.
321
322 Returns (audio_source, final_display_name).
323 """
324 if is_url:
325 before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
326 audio_source = discord.FFmpegPCMAudio(source, before_options=before_options)
327 filename = os.path.basename(source.split("?")[0])
328 filename = urllib.parse.unquote(filename)
329 clean_filename = os.path.splitext(filename)[0]
330 return audio_source, clean_filename
331 else:
332 audio_source = discord.FFmpegPCMAudio(source=source)
333 try:
334 metadata = mutagen.open(source)
335 if metadata:
336 title = (
337 metadata.get("title", [None])[0]
338 if hasattr(metadata, "get")
339 else None
340 )
341 if title:
342 display_name = title
343 logger.debug(f"Read metadata - title: {title}")
344 else:
345 display_name = os.path.splitext(os.path.basename(source))[0]
346 except Exception as e:
347 logger.warning(f"Could not read metadata from {source}: {str(e)}")
348 display_name = os.path.splitext(os.path.basename(source))[0]
349
350 return audio_source, display_name
351
352 @staticmethod
353 async def prepare_source(source):
354 """Determine whether *source* is a URL or local file.
355
356 Returns (source_path, display_name, is_url) or (None, None, None) on failure.
357 """
358 is_url = source.startswith(("http://", "https://"))
359
360 if is_url:
361 logger.info(f"Attempting to play stream from URL: {source}")
362 return source, source, is_url
363
364 display_name, absolute_source = await VoiceManager.prepare_file_source(source)
365 if not display_name:
366 return None, None, None
367
368 return absolute_source, display_name, is_url
369
370 @staticmethod
371 async def cleanup_temp_file(source):
372 """Remove a temporary file if it exists."""
373 if source and os.path.exists(source):
374 try:
375 os.remove(source)
376 logger.info(f"Cleaned up temporary file: {source}")
377 except Exception as e:
378 logger.error(f"Error cleaning up file {source}: {str(e)}")
379
380 @staticmethod
381 async def cleanup_if_needed(source, delete_after_play, is_url):
382 """Conditionally clean up a file after playback."""
383 if delete_after_play and not is_url:
384 await VoiceManager.cleanup_temp_file(source)
385
386 # --- High-level play (full flow) -----------------------------------------
387
388 async def play(self, ctx, source, delete_after_play=False, interrupt=True):
389 """High-level playback: validate context, prepare source, connect, play, cleanup.
390
391 Sends ``grom: {name}`` immediately on start, and an error follow-up
392 if playback fails.
393
394 Args:
395 ctx: Discord application context (used for channel resolution & error messages).
396 source: Local file path or URL to stream.
397 delete_after_play: Remove local file after playback (no-op for URLs).
398 interrupt: Stop currently playing audio before starting.
399
400 Returns:
401 bool: True if playback completed successfully, False otherwise.
402 """
403 prepared_source, display_name, is_url = await VoiceManager.prepare_source(source)
404 if not prepared_source:
405 await VoiceManager.cleanup_if_needed(source, delete_after_play, False)
406 return False
407
408 target_channel = await VoiceManager.validate_voice_channel(ctx)
409 if not target_channel:
410 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
411 return False
412
413 vc = await self.connect(target_channel)
414 if not vc:
415 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
416 return False
417
418 if interrupt and (vc.is_playing() or vc.is_paused()):
419 vc.stop()
420
421 audio_source, final_display_name = await VoiceManager.prepare_audio_source(
422 prepared_source, is_url, display_name
423 )
424
425 try:
426 await ctx.followup.send(f"grom: {final_display_name}")
427 except Exception as e:
428 logger.error(e)
429
430 success = await self._play_await(vc, audio_source)
431
432 if success:
433 logger.info(f"Playback completed: {final_display_name}")
434 else:
435 logger.error(f"Playback failed: {prepared_source}")
436 try:
437 await ctx.followup.send(
438 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
439 )
440 except Exception:
441 pass
442 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
443
444 return success
diff --git a/cogs/web.py b/cogs/web.py
deleted file mode 100644
index fd22f9f..0000000
--- a/cogs/web.py
+++ /dev/null
@@ -1,90 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import discord
7from discord import Embed
8from discord.ext import commands
9import requests
10from bs4 import BeautifulSoup
11import urllib.parse
12import yt_dlp
13import logging
14from cogs.utils.url_helper import clean_url_for_ytdlp
15
16logger = logging.getLogger(__name__)
17
18
19class Web(commands.Cog):
20 def __init__(self, bot):
21 self.bot = bot
22
23 def _extract_youtube_title(self, url):
24 """Extract YouTube video title if URL is a YouTube link."""
25 if "youtube.com/watch" not in url:
26 return None
27
28 url = clean_url_for_ytdlp(url)
29 logger.info("youtube link")
30 try:
31 ydl_opts = {"quiet": True, "nowarnings": True}
32 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
33 info = ydl.extract_info(url, download=False)
34 return info.get("title") if info else None
35 except Exception:
36 pass
37 return None
38
39 def _process_search_results(self, links, query):
40 """Process search results and create embed."""
41 embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF)
42 view = discord.ui.View(disable_on_timeout=True)
43
44 for i, link in enumerate(links[:5], start=1):
45 title = link.text
46 youtube_title = self._extract_youtube_title(link.get("href"))
47 if youtube_title:
48 title = youtube_title
49
50 embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False)
51
52 return embed, view
53
54 @commands.slash_command(
55 name="web",
56 description="Search the web using DuckDuckGo"
57 )
58 async def web(
59 self,
60 ctx: discord.ApplicationContext,
61 query: str
62 ):
63 await ctx.defer()
64 headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"}
65 encoded_query = urllib.parse.quote_plus(query)
66 url = f"https://duckduckgo.com/html/?q={encoded_query}"
67 response = requests.get(url, headers=headers)
68
69 logger.info(f"Query: {query}")
70
71 if response.status_code != 200:
72 await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True)
73 logger.error(f"Error: {response.status_code}")
74 return
75
76 soup = BeautifulSoup(response.text, "html.parser")
77 links = soup.find_all("a", class_="result__a")
78
79 if not links:
80 await ctx.respond(content="No results found.", ephemeral=True)
81 logger.warning("No results found")
82 return
83
84 embed, view = self._process_search_results(links, query)
85 await ctx.respond(embed=embed, view=view)
86 logger.info("Search results sent")
87
88
89def setup(bot):
90 bot.add_cog(Web(bot))
diff --git a/main.py b/main.py
deleted file mode 100644
index cab1f54..0000000
--- a/main.py
+++ /dev/null
@@ -1,96 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import asyncio
7import logging
8import os
9import sys
10import argparse
11
12import discord
13from discord.ext import commands
14from dotenv import load_dotenv
15
16from cogs.utils import settings
17
18
19def setup_logging(debug: bool = False):
20 """Configure logging based on debug flag."""
21 level = logging.DEBUG if debug else logging.INFO
22
23 class DecryptionFilter(logging.Filter):
24 def filter(self, record):
25 return "Decryption failed" not in record.getMessage()
26
27 logging.basicConfig(
28 level=level,
29 datefmt="%m-%d %H:%M:%S",
30 format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s",
31 handlers=[logging.StreamHandler(sys.stdout)],
32 )
33 logging.getLogger().addFilter(DecryptionFilter())
34
35
36# Create a new event loop and set it as the current loop
37loop = asyncio.new_event_loop()
38asyncio.set_event_loop(loop)
39
40intents = discord.Intents.all()
41bot = commands.Bot(intents=intents, loop=loop)
42
43
44@bot.event
45async def on_ready():
46 logger = logging.getLogger(__name__)
47 logger.info("ŁUKASZ SPERMA AKTYWACJA")
48
49 await settings.load_settings()
50
51
52if __name__ == "__main__":
53 parser = argparse.ArgumentParser(description="Run Senator Sperma bot")
54 parser.add_argument("--debug", action="store_true", help="Enable debug logging")
55 args, unknown = parser.parse_known_args()
56
57 load_dotenv()
58 setup_logging(args.debug)
59
60 extensions = [
61 "cogs.vclogs",
62 "cogs.compression",
63 "cogs.tts",
64 "cogs.web",
65 "cogs.ai",
66 "cogs.conversion",
67 "cogs.math_commands",
68 "cogs.downloader",
69 "cogs.say",
70 "cogs.assistant",
71 "cogs.music.play_commands",
72 "cogs.music.stop",
73 "cogs.music.join",
74 "cogs.search",
75 "cogs.trigger",
76 "cogs.cron",
77 "cogs.qr",
78 "cogs.uploader",
79 ]
80
81 try:
82 for i, extension in enumerate(extensions, 1):
83 bot.load_extension(extension)
84 logging.getLogger(__name__).info(f"Loaded {i}/{len(extensions)} extensions")
85
86 token = os.getenv("TOKEN")
87 if not token:
88 raise ValueError(
89 "No token provided. Please set the TOKEN environment variable."
90 )
91
92 bot.run(token)
93
94 except Exception as e:
95 logging.getLogger(__name__).error(f"Error starting the bot: {e}")
96 sys.exit(1)
diff --git a/pyrightconfig.json b/pyrightconfig.json
deleted file mode 100644
index 38f1ed5..0000000
--- a/pyrightconfig.json
+++ /dev/null
@@ -1,19 +0,0 @@
1{
2 "include": ["cogs", "main.py", "cogs/utils", "tests"],
3 "exclude": ["pycord", ".venv", "temp", "data"],
4 "typeCheckingMode": "basic",
5 "reportMissingTypeStubs": false,
6 "reportUnknownMemberType": "none",
7 "reportUnknownParameterType": "none",
8 "reportUnknownArgumentType": "none",
9 "reportUnknownVariableType": "none",
10 "reportMissingParameterType": false,
11 "reportAny": false,
12 "reportOptionalMemberAccess": false,
13 "reportOptionalSubscript": false,
14 "reportCallIssue": false,
15 "reportAttributeAccessIssue": false,
16 "reportPossiblyUnboundVariable": false,
17 "reportArgumentType": false,
18 "reportUnreachable": false,
19}
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 23e8e9c..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,9 +0,0 @@
1[pytest]
2markers =
3 slow: marks tests as slow (deselect with '-m "not slow"')
4asyncio_mode = strict
5asyncio_default_fixture_loop_scope = function
6filterwarnings =
7 ignore::RuntimeWarning
8 ignore::pytest.PytestUnraisableExceptionWarning
9 ignore::DeprecationWarning:discord.client \ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index ed5f5cc..0000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,18 +0,0 @@
1pytest==9.0.3
2pytest-asyncio==1.3.0
3basedpyright==1.39.5
4aiofiles==25.1.0
5aiohttp==3.13.5
6beautifulsoup4==4.14.3
7croniter==6.2.2
8gTTS==2.5.4
9httpx==0.28.1
10mutagen==1.47.0
11pillow==12.2.0
12py-cord==2.8.0
13python-dotenv==1.2.2
14pyzbar==0.1.9
15qrcode==8.2
16requests==2.34.2
17yt-dlp==2026.3.17
18youtube-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 @@
1aiofiles==25.1.0
2aiohttp==3.13.5
3beautifulsoup4==4.14.3
4croniter==6.2.2
5gTTS==2.5.4
6httpx==0.28.1
7mutagen==1.47.0
8pillow==12.2.0
9py-cord==2.8.0
10python-dotenv==1.2.2
11pyzbar==0.1.9
12qrcode==8.2
13requests==2.34.2
14yt-dlp==2026.3.17
15youtube-search-python==1.6.6
diff --git a/scripts/bump_reqs.py b/scripts/bump_reqs.py
deleted file mode 100644
index 5d253e7..0000000
--- a/scripts/bump_reqs.py
+++ /dev/null
@@ -1,50 +0,0 @@
1#!/usr/bin/env python3
2"""Bump all pinned versions in requirements*.txt to latest from PyPI."""
3
4import json
5import re
6import urllib.request
7from pathlib import Path
8
9REQS = sorted(Path().glob("requirements*.txt"))
10PYPI = "https://pypi.org/pypi/{}/json"
11
12
13def latest_version(package: str) -> str | None:
14 url = PYPI.format(package)
15 try:
16 with urllib.request.urlopen(url, timeout=10) as resp:
17 data = json.load(resp)
18 return data["info"]["version"]
19 except Exception:
20 return None
21
22
23def bump_file(path: Path):
24 lines = path.read_text().splitlines()
25 changed = []
26 for line in lines:
27 m = re.match(r"^([\w-]+(?:\[[\w-]+])?)==[\w.]+$", line)
28 if m:
29 pkg = m.group(1)
30 latest = latest_version(pkg)
31 if latest:
32 changed.append(f"{pkg}=={latest}")
33 if f"{pkg}=={latest}" != line:
34 print(f" {pkg}: {line.split('==')[1]} -> {latest}")
35 else:
36 changed.append(line)
37 print(f" {pkg}: failed to fetch, keeping current")
38 else:
39 changed.append(line)
40 path.write_text("\n".join(changed) + "\n")
41
42
43def main():
44 for req in REQS:
45 print(f"\n{req}:")
46 bump_file(req)
47
48
49if __name__ == "__main__":
50 main()
diff --git a/src/commands/ai.rs b/src/commands/ai.rs
new file mode 100644
index 0000000..a75f164
--- /dev/null
+++ b/src/commands/ai.rs
@@ -0,0 +1,76 @@
1use crate::Data;
2use crate::error::Error;
3use crate::lang;
4
5type Context<'a> = poise::Context<'a, Data, Error>;
6
7/// Query the AI senator via OpenRouter
8#[poise::command(slash_command)]
9pub async fn senator(
10 ctx: Context<'_>,
11 #[description = "Wiadomość do wysłania"] text: String,
12 #[description = "Przemówić w kanale głosowym?"] tts_vc: Option<bool>,
13) -> crate::error::Result<()> {
14 ctx.defer().await?;
15
16 let api_key = ctx
17 .data()
18 .config
19 .openrouter_api_key
20 .as_ref()
21 .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?;
22 let model = ctx
23 .data()
24 .config
25 .openrouter_model
26 .as_deref()
27 .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free");
28
29 let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string());
30 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?;
31
32 if tts_vc.unwrap_or(false) {
33 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
34 let channel_id = ctx
35 .serenity_context()
36 .cache
37 .guild(guild_id)
38 .and_then(|g| {
39 g.voice_states
40 .get(&ctx.author().id)
41 .and_then(|vs| vs.channel_id)
42 })
43 .ok_or_else(|| lang::err_not_in_voice())?;
44
45 let tts_url = format!(
46 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob",
47 crate::commands::tts::urlencoding(&response)
48 );
49 let audio_data = ctx
50 .data()
51 .http_client
52 .get(&tts_url)
53 .send()
54 .await?
55 .bytes()
56 .await?;
57
58 let call = ctx
59 .data()
60 .voice_manager
61 .connect(guild_id, channel_id)
62 .await?;
63 let input = crate::voice::decode_audio(&audio_data)?;
64 let mut call_lock = call.lock().await;
65 if call_lock.current_channel().is_some() {
66 call_lock.play_input(input);
67 }
68 }
69
70 ctx.say(response).await?;
71 Ok(())
72}
73
74pub fn commands() -> Vec<poise::Command<Data, Error>> {
75 vec![senator()]
76}
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs
new file mode 100644
index 0000000..497aec9
--- /dev/null
+++ b/src/commands/autocomplete.rs
@@ -0,0 +1,128 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::copyparty::{html_decode, percent_decode};
5use crate::error::Error;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9const MAX_CHOICE_LEN: usize = 99;
10
11fn filename_label(raw_path: &str) -> Option<String> {
12 let raw_name = raw_path.rsplit('/').next()?;
13 if raw_name.is_empty() {
14 return None;
15 }
16 let decoded = html_decode(&percent_decode(raw_name));
17 if decoded.len() > MAX_CHOICE_LEN {
18 let mut s: String = decoded.chars().take(MAX_CHOICE_LEN - 1).collect();
19 s.push('\u{2026}');
20 Some(s)
21 } else {
22 Some(decoded)
23 }
24}
25
26pub async fn audio_file<'a>(
27 ctx: Context<'_>,
28 partial: &'a str,
29) -> Vec<serenity::AutocompleteChoice> {
30 eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})");
31 let results = ctx
32 .data()
33 .copyparty
34 .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"])
35 .await;
36 match results {
37 Ok(hits) => {
38 eprintln!("[DEBUG] audio_file got {} raw hits", hits.len());
39 let mut choices = Vec::new();
40 for (i, hit) in hits.into_iter().enumerate() {
41 let rp = match &hit.rp {
42 Some(p) => p.clone(),
43 None => continue,
44 };
45 eprintln!(
46 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
47 hit.url, hit.name, hit.path
48 );
49 let Some(label) = filename_label(&rp) else {
50 continue;
51 };
52 ctx.data()
53 .ac_stash(ctx.author().id, label.clone(), hit)
54 .await;
55 eprintln!("[DEBUG] -> cached as {label:?}");
56 choices.push(serenity::AutocompleteChoice::new(label.clone(), rp));
57 if choices.len() >= 25 {
58 break;
59 }
60 }
61 eprintln!("[DEBUG] audio_file returning {} choices", choices.len());
62 choices
63 }
64 Err(e) => {
65 eprintln!("[DEBUG] audio_file search failed: {e}");
66 vec![]
67 }
68 }
69}
70
71pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<serenity::AutocompleteChoice> {
72 eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})");
73 let results = ctx.data().copyparty.search(partial, 25, &[]).await;
74 match results {
75 Ok(hits) => {
76 eprintln!("[DEBUG] any_file got {} raw hits", hits.len());
77 let mut choices = Vec::new();
78 for (i, hit) in hits.into_iter().enumerate() {
79 let rp = match &hit.rp {
80 Some(p) => p.clone(),
81 None => continue,
82 };
83 eprintln!(
84 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
85 hit.url, hit.name, hit.path
86 );
87 let Some(label) = filename_label(&rp) else {
88 continue;
89 };
90 ctx.data()
91 .ac_stash(ctx.author().id, label.clone(), hit)
92 .await;
93 eprintln!("[DEBUG] -> cached as {label:?}");
94 choices.push(serenity::AutocompleteChoice::new(label.clone(), label));
95 if choices.len() >= 25 {
96 break;
97 }
98 }
99 eprintln!("[DEBUG] any_file returning {} choices", choices.len());
100 choices
101 }
102 Err(e) => {
103 eprintln!("[DEBUG] any_file search failed: {e}");
104 vec![]
105 }
106 }
107}
108
109pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String> {
110 eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})");
111 let db_pool = &ctx.data().db_pool;
112 match crate::database::list_triggers(db_pool).await {
113 Ok(triggers) => {
114 let names: Vec<_> = triggers
115 .into_iter()
116 .map(|t| t.name)
117 .filter(|name| partial.is_empty() || name.starts_with(partial))
118 .take(25)
119 .collect();
120 eprintln!("[DEBUG] trigger_name returning {} names", names.len());
121 names
122 }
123 Err(e) => {
124 eprintln!("[DEBUG] trigger_name query failed: {e}");
125 vec![]
126 }
127 }
128}
diff --git a/src/commands/cron.rs b/src/commands/cron.rs
new file mode 100644
index 0000000..48f6ff1
--- /dev/null
+++ b/src/commands/cron.rs
@@ -0,0 +1,155 @@
1use poise::serenity_prelude as serenity;
2
3use super::autocomplete::audio_file;
4use crate::Data;
5use crate::database;
6use crate::error::{Error, Result};
7use crate::lang;
8
9type Context<'a> = poise::Context<'a, Data, Error>;
10
11/// Cron job management
12#[poise::command(
13 slash_command,
14 subcommands("dodaj_audio", "dodaj_wiadomość", "pokaż", "usuń", "pauza", "wznów",)
15)]
16pub async fn cron(_ctx: Context<'_>) -> Result<()> {
17 Ok(())
18}
19
20/// Schedule audio playback
21#[poise::command(slash_command)]
22pub async fn dodaj_audio(
23 ctx: Context<'_>,
24 #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String,
25 #[description = "Target voice channel"] channel: serenity::Channel,
26 #[description = "Copyparty URL of audio file"]
27 #[autocomplete = "audio_file"]
28 audio_filename: String,
29) -> Result<()> {
30 let channel_id = match channel {
31 serenity::Channel::Guild(ch) => ch.id.get() as i64,
32 _ => return Err(lang::err_bad_channel_type()),
33 };
34
35 database::add_cron_job(
36 &ctx.data().db_pool,
37 &cron_expression,
38 "audio",
39 channel_id,
40 Some(&audio_filename),
41 None,
42 )
43 .await?;
44
45 ctx.say(lang::fmt_cron_audio_queued(&cron_expression))
46 .await?;
47 Ok(())
48}
49
50/// Schedule a periodic message
51#[poise::command(slash_command)]
52pub async fn dodaj_wiadomość(
53 ctx: Context<'_>,
54 #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String,
55 #[description = "Docelowy kanał tekstowy"] channel: serenity::Channel,
56 #[description = "Zawartość wiadomości"] message: String,
57) -> Result<()> {
58 let channel_id = match channel {
59 serenity::Channel::Guild(ch) => ch.id.get() as i64,
60 _ => return Err(lang::err_bad_channel_type()),
61 };
62
63 database::add_cron_job(
64 &ctx.data().db_pool,
65 &cron_expression,
66 "wiadomość",
67 channel_id,
68 None,
69 Some(&message),
70 )
71 .await?;
72
73 ctx.say(lang::L_CRON_QUEUED).await?;
74 Ok(())
75}
76
77/// List all cron jobs
78#[poise::command(slash_command)]
79pub async fn pokaż(ctx: Context<'_>) -> Result<()> {
80 let jobs = database::list_cron_jobs(&ctx.data().db_pool).await?;
81 if jobs.is_empty() {
82 ctx.say(lang::L_CRON_NONE).await?;
83 return Ok(());
84 }
85
86 let mut embed = serenity::CreateEmbed::default()
87 .title(lang::L_CRON_TITLE)
88 .color(0x00aaff);
89
90 for job in &jobs {
91 let status = if job.active == 1 {
92 lang::L_CRON_ACTIVE
93 } else {
94 lang::L_CRON_PAUSED
95 };
96 let desc = match job.r#type.as_str() {
97 "audio" => lang::fmt_cron_label_audio(job.audio_filename.as_deref().unwrap_or("?")),
98 _ => lang::fmt_cron_label_msg(job.message.as_deref().unwrap_or("?")),
99 };
100 embed = embed.field(
101 lang::fmt_cron_job_status(job.id, status),
102 lang::fmt_cron_field(&job.cron_expression, job.channel_id, &desc),
103 false,
104 );
105 }
106
107 ctx.send(poise::CreateReply::default().embed(embed)).await?;
108 Ok(())
109}
110
111/// Remove a cron job
112#[poise::command(slash_command)]
113pub async fn usuń(
114 ctx: Context<'_>,
115 #[description = "ID pracy do usunięcia"] job_id: i64,
116) -> Result<()> {
117 if database::delete_cron_job(&ctx.data().db_pool, job_id).await? {
118 ctx.say(lang::fmt_cron_deleted(job_id)).await?;
119 } else {
120 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
121 }
122 Ok(())
123}
124
125/// Pause a cron job
126#[poise::command(slash_command)]
127pub async fn pauza(
128 ctx: Context<'_>,
129 #[description = "ID pracy do zapauzowania"] job_id: i64,
130) -> Result<()> {
131 if database::set_cron_job_active(&ctx.data().db_pool, job_id, false).await? {
132 ctx.say(lang::fmt_cron_paused(job_id)).await?;
133 } else {
134 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
135 }
136 Ok(())
137}
138
139/// Resume a paused cron job
140#[poise::command(slash_command)]
141pub async fn wznów(
142 ctx: Context<'_>,
143 #[description = "ID pracy cron do wznowienia"] job_id: i64,
144) -> Result<()> {
145 if database::set_cron_job_active(&ctx.data().db_pool, job_id, true).await? {
146 ctx.say(lang::fmt_cron_resumed(job_id)).await?;
147 } else {
148 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
149 }
150 Ok(())
151}
152
153pub fn commands() -> Vec<poise::Command<Data, Error>> {
154 vec![cron()]
155}
diff --git a/src/commands/math.rs b/src/commands/math.rs
new file mode 100644
index 0000000..43a7de8
--- /dev/null
+++ b/src/commands/math.rs
@@ -0,0 +1,77 @@
1use crate::lang;
2use crate::Data;
3use crate::error::{Error, Result};
4
5type Context<'a> = poise::Context<'a, Data, Error>;
6
7/// Evaluate a math expression via qalc
8#[poise::command(slash_command)]
9pub async fn math(
10 ctx: Context<'_>,
11 #[description = "Ekspresja matematyczn"] equation: String,
12) -> Result<()> {
13 if equation.len() > 1000 {
14 ctx.say(lang::L_MATH_TOO_LONG).await?;
15 return Ok(());
16 }
17
18 if !equation.is_ascii() {
19 ctx.say(lang::L_MATH_NOT_ASCII).await?;
20 return Ok(());
21 }
22
23 let mut depth = 0i32;
24 for c in equation.chars() {
25 match c {
26 '(' | '[' | '{' => depth += 1,
27 ')' | ']' | '}' => depth -= 1,
28 _ => {}
29 }
30 if depth < 0 {
31 ctx.say(lang::L_MATH_UNBALANCED).await?;
32 return Ok(());
33 }
34 }
35 if depth != 0 {
36 ctx.say(lang::L_MATH_UNBALANCED).await?;
37 return Ok(());
38 }
39
40 let dangerous = [
41 "eval",
42 "exec",
43 "os.",
44 "subprocess",
45 "import",
46 "__",
47 "system",
48 "shutil",
49 "typecast",
50 "shell",
51 "fork",
52 "execve",
53 ];
54 let lower = equation.to_lowercase();
55 if dangerous.iter().any(|d| lower.contains(d)) {
56 ctx.say(lang::L_MATH_DANGEROUS).await?;
57 return Ok(());
58 }
59
60 let output = tokio::process::Command::new("qalc")
61 .arg(&equation)
62 .output()
63 .await
64 .map_err(|e| Error::Custom(lang::fmt_math_qalc_err(&e)))?;
65
66 let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
67 if result.is_empty() {
68 ctx.say(lang::L_MATH_NO_RESULT).await?;
69 } else {
70 ctx.say(lang::fmt_math_result(&result)).await?;
71 }
72 Ok(())
73}
74
75pub fn commands() -> Vec<poise::Command<Data, Error>> {
76 vec![math()]
77}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
new file mode 100644
index 0000000..9d6a1d0
--- /dev/null
+++ b/src/commands/mod.rs
@@ -0,0 +1,26 @@
1pub mod music;
2pub mod tts;
3pub mod ai;
4pub mod web;
5pub mod math;
6pub mod qr;
7pub mod cron;
8pub mod trigger;
9pub mod utilities;
10pub mod autocomplete;
11
12use crate::Data;
13
14pub fn all_commands() -> Vec<poise::Command<Data, crate::error::Error>> {
15 let mut cmds = Vec::new();
16 cmds.extend(music::commands());
17 cmds.extend(tts::commands());
18 cmds.extend(ai::commands());
19 cmds.extend(web::commands());
20 cmds.extend(math::commands());
21 cmds.extend(qr::commands());
22 cmds.extend(cron::commands());
23 cmds.extend(trigger::commands());
24 cmds.extend(utilities::commands());
25 cmds
26}
diff --git a/src/commands/music.rs b/src/commands/music.rs
new file mode 100644
index 0000000..313577d
--- /dev/null
+++ b/src/commands/music.rs
@@ -0,0 +1,414 @@
1use poise::serenity_prelude as serenity;
2use tracing::{error, info};
3
4use super::autocomplete::audio_file;
5use crate::Data;
6use crate::copyparty::{html_decode, percent_decode};
7use crate::error::{Error, Result};
8use crate::lang;
9
10type Context<'a> = poise::Context<'a, Data, Error>;
11
12// ── Source classification ──────────────────────────────────────────
13
14/// What kind of audio source are we dealing with?
15///
16/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN`
17/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while
18/// *every other* URL is streamed directly via ffmpeg (radio, HLS,
19/// playlists, direct media files).
20#[derive(Debug, Clone, PartialEq)]
21enum SourceKind {
22 /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve
23 /// raw audio at a direct URL. Needs yt-dlp to extract + download the
24 /// media to memory before decoding.
25 YtDlp,
26 /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`,
27 /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or
28 /// directly-accessible media files. Streamed via an ffmpeg subprocess
29 /// — NO full download. ffmpeg handles playlist parsing, Icecast
30 /// metadata, reconnection, and codec/container detection universally.
31 Stream,
32}
33
34/// Play music from Copyparty, URL, or file upload
35#[poise::command(slash_command)]
36pub async fn grajcuj(
37 ctx: Context<'_>,
38 #[description = "Plik do odtworzenia"]
39 #[autocomplete = "audio_file"]
40 melodia: Option<String>,
41 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
42 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
43) -> Result<()> {
44 let compressed = compressed.unwrap_or(false);
45 eprintln!(
46 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}",
47 melodia,
48 file.as_ref().map(|f| &f.filename)
49 );
50 let result = play_impl(ctx, melodia, file, compressed).await;
51 if let Err(e) = &result {
52 eprintln!("[DEBUG] play() error: {e}");
53 error!("play command failed: {e}");
54 }
55 result
56}
57
58struct ResolvedSource {
59 url: String,
60 display_name: String,
61 kind: SourceKind,
62}
63
64fn clean_name(raw: &str) -> String {
65 html_decode(&percent_decode(raw))
66}
67
68// ── URL classification helpers ──────────────────────────────────────
69
70/// URLs that require yt-dlp extraction (platforms that don't serve
71/// raw audio at a direct URL). These MUST be downloaded first.
72///
73/// Matches the Python original's `EXTERNAL_URL_PATTERN`:
74/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch).
75fn needs_ytdlp(url: &str) -> bool {
76 let lower = url.to_lowercase();
77 lower.contains("youtube.com/")
78 || lower.contains("youtu.be/")
79 || lower.contains("twitch.tv/")
80 || lower.contains("soundcloud.com/")
81 || lower.contains("bandcamp.com/")
82}
83
84/// Heuristic: does this URL *look* like a live internet radio stream?
85/// (Used for logging / display only — dispatch is controlled by `SourceKind`.)
86fn is_live_stream_hint(url: &str) -> bool {
87 let lower = url.to_lowercase();
88 // Common Icecast/Shoutcast mount patterns
89 lower.contains("/stream")
90 || lower.contains("/live")
91 || lower.contains("/radio")
92 || lower.contains(";stream")
93 || lower.contains("icecast")
94 || lower.contains("shoutcast")
95 || lower.ends_with(".m3u8")
96 || lower.ends_with(".m3u")
97 || lower.ends_with(".pls")
98 || lower.contains(":8000/") // common Icecast port
99 || lower.contains(":8080/") // common alt port
100}
101
102async fn resolve_source(
103 ctx: Context<'_>,
104 melodia: Option<String>,
105 file: Option<serenity::Attachment>,
106) -> Result<ResolvedSource> {
107 // ── File attachment ──────────────────────────────────────────
108 // Discord attachment URLs are direct media file URLs — ffmpeg streams
109 // them without a full download.
110 if let Some(file) = file {
111 return Ok(ResolvedSource {
112 url: file.url.clone(),
113 display_name: file.filename.clone(),
114 kind: SourceKind::Stream,
115 });
116 }
117
118 let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?;
119
120 // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─
121 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await {
122 let path = hit.url_path().unwrap_or("");
123 let path = if path.starts_with('/') {
124 path.to_string()
125 } else {
126 format!("/{path}")
127 };
128 let url = ctx.data().copyparty.build_url(&path);
129 let display_name = clean_name(hit.filename().unwrap_or(&name));
130 return Ok(ResolvedSource {
131 url,
132 display_name,
133 kind: SourceKind::Stream,
134 });
135 }
136
137 // ── Direct Copyparty path (starts with '/') ──────────────────
138 if name.starts_with('/') {
139 let url = ctx.data().copyparty.build_url(&name);
140 let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name));
141 return Ok(ResolvedSource {
142 url,
143 display_name,
144 kind: SourceKind::Stream,
145 });
146 }
147
148 // ── Direct HTTP(S) URL ───────────────────────────────────────
149 if name.starts_with("http://") || name.starts_with("https://") {
150 let url = name.clone();
151 let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url));
152
153 // yt-dlp platforms → download; everything else (radio, HLS,
154 // playlists, Copyparty, direct media) → ffmpeg stream.
155 let kind = if needs_ytdlp(&url) {
156 SourceKind::YtDlp
157 } else {
158 SourceKind::Stream
159 };
160 return Ok(ResolvedSource {
161 url,
162 display_name,
163 kind,
164 });
165 }
166
167 // ── Fallback: search Copyparty ───────────────────────────────
168 info!(query = %name, "searching copyparty for audio");
169 let results = ctx
170 .data()
171 .copyparty
172 .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"])
173 .await
174 .map_err(|e| {
175 error!(query = %name, error = %e, "copyparty search failed");
176 Error::Custom(lang::fmt_music_search_failed(&e))
177 })?;
178 let hit = results
179 .first()
180 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?;
181 let path = hit
182 .url_path()
183 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?;
184 let path = if path.starts_with('/') {
185 path.to_string()
186 } else {
187 format!("/{path}")
188 };
189 let url = ctx.data().copyparty.build_url(&path);
190 let display_name = clean_name(hit.filename().unwrap_or("unknown"));
191 Ok(ResolvedSource {
192 url,
193 display_name,
194 kind: SourceKind::Stream,
195 })
196}
197
198/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
199fn redact_url(url: &str) -> &str {
200 let Some(q) = url.find('?') else {
201 return url;
202 };
203 let before = &url[..q];
204 let after = &url[q..];
205 // Strip `?pw=...` or `&pw=...`
206 if after.strip_prefix("?pw=").is_some() {
207 return before;
208 }
209 if let Some(idx) = after.find("&pw=") {
210 return &url[..q + idx];
211 }
212 url
213}
214
215/// Strip tracking/playlist params (`&...`) from YouTube URLs.
216/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
217pub(crate) fn sanitize_yt_url(url: &str) -> String {
218 let lower = url.to_lowercase();
219 if lower.contains("youtube.com/") || lower.contains("youtu.be/") {
220 if let Some(pos) = url.find('&') {
221 return url[..pos].to_string();
222 }
223 }
224 url.to_string()
225}
226
227// ── Playlist resolution is handled by ffmpeg natively ──────────────
228// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment
229// fetching, PLS/M3U entry parsing) — no manual resolution needed.
230// This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach.
231
232async fn play_impl(
233 ctx: Context<'_>,
234 melodia: Option<String>,
235 file: Option<serenity::Attachment>,
236 compressed: bool,
237) -> Result<()> {
238 ctx.defer().await?;
239
240 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
241
242 let channel_id = ctx
243 .serenity_context()
244 .cache
245 .guild(guild_id)
246 .and_then(|g| {
247 g.voice_states
248 .get(&ctx.author().id)
249 .and_then(|vs| vs.channel_id)
250 })
251 .ok_or_else(|| lang::err_not_in_voice())?;
252
253 let src = resolve_source(ctx, melodia, file).await?;
254
255 let call;
256 let input;
257
258 if compressed {
259 // ── Compressed path: download → compress → decode ──────────
260 // Defer stopping current track until compression is done.
261 let audio_bytes = match src.kind {
262 SourceKind::YtDlp => {
263 let clean_url = sanitize_yt_url(&src.url);
264 info!(url = %clean_url, "downloading via yt-dlp for compression");
265 crate::voice::ytdlp_download(&clean_url).await?
266 }
267 SourceKind::Stream => {
268 if is_live_stream_hint(&src.url) {
269 ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?;
270 // Fall through to normal streaming below
271 let live = true;
272 info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg");
273 // Stop current, connect, stream
274 {
275 let mut map = ctx.data().music_tracks.lock().await;
276 if let Some(handle) = map.remove(&guild_id.get()) {
277 let _ = handle.stop();
278 }
279 }
280 let (call_res, input_res) = tokio::join!(
281 ctx.data().voice_manager.connect(guild_id, channel_id),
282 crate::voice::ffmpeg_stream_input(&src.url)
283 );
284 call = call_res?;
285 input = input_res?;
286 // skip the rest of the compressed path
287 let handle = {
288 let mut call_lock = call.lock().await;
289 call_lock.play_input(input)
290 };
291 ctx.data()
292 .music_tracks
293 .lock()
294 .await
295 .insert(guild_id.get(), handle);
296 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
297 return Ok(());
298 }
299 info!(url = %redact_url(&src.url), "downloading for compression");
300 crate::voice::download_url(&ctx.data().http_client, &src.url).await?
301 }
302 };
303
304 info!("degrading audio ({} bytes)", audio_bytes.len());
305 let degraded = crate::voice::degrade_audio(&audio_bytes).await?;
306
307 // Deferred stop: only interrupt current track now that we're ready.
308 {
309 let mut map = ctx.data().music_tracks.lock().await;
310 if let Some(handle) = map.remove(&guild_id.get()) {
311 let _ = handle.stop();
312 }
313 }
314 call = ctx
315 .data()
316 .voice_manager
317 .connect(guild_id, channel_id)
318 .await?;
319 input = degraded;
320 } else {
321 // ── Normal path: stop first, then stream/download ──────────
322 {
323 let mut map = ctx.data().music_tracks.lock().await;
324 if let Some(handle) = map.remove(&guild_id.get()) {
325 let _ = handle.stop();
326 }
327 }
328
329 match src.kind {
330 SourceKind::YtDlp => {
331 let clean_url = sanitize_yt_url(&src.url);
332 info!(url = %clean_url, "downloading via yt-dlp");
333 let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?;
334 let (call_res, input_res) = tokio::join!(
335 ctx.data().voice_manager.connect(guild_id, channel_id),
336 tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes))
337 );
338 call = call_res?;
339 input = input_res??;
340 }
341 SourceKind::Stream => {
342 let live = is_live_stream_hint(&src.url);
343 info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess");
344 let (call_res, input_res) = tokio::join!(
345 ctx.data().voice_manager.connect(guild_id, channel_id),
346 crate::voice::ffmpeg_stream_input(&src.url)
347 );
348 call = call_res?;
349 input = input_res?;
350 }
351 }
352 }
353
354 let handle = {
355 let mut call_lock = call.lock().await;
356 call_lock.play_input(input)
357 };
358
359 ctx.data()
360 .music_tracks
361 .lock()
362 .await
363 .insert(guild_id.get(), handle);
364
365 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
366 Ok(())
367}
368
369/// Stop playback and disconnect from voice
370#[poise::command(slash_command)]
371pub async fn stop(ctx: Context<'_>) -> Result<()> {
372 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
373 ctx.data().voice_manager.disconnect(guild_id).await?;
374 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
375 ctx.send(
376 poise::CreateReply::default()
377 .content(lang::L_MUSIC_STOPPED)
378 .ephemeral(true),
379 )
380 .await?;
381 Ok(())
382}
383
384/// Join your voice channel
385#[poise::command(slash_command)]
386pub async fn join(ctx: Context<'_>) -> Result<()> {
387 ctx.defer().await?;
388 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
389
390 let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| {
391 g.voice_states
392 .get(&ctx.author().id)
393 .and_then(|vs| vs.channel_id)
394 });
395
396 match channel_id {
397 Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await {
398 Ok(_) => {
399 ctx.say(lang::fmt_voice_joined(&cid)).await?;
400 }
401 Err(e) => {
402 ctx.say(lang::fmt_voice_join_failed(&e)).await?;
403 }
404 },
405 None => {
406 ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?;
407 }
408 }
409 Ok(())
410}
411
412pub fn commands() -> Vec<poise::Command<Data, Error>> {
413 vec![grajcuj(), stop(), join()]
414}
diff --git a/src/commands/qr.rs b/src/commands/qr.rs
new file mode 100644
index 0000000..26232ee
--- /dev/null
+++ b/src/commands/qr.rs
@@ -0,0 +1,87 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::error::{Error, Result};
5use crate::lang;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9/// QR group
10#[poise::command(slash_command, subcommands("dekodowanie", "generowanie",))]
11pub async fn qr(_ctx: Context<'_>) -> Result<()> {
12 Ok(())
13}
14
15/// Generate a QR code from text
16#[poise::command(slash_command)]
17pub async fn generowanie(
18 ctx: Context<'_>,
19 #[description = "Zawartość kodu QR"] text: String,
20) -> Result<()> {
21 ctx.defer().await?;
22
23 let code = qrcode::QrCode::new(text.as_bytes())?;
24 let image = code.render::<image::Luma<u8>>().build();
25
26 let scaled = image::imageops::resize(
27 &image,
28 image.width() * 10,
29 image.height() * 10,
30 image::imageops::Nearest,
31 );
32
33 let mut buf = std::io::Cursor::new(Vec::new());
34 scaled.write_to(&mut buf, image::ImageFormat::Png)?;
35
36 ctx.send(
37 poise::CreateReply::default()
38 .content(lang::L_QR_GENERATED)
39 .attachment(serenity::CreateAttachment::bytes(
40 buf.into_inner(),
41 "qrcode.png",
42 )),
43 )
44 .await?;
45 Ok(())
46}
47
48/// Decode a QR code from an uploaded image
49#[poise::command(slash_command)]
50pub async fn dekodowanie(
51 ctx: Context<'_>,
52 #[description = "Obraz zawierający kod QR"] image: serenity::Attachment,
53) -> Result<()> {
54 ctx.defer().await?;
55
56 let img_data = ctx
57 .data()
58 .http_client
59 .get(&image.url)
60 .send()
61 .await?
62 .bytes()
63 .await?;
64 let img = image::load_from_memory(&img_data)?;
65 let gray = img.to_luma8();
66
67 let decoded = decode_qr_simple(&gray);
68 match decoded {
69 Some(text) => {
70 ctx.say(lang::fmt_qr_decoded(&text)).await?;
71 }
72 None => {
73 ctx.say(lang::L_QR_DECODE_FAILED).await?;
74 }
75 }
76 Ok(())
77}
78
79fn decode_qr_simple(img: &image::ImageBuffer<image::Luma<u8>, Vec<u8>>) -> Option<String> {
80 rxing::helpers::detect_in_luma(img.as_raw().to_vec(), img.width(), img.height(), None)
81 .ok()
82 .map(|r| r.getText().to_owned())
83}
84
85pub fn commands() -> Vec<poise::Command<Data, Error>> {
86 vec![qr()]
87}
diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs
new file mode 100644
index 0000000..7453e03
--- /dev/null
+++ b/src/commands/trigger.rs
@@ -0,0 +1,199 @@
1use poise::serenity_prelude as serenity;
2
3use super::autocomplete::trigger_name;
4use crate::Data;
5use crate::database;
6use crate::error::{Error, Result};
7use crate::lang;
8
9type Context<'a> = poise::Context<'a, Data, Error>;
10
11/// Trigger keyword management
12#[poise::command(
13 slash_command,
14 subcommands("create", "add", "remove", "delete", "list", "help",)
15)]
16pub async fn trigger(_ctx: Context<'_>) -> Result<()> {
17 Ok(())
18}
19
20/// Create a new trigger group
21#[poise::command(slash_command)]
22pub async fn create(
23 ctx: Context<'_>,
24 #[description = "Logical group name"]
25 #[autocomplete = "trigger_name"]
26 name: String,
27) -> Result<()> {
28 match database::create_trigger(&ctx.data().db_pool, &name).await {
29 Ok(_) => {
30 ctx.data().reload_triggers().await;
31 ctx.say(lang::fmt_trigger_created(&name)).await?;
32 }
33 Err(e) => {
34 ctx.say(lang::fmt_trigger_failed(&e)).await?;
35 }
36 }
37 Ok(())
38}
39
40/// Add keywords and/or responses to a trigger group
41#[poise::command(slash_command)]
42pub async fn add(
43 ctx: Context<'_>,
44 #[description = "Existing group name"]
45 #[autocomplete = "trigger_name"]
46 name: String,
47 #[description = "Keywords separated by \\"] trigger_keywords: Option<String>,
48 #[description = "Responses separated by \\"] response: Option<String>,
49) -> Result<()> {
50 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
51 .await?
52 .ok_or_else(|| lang::err_trigger_not_found())?;
53
54 let mut modified = false;
55 if let Some(kw) = trigger_keywords {
56 for keyword in kw.split('\\') {
57 let k = keyword.trim();
58 if !k.is_empty() {
59 database::add_keyword(&ctx.data().db_pool, t.id, k).await?;
60 modified = true;
61 }
62 }
63 }
64 if let Some(r) = response {
65 for resp in r.split('\\') {
66 let r = resp.trim();
67 if !r.is_empty() {
68 database::add_response(&ctx.data().db_pool, t.id, r).await?;
69 modified = true;
70 }
71 }
72 }
73
74 if modified {
75 ctx.data().reload_triggers().await;
76 }
77 ctx.say(lang::L_TRIGGER_UPDATED).await?;
78 Ok(())
79}
80
81/// Remove keyword or response from a group
82#[poise::command(slash_command)]
83pub async fn remove(
84 ctx: Context<'_>,
85 #[description = "Group name"]
86 #[autocomplete = "trigger_name"]
87 name: String,
88 #[description = "Keyword to remove"] trigger_keyword: Option<String>,
89 #[description = "Response to remove"] response: Option<String>,
90) -> Result<()> {
91 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
92 .await?
93 .ok_or_else(|| lang::err_trigger_not_found())?;
94
95 let mut modified = false;
96 if let Some(kw) = trigger_keyword {
97 database::remove_keyword(&ctx.data().db_pool, t.id, &kw).await?;
98 modified = true;
99 }
100 if let Some(r) = response {
101 database::remove_response(&ctx.data().db_pool, t.id, &r).await?;
102 modified = true;
103 }
104
105 if modified {
106 ctx.data().reload_triggers().await;
107 }
108 ctx.say(lang::L_TRIGGER_UPDATED).await?;
109 Ok(())
110}
111
112/// Delete an entire trigger group
113#[poise::command(slash_command)]
114pub async fn delete(
115 ctx: Context<'_>,
116 #[description = "Group name"]
117 #[autocomplete = "trigger_name"]
118 name: String,
119) -> Result<()> {
120 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
121 .await?
122 .ok_or_else(|| lang::err_trigger_not_found())?;
123
124 database::delete_trigger(&ctx.data().db_pool, t.id).await?;
125 ctx.data().reload_triggers().await;
126 ctx.say(lang::fmt_trigger_deleted(&name)).await?;
127 Ok(())
128}
129
130/// List keywords and responses for a group
131#[poise::command(slash_command)]
132pub async fn list(
133 ctx: Context<'_>,
134 #[description = "Group name"]
135 #[autocomplete = "trigger_name"]
136 name: String,
137) -> Result<()> {
138 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
139 .await?
140 .ok_or_else(|| lang::err_trigger_not_found())?;
141
142 let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?;
143 let responses = database::get_responses(&ctx.data().db_pool, t.id).await?;
144
145 let mut embed = serenity::CreateEmbed::default()
146 .title(lang::fmt_trigger_title(&name))
147 .color(0x00aaff);
148
149 embed = embed.field(
150 "Keywords",
151 if keywords.is_empty() {
152 lang::L_TRIGGER_KW_NONE.into()
153 } else {
154 keywords
155 .iter()
156 .map(|r| format!("{r}"))
157 .collect::<Vec<_>>()
158 .join("\n")
159 },
160 false,
161 );
162 embed = embed.field(
163 "Responses",
164 if responses.is_empty() {
165 lang::L_TRIGGER_KW_NONE.into()
166 } else {
167 responses
168 .iter()
169 .map(|r| format!("{r}"))
170 .collect::<Vec<_>>()
171 .join("\n")
172 },
173 false,
174 );
175
176 ctx.send(poise::CreateReply::default().embed(embed)).await?;
177 Ok(())
178}
179
180/// Display trigger command usage info
181#[poise::command(slash_command)]
182pub async fn help(ctx: Context<'_>) -> Result<()> {
183 let help = [
184 "**Trigger Commands**",
185 "`/trigger create <name>` — Create new trigger group",
186 "`/trigger add <name> <trigger> <response>` — Add keywords/responses (separate with \\)",
187 "`/trigger remove <name> <trigger> <response>` — Remove keywords/responses",
188 "`/trigger delete <name>` — Delete entire group",
189 "`/trigger list <name>` — View group details",
190 ]
191 .join("\n");
192
193 ctx.say(help).await?;
194 Ok(())
195}
196
197pub fn commands() -> Vec<poise::Command<Data, Error>> {
198 vec![trigger()]
199}
diff --git a/src/commands/tts.rs b/src/commands/tts.rs
new file mode 100644
index 0000000..ee13596
--- /dev/null
+++ b/src/commands/tts.rs
@@ -0,0 +1,106 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::error::{Error, Result};
5use crate::lang;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9#[derive(poise::ChoiceParameter)]
10pub enum OutputMode {
11 #[name = "File"]
12 File,
13 #[name = "Voice Channel"]
14 Vc,
15}
16
17/// Generate Google TTS (Polish) and output to file or voice channel
18#[poise::command(slash_command)]
19pub async fn tts(
20 ctx: Context<'_>,
21 #[description = "Text to synthesize"] text: String,
22 #[description = "Output destination (file or vc)"] output: Option<OutputMode>,
23) -> Result<()> {
24 ctx.defer().await?;
25 let lang = "pl";
26 let tts_url = format!(
27 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob",
28 urlencoding(&text),
29 lang
30 );
31
32 let output = output.unwrap_or(OutputMode::Vc);
33
34 let audio_data = ctx
35 .data()
36 .http_client
37 .get(&tts_url)
38 .send()
39 .await?
40 .bytes()
41 .await?;
42
43 match output {
44 OutputMode::File => {
45 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
46 .await
47 .ok();
48 let path = format!(
49 "{}/tts_{}.mp3",
50 crate::config::TEMP_PATH,
51 uuid::Uuid::new_v4()
52 );
53 tokio::fs::write(&path, &audio_data).await?;
54 ctx.send(
55 poise::CreateReply::default()
56 .content(lang::L_TTS_SAVED)
57 .attachment(serenity::CreateAttachment::path(&path).await.unwrap()),
58 )
59 .await?;
60 }
61 OutputMode::Vc => {
62 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
63 let channel_id = ctx
64 .serenity_context()
65 .cache
66 .guild(guild_id)
67 .and_then(|g| {
68 g.voice_states
69 .get(&ctx.author().id)
70 .and_then(|vs| vs.channel_id)
71 })
72 .ok_or_else(|| lang::err_not_in_voice())?;
73
74 let call = ctx
75 .data()
76 .voice_manager
77 .connect(guild_id, channel_id)
78 .await?;
79 let input = crate::voice::decode_audio(&audio_data)?;
80 let mut call_lock = call.lock().await;
81 if call_lock.current_channel().is_some() {
82 call_lock.play_input(input);
83 }
84 ctx.say(lang::L_TTS_PLAYING).await?;
85 }
86 }
87 Ok(())
88}
89
90pub fn urlencoding(s: &str) -> String {
91 let mut out = String::with_capacity(s.len());
92 for &byte in s.as_bytes() {
93 match byte {
94 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => {
95 out.push(byte as char)
96 }
97 b' ' => out.push('+'),
98 _ => out.push_str(&format!("%{:02X}", byte)),
99 }
100 }
101 out
102}
103
104pub fn commands() -> Vec<poise::Command<Data, Error>> {
105 vec![tts()]
106}
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
new file mode 100644
index 0000000..10ce403
--- /dev/null
+++ b/src/commands/utilities.rs
@@ -0,0 +1,918 @@
1use poise::serenity_prelude as serenity;
2use tracing::warn;
3
4use super::autocomplete::any_file;
5use super::music::sanitize_yt_url;
6use crate::Data;
7use crate::copyparty::{html_decode, percent_decode};
8use crate::error::{Error, Result};
9use crate::lang;
10
11type Context<'a> = poise::Context<'a, Data, Error>;
12
13// Above this size (or when `serwer` is forced) the result is uploaded to
14// copyparty instead of being attached to the Discord reply. Discord's own
15// attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python
16// original and keeps replies snappy.
17const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024;
18
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum FileType {
21 Video,
22 Audio,
23 Image,
24 Unknown,
25}
26
27/// Classify a file by its extension.
28///
29/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without
30/// pulling in a mime crate: the set of extensions below covers everything the
31/// original bot handled.
32fn file_type(filename: &str) -> FileType {
33 let Some((_, ext)) = filename.rsplit_once('.') else {
34 return FileType::Unknown;
35 };
36 if ext.is_empty() || ext.contains('/') {
37 return FileType::Unknown;
38 }
39 match ext.to_ascii_lowercase().as_str() {
40 "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp"
41 | "ts" => FileType::Video,
42 "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio,
43 "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => {
44 FileType::Image
45 }
46 _ => FileType::Unknown,
47 }
48}
49
50/// Lowercased extension without the leading dot, or `None` if there is none.
51fn ext_of(filename: &str) -> Option<String> {
52 let (_, ext) = filename.rsplit_once('.')?;
53 if ext.is_empty() || ext.contains('/') {
54 None
55 } else {
56 Some(ext.to_ascii_lowercase())
57 }
58}
59
60/// Filename stem: everything before the final dot of the basename.
61fn stem_of(filename: &str) -> &str {
62 let base = filename.rsplit('/').next().unwrap_or(filename);
63 match base.rsplit_once('.') {
64 Some((stem, _)) if !stem.is_empty() => stem,
65 _ => base,
66 }
67}
68
69fn temp_path_for(name: &str) -> String {
70 format!("{}/{}", crate::config::TEMP_PATH, name)
71}
72
73/// Best-effort removal of temp files; ignores missing files.
74async fn cleanup(paths: &[&str]) {
75 for p in paths {
76 let _ = tokio::fs::remove_file(p).await;
77 }
78}
79
80/// Resolve the input source — either an autocomplete-picked copyparty file or
81/// a Discord attachment — downloading the bytes and returning them alongside
82/// the original filename.
83///
84/// Replies with an appropriate user-facing message and returns `Ok(None)` when
85/// no valid source was supplied, so callers can early-return without sending a
86/// second message.
87async fn resolve_input(
88 ctx: &Context<'_>,
89 plik: Option<&str>,
90 zalacznik: Option<&serenity::Attachment>,
91) -> Result<Option<(Vec<u8>, String)>> {
92 if let Some(name) = plik {
93 // Autocomplete-selected copyparty file: the choice value is the decoded
94 // filename, stashed into the per-user ac_cache by `any_file`.
95 let hit = match ctx.data().ac_lookup(ctx.author().id, name).await {
96 Some(h) => h,
97 None => {
98 ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?;
99 return Ok(None);
100 }
101 };
102 let path = hit.url_path().unwrap_or("");
103 let path = if path.starts_with('/') {
104 path.to_string()
105 } else {
106 format!("/{path}")
107 };
108 let full_url = ctx.data().copyparty.build_url(&path);
109 let resp = ctx.data().http_client.get(&full_url).send().await?;
110 if !resp.status().is_success() {
111 ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?;
112 return Ok(None);
113 }
114 let bytes = resp.bytes().await?.to_vec();
115 return Ok(Some((bytes, name.to_string())));
116 }
117
118 if let Some(att) = zalacznik {
119 let resp = ctx.data().http_client.get(&att.url).send().await?;
120 if !resp.status().is_success() {
121 ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?;
122 return Ok(None);
123 }
124 let bytes = resp.bytes().await?.to_vec();
125 return Ok(Some((bytes, att.filename.clone())));
126 }
127
128 ctx.say(lang::L_UTIL_NEED_INPUT).await?;
129 Ok(None)
130}
131
132/// Output extension the compressor produces for a given input.
133///
134/// - Video keeps its container extension (ffmpeg re-muxes in place).
135/// - Audio is always re-encoded to Opus in an `.ogg` container.
136/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`.
137fn compress_output_ext(ft: FileType, original: &str) -> String {
138 let orig = ext_of(original);
139 match ft {
140 FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()),
141 FileType::Audio => "ogg".to_string(),
142 FileType::Image => match orig.as_deref() {
143 Some("jpg" | "jpeg" | "gif") => orig.unwrap(),
144 _ => "jpg".to_string(),
145 },
146 FileType::Unknown => String::new(),
147 }
148}
149
150/// Build the ffmpeg/magick command for extreme-low-quality compression.
151/// Returns `None` for unsupported (`Unknown`) file types.
152fn build_compress_command(
153 ft: FileType,
154 input: &str,
155 output: &str,
156) -> Option<tokio::process::Command> {
157 match ft {
158 FileType::Video => {
159 let mut c = tokio::process::Command::new("ffmpeg");
160 c.arg("-y")
161 .arg("-i")
162 .arg(input)
163 .arg("-c:v")
164 .arg("libx264")
165 .arg("-crf")
166 .arg("51")
167 .arg("-b:v")
168 .arg("100")
169 .arg("-vf")
170 .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2")
171 .arg("-preset")
172 .arg("ultrafast")
173 .arg("-c:a")
174 .arg("libopus")
175 .arg("-b:a")
176 .arg("500")
177 .arg(output);
178 Some(c)
179 }
180 FileType::Audio => {
181 let mut c = tokio::process::Command::new("ffmpeg");
182 c.arg("-y")
183 .arg("-i")
184 .arg(input)
185 .arg("-c:a")
186 .arg("libopus")
187 .arg("-b:a")
188 .arg("500")
189 .arg("-ar")
190 .arg("12000")
191 .arg("-ac")
192 .arg("1")
193 .arg(output);
194 Some(c)
195 }
196 FileType::Image => {
197 let mut c = tokio::process::Command::new("magick");
198 c.arg(input);
199 match ext_of(input).as_deref() {
200 Some("gif") => {
201 c.arg("-coalesce")
202 .arg("-resize")
203 .arg("30%")
204 .arg("-colors")
205 .arg("32")
206 .arg("-strip")
207 .arg("-layers")
208 .arg("Optimize");
209 }
210 // jpg/jpeg stay jpg; other images are rewritten to .jpg by
211 // `compress_output_ext` and magick infers the encoder from the
212 // output extension.
213 _ => {
214 c.arg("-strip")
215 .arg("-resize")
216 .arg("30%")
217 .arg("-quality")
218 .arg("5");
219 }
220 }
221 c.arg(output);
222 Some(c)
223 }
224 FileType::Unknown => None,
225 }
226}
227
228enum DeliverKind {
229 Compress,
230 Convert,
231}
232
233/// Compress a single in-memory blob (an attachment body) into a compressed
234/// file, returning the encoded bytes and the output filename.
235///
236/// Mirrors the per-attachment path of Python's `Compression` message command,
237/// including sequential `TEMP_PATH` reuse so identical attachment stems don't
238/// collide (each iteration writes, encodes, then `cleanup`s before the next).
239async fn compress_attachment_bytes(
240 ft: FileType,
241 original: &str,
242 input_bytes: &[u8],
243) -> Option<(Vec<u8>, String)> {
244 let stem = stem_of(original).to_string();
245 let out_ext = compress_output_ext(ft, original);
246 let out_filename = format!("compressed_{stem}.{out_ext}");
247 let input_path = temp_path_for(original);
248 let output_path = temp_path_for(&out_filename);
249 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
250
251 if tokio::fs::write(&input_path, input_bytes).await.is_err() {
252 return None;
253 }
254 let mut cmd = match build_compress_command(ft, &input_path, &output_path) {
255 Some(c) => c,
256 None => {
257 cleanup(&cleanup_paths).await;
258 return None;
259 }
260 };
261 let res = match cmd.output().await {
262 Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(),
263 _ => None,
264 };
265 cleanup(&cleanup_paths).await;
266 res.map(|b| (b, out_filename))
267}
268
269/// Strip characters that Discord/coping filenames can't safely carry. Mirrors
270/// Python's `Downloader._sanitize_filename`.
271fn sanitize_filename(name: &str) -> String {
272 const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
273 name.chars()
274 .filter(|c| !BAD.contains(c))
275 .collect::<String>()
276 .trim()
277 .to_string()
278}
279
280/// Extract `https://` URLs containing a path (host + `/...`) from a message.
281/// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the
282/// message-context `download` command — only URLs with at least one `/` after
283/// the host are matched, and `http://` links are intentionally ignored.
284fn extract_urls(content: &str) -> Vec<String> {
285 let bytes = content.as_bytes();
286 let mut urls = Vec::new();
287 let mut i = 0;
288 while let Some(pos) = content[i..].find("https://") {
289 let start = i + pos;
290 let after = start + "https://".len();
291
292 // Consume the host (until `/` or whitespace).
293 let mut j = after;
294 while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' {
295 j += 1;
296 }
297
298 if j < bytes.len() && bytes[j] == b'/' {
299 // Consume the rest of the URL (until whitespace).
300 let mut k = j;
301 while k < bytes.len() && !bytes[k].is_ascii_whitespace() {
302 k += 1;
303 }
304 urls.push(content[start..k].to_string());
305 i = k;
306 } else {
307 // No path component — not matched by the regex; advance past it.
308 i = after;
309 }
310 }
311 urls
312}
313
314/// Locate the single file yt-dlp produced in `dir`, picking the most recently
315/// modified one (matches Python's `os.listdir`-first fallback behaviour while
316/// being deterministic enough for the per-call unique subdir).
317async fn find_downloaded_file(dir: &str) -> Option<(String, String)> {
318 let mut rd = tokio::fs::read_dir(dir).await.ok()?;
319 let mut newest: Option<(std::time::SystemTime, String)> = None;
320 while let Ok(Some(entry)) = rd.next_entry().await {
321 let meta = match entry.metadata().await {
322 Ok(m) => m,
323 Err(_) => continue,
324 };
325 if !meta.is_file() {
326 continue;
327 }
328 let mtime = match meta.modified() {
329 Ok(t) => t,
330 Err(_) => continue,
331 };
332 if let Some((t, _)) = &newest {
333 if *t >= mtime {
334 continue;
335 }
336 }
337 newest = Some((mtime, entry.path().to_string_lossy().into_owned()));
338 }
339 newest.map(|(_, p)| {
340 let fname = std::path::Path::new(&p)
341 .file_name()
342 .map(|s| s.to_string_lossy().into_owned())
343 .unwrap_or_default();
344 (p, fname)
345 })
346}
347
348/// Hand the produced download back to the user: attach when below the size
349/// threshold (and upload isn't forced), otherwise push to copyparty and reply
350/// with the download URL. Mirrors Python's `Downloader` slash `/download`.
351async fn deliver_download(
352 ctx: &Context<'_>,
353 bytes: Vec<u8>,
354 filename: String,
355 catbox: bool,
356) -> Result<()> {
357 if bytes.len() > LARGE_FILE_THRESHOLD || catbox {
358 match ctx.data().copyparty.upload(&filename, bytes).await {
359 Ok(url) => {
360 ctx.say(lang::fmt_download_uploaded(&url)).await?;
361 }
362 Err(e) => {
363 tracing::warn!("copyparty upload failed: {e}");
364 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
365 }
366 }
367 } else {
368 ctx.send(
369 poise::CreateReply::default()
370 .attachment(serenity::CreateAttachment::bytes(bytes, filename)),
371 )
372 .await?;
373 }
374 Ok(())
375}
376
377/// Run yt-dlp on `url` and deliver the resulting file. The caller is expected
378/// to have already called `ctx.defer()`. Each invocation uses a fresh unique
379/// subdirectory under `TEMP_PATH` (matching Python's per-call
380/// `tempfile.TemporaryDirectory`) so concurrent downloads never collide.
381async fn run_download(
382 ctx: &Context<'_>,
383 url: &str,
384 catbox: bool,
385 fformat: Option<&str>,
386 filename_user: Option<&str>,
387) -> Result<()> {
388 let fmt = fformat.unwrap_or("mp4");
389 let is_audio = matches!(fmt, "mp3" | "wav" | "m4a");
390 let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4());
391 tokio::fs::create_dir_all(&dl_dir).await.ok();
392
393 let out_template = match filename_user {
394 Some(name) => {
395 // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext.
396 let clean: String = sanitize_filename(name).chars().take(100).collect();
397 format!("{dl_dir}/{clean}.%(ext)s")
398 }
399 None => format!("{dl_dir}/%(title).100s.%(ext)s"),
400 };
401
402 let mut cmd = tokio::process::Command::new("yt-dlp");
403 cmd.arg("-f")
404 .arg("bestaudio/bestvideo+bestaudio")
405 .arg("--merge-output-format")
406 .arg("mp4")
407 .arg("-o")
408 .arg(&out_template);
409 if is_audio {
410 cmd.arg("--extract-audio").arg("--audio-format").arg(fmt);
411 }
412 cmd.arg(sanitize_yt_url(url));
413
414 let output = cmd
415 .output()
416 .await
417 .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?;
418 if !output.status.success() {
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 // Keep the full yt-dlp trace in the logs; the user only sees the
421 // short `fmt_download_failed` (translated) message.
422 warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end());
423 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
424 ctx.say(lang::fmt_download_failed(&stderr)).await?;
425 return Ok(());
426 }
427
428 let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else {
429 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
430 ctx.say(lang::fmt_download_failed("no file produced"))
431 .await?;
432 return Ok(());
433 };
434
435 // Determine the display filename for the upload/attachment.
436 let final_name = match filename_user {
437 Some(name) => {
438 let clean: String = sanitize_filename(name).chars().take(100).collect();
439 let ext = std::path::Path::new(&basename)
440 .extension()
441 .map(|e| format!(".{}", e.to_string_lossy()))
442 .unwrap_or_default();
443 format!("{clean}{ext}")
444 }
445 None => basename,
446 };
447
448 let bytes = match tokio::fs::read(&actual_path).await {
449 Ok(b) => b,
450 Err(e) => {
451 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
452 ctx.say(lang::fmt_download_failed(&e.to_string())).await?;
453 return Ok(());
454 }
455 };
456
457 let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await;
458 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
459 deliver_result
460}
461
462/// Hand the produced bytes back to the user.
463///
464/// Below the size threshold (and when upload isn't forced) the result is
465/// attached directly to the reply. Otherwise it's pushed to copyparty and the
466/// reply contains the download URL. Upload failures are reported gracefully
467/// rather than propagated, matching the Python original.
468async fn deliver(
469 ctx: &Context<'_>,
470 bytes: Vec<u8>,
471 filename: String,
472 force_upload: bool,
473 kind: DeliverKind,
474) -> Result<()> {
475 if bytes.len() > LARGE_FILE_THRESHOLD || force_upload {
476 match ctx.data().copyparty.upload(&filename, bytes).await {
477 Ok(url) => {
478 let msg = match kind {
479 DeliverKind::Compress => lang::fmt_util_compressed(&url),
480 DeliverKind::Convert => lang::fmt_util_converted(&url),
481 };
482 ctx.say(msg).await?;
483 }
484 Err(e) => {
485 tracing::warn!("copyparty upload failed: {e}");
486 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
487 }
488 }
489 } else {
490 ctx.send(
491 poise::CreateReply::default()
492 .attachment(serenity::CreateAttachment::bytes(bytes, filename)),
493 )
494 .await?;
495 }
496 Ok(())
497}
498
499/// Compress media to extreme low quality
500#[poise::command(slash_command, aliases("kompresja"))]
501pub async fn compress(
502 ctx: Context<'_>,
503 #[description = "File from Copyparty"]
504 #[autocomplete = "any_file"]
505 plik: Option<String>,
506 #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>,
507 #[description = "Force upload to Copyparty"] serwer: Option<bool>,
508) -> Result<()> {
509 ctx.defer().await?;
510 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
511 .await
512 .ok();
513
514 let (input_bytes, original_filename) =
515 match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? {
516 Some(v) => v,
517 None => return Ok(()),
518 };
519
520 let ft = file_type(&original_filename);
521 let stem = stem_of(&original_filename).to_string();
522 let out_ext = compress_output_ext(ft, &original_filename);
523 let out_filename = format!("compressed_{stem}.{out_ext}");
524 let input_path = temp_path_for(&original_filename);
525 let output_path = temp_path_for(&out_filename);
526 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
527
528 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
529 tracing::warn!("compress: failed to write input: {e}");
530 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
531 return Ok(());
532 }
533
534 let result = match build_compress_command(ft, &input_path, &output_path) {
535 Some(mut cmd) => cmd.output().await,
536 None => {
537 cleanup(&cleanup_paths).await;
538 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
539 return Ok(());
540 }
541 };
542
543 match result {
544 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
545 Ok(out_bytes) => {
546 cleanup(&cleanup_paths).await;
547 deliver(
548 &ctx,
549 out_bytes,
550 out_filename,
551 serwer.unwrap_or(false),
552 DeliverKind::Compress,
553 )
554 .await?;
555 }
556 Err(e) => {
557 tracing::warn!("compress: output missing after encode: {e}");
558 cleanup(&cleanup_paths).await;
559 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
560 }
561 },
562 _ => {
563 cleanup(&cleanup_paths).await;
564 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
565 }
566 }
567 Ok(())
568}
569
570/// Convert media between formats
571#[poise::command(slash_command, aliases("konwersja"))]
572pub async fn convert(
573 ctx: Context<'_>,
574 #[description = "File from Copyparty"]
575 #[autocomplete = "any_file"]
576 plik: Option<String>,
577 #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>,
578 #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String,
579 #[description = "Force upload to Copyparty"] serwer: Option<bool>,
580) -> Result<()> {
581 ctx.defer().await?;
582 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
583 .await
584 .ok();
585
586 let (input_bytes, original_filename) =
587 match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? {
588 Some(v) => v,
589 None => return Ok(()),
590 };
591
592 // Normalise the requested extension: lowercase, no leading dot, and reject
593 // anything containing path separators or extra dots — this keeps it a
594 // single clean extension component and rules out escaping TEMP_PATH.
595 let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase();
596 if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') {
597 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
598 return Ok(());
599 }
600
601 let ft = file_type(&original_filename);
602 let stem = stem_of(&original_filename).to_string();
603 let out_filename = format!("{stem}.{fmt}");
604 let input_path = temp_path_for(&original_filename);
605 let output_path = temp_path_for(&out_filename);
606 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
607
608 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
609 tracing::warn!("convert: failed to write input: {e}");
610 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
611 return Ok(());
612 }
613
614 let mut cmd = match ft {
615 FileType::Video | FileType::Audio => {
616 let mut c = tokio::process::Command::new("ffmpeg");
617 c.arg("-y").arg("-i").arg(&input_path).arg(&output_path);
618 c
619 }
620 FileType::Image => {
621 let mut c = tokio::process::Command::new("magick");
622 c.arg(&input_path).arg(&output_path);
623 c
624 }
625 FileType::Unknown => {
626 cleanup(&cleanup_paths).await;
627 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
628 return Ok(());
629 }
630 };
631
632 match cmd.output().await {
633 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
634 Ok(out_bytes) => {
635 cleanup(&cleanup_paths).await;
636 deliver(
637 &ctx,
638 out_bytes,
639 out_filename,
640 serwer.unwrap_or(false),
641 DeliverKind::Convert,
642 )
643 .await?;
644 }
645 Err(e) => {
646 tracing::warn!("convert: output missing after encode: {e}");
647 cleanup(&cleanup_paths).await;
648 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
649 }
650 },
651 Ok(out) => {
652 let stderr = String::from_utf8_lossy(&out.stderr);
653 cleanup(&cleanup_paths).await;
654 ctx.say(lang::fmt_util_convert_err(&stderr)).await?;
655 }
656 Err(e) => {
657 cleanup(&cleanup_paths).await;
658 ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?;
659 }
660 }
661 Ok(())
662}
663
664/// Upload files from Discord to Copyparty
665#[poise::command(slash_command)]
666pub async fn upload(
667 ctx: Context<'_>,
668 #[description = "File to upload"] zalacznik: serenity::Attachment,
669) -> Result<()> {
670 ctx.defer().await?;
671
672 let data = ctx
673 .data()
674 .http_client
675 .get(&zalacznik.url)
676 .send()
677 .await?
678 .bytes()
679 .await?;
680 let file_url = ctx
681 .data()
682 .copyparty
683 .upload(&zalacznik.filename, data.to_vec())
684 .await?;
685 ctx.say(lang::fmt_uploaded(&file_url)).await?;
686 Ok(())
687}
688
689/// Search Copyparty file server and return a direct URL
690#[poise::command(slash_command)]
691pub async fn search(
692 ctx: Context<'_>,
693 #[description = "Partial filename to search"]
694 #[autocomplete = "any_file"]
695 filename: String,
696) -> Result<()> {
697 // Autocomplete cache: per-user (UserId, filename) → SearchHit.
698 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await {
699 let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename)));
700 let path = hit.url_path().unwrap_or("");
701 let path = if path.starts_with('/') {
702 path.to_string()
703 } else {
704 format!("/{path}")
705 };
706 let full_url = format!("{}{}", ctx.data().config.copyparty_url, path);
707 ctx.say(lang::fmt_search_result(&name, &full_url)).await?;
708 return Ok(());
709 }
710
711 // Direct copyparty path from manual input (starts with /)
712 if filename.starts_with('/') {
713 let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename);
714 ctx.say(lang::fmt_search_result(&filename, &full_url))
715 .await?;
716 return Ok(());
717 }
718
719 let results = ctx.data().copyparty.search(&filename, 10, &[]).await?;
720
721 if results.is_empty() {
722 ctx.say(lang::L_SEARCH_NO_RESULTS).await?;
723 return Ok(());
724 }
725
726 let hit = &results[0];
727 let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown")));
728 let path = hit.url_path().unwrap_or("");
729 let path = if path.starts_with('/') {
730 path.to_string()
731 } else {
732 format!("/{path}")
733 };
734 let full_url = format!("{}{}", ctx.data().config.copyparty_url, path);
735
736 ctx.say(lang::fmt_search_result(&name, &full_url)).await?;
737 Ok(())
738}
739
740/// Download media from any yt-dlp-supported URL
741#[poise::command(slash_command)]
742pub async fn download(
743 ctx: Context<'_>,
744 #[description = "Media URL"] url: String,
745 #[description = "Force upload to Copyparty"] catbox: Option<bool>,
746 #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option<String>,
747 #[description = "Custom output filename"] filename_user: Option<String>,
748) -> Result<()> {
749 ctx.defer().await?;
750 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
751 .await
752 .ok();
753 run_download(
754 &ctx,
755 &url,
756 catbox.unwrap_or(false),
757 fformat.as_deref(),
758 filename_user.as_deref(),
759 )
760 .await
761}
762
763/// Right-click a message → upload its attachments to Copyparty
764/// (mirrors Python's `@commands.message_command(name="upload")`).
765///
766/// Display name is not `"upload"` because poise's dispatcher matches commands
767/// by name alone without considering `CommandType`, so a context-menu named
768/// `"upload"` would shadow — and be shadowed by — the slash `/upload` command
769/// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"`
770/// avoids the collision while keeping the Polish user-facing label.
771#[poise::command(context_menu_command = "Wyślij załączniki")]
772pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
773 if message.attachments.is_empty() {
774 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
775 return Ok(());
776 }
777 ctx.defer().await?;
778
779 let mut urls: Vec<String> = Vec::new();
780 let mut failed = 0usize;
781 for att in &message.attachments {
782 let resp = match ctx.data().http_client.get(&att.url).send().await {
783 Ok(r) if r.status().is_success() => r,
784 _ => {
785 failed += 1;
786 continue;
787 }
788 };
789 let bytes = resp.bytes().await?.to_vec();
790 match ctx.data().copyparty.upload(&att.filename, bytes).await {
791 Ok(url) => urls.push(url),
792 Err(e) => {
793 tracing::warn!("upload: copyparty failed for {}: {e}", att.filename);
794 failed += 1;
795 }
796 }
797 }
798
799 if urls.is_empty() {
800 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
801 return Ok(());
802 }
803
804 let mut resp = lang::fmt_upload_summary(urls.len());
805 for u in &urls {
806 resp.push_str(u);
807 resp.push('\n');
808 }
809 if failed > 0 {
810 resp.push_str(&lang::fmt_upload_partial_failed(failed));
811 }
812 ctx.say(resp).await?;
813 Ok(())
814}
815
816/// Right-click a message → compress all of its attachments
817/// (mirrors Python's `@commands.message_command(name="kompresja")`).
818#[poise::command(context_menu_command = "kompresja")]
819pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
820 if message.attachments.is_empty() {
821 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
822 return Ok(());
823 }
824 ctx.defer().await?;
825 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
826 .await
827 .ok();
828
829 let total = message.attachments.len();
830 let mut attachments_out: Vec<serenity::CreateAttachment> = Vec::new();
831 let mut made = 0usize;
832
833 for att in &message.attachments {
834 let resp = match ctx.data().http_client.get(&att.url).send().await {
835 Ok(r) if r.status().is_success() => r,
836 _ => continue,
837 };
838 let bytes = match resp.bytes().await {
839 Ok(b) => b.to_vec(),
840 Err(_) => continue,
841 };
842 let ft = file_type(&att.filename);
843 if let Some((out_bytes, out_name)) =
844 compress_attachment_bytes(ft, &att.filename, &bytes).await
845 {
846 attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name));
847 made += 1;
848 }
849 }
850
851 if attachments_out.is_empty() {
852 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
853 return Ok(());
854 }
855
856 let mut reply =
857 poise::CreateReply::default().content(lang::fmt_message_compressed(made, total));
858 for a in attachments_out {
859 reply = reply.attachment(a);
860 }
861 ctx.send(reply).await?;
862 Ok(())
863}
864
865/// Right-click a message → download every `https://` URL it contains
866/// (mirrors Python's `@commands.message_command(name="download")`).
867///
868/// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with
869/// the slash `/download` command in poise's name-only dispatcher (see the note
870/// on `message_upload`).
871#[poise::command(context_menu_command = "Pobierz z linku")]
872pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
873 let urls = extract_urls(&message.content);
874 if urls.is_empty() {
875 // Faithful to the Python original, which reuses the attachments-missing
876 // string for the no-URL case.
877 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
878 return Ok(());
879 }
880 ctx.defer().await?;
881 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
882 .await
883 .ok();
884
885 for url in urls {
886 if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await {
887 tracing::warn!("download runner error for {url}: {e}");
888 }
889 }
890 Ok(())
891}
892
893/// Echo text to channel
894#[poise::command(slash_command)]
895pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> {
896 ctx.say(&text).await?;
897 ctx.send(
898 poise::CreateReply::default()
899 .content("\u{200b}")
900 .ephemeral(true),
901 )
902 .await?;
903 Ok(())
904}
905
906pub fn commands() -> Vec<poise::Command<Data, Error>> {
907 vec![
908 compress(),
909 convert(),
910 upload(),
911 search(),
912 download(),
913 say(),
914 message_upload(),
915 message_compression(),
916 message_download(),
917 ]
918}
diff --git a/src/commands/web.rs b/src/commands/web.rs
new file mode 100644
index 0000000..d68f663
--- /dev/null
+++ b/src/commands/web.rs
@@ -0,0 +1,122 @@
1use poise::serenity_prelude as serenity;
2use scraper::{Html, Selector};
3
4use crate::Data;
5use crate::error::{Error, Result};
6use crate::lang;
7
8type Context<'a> = poise::Context<'a, Data, Error>;
9
10/// Search the web via DuckDuckGo
11#[poise::command(slash_command)]
12pub async fn web(ctx: Context<'_>, #[description = "Search query"] query: String) -> Result<()> {
13 ctx.defer().await?;
14
15 let url = format!(
16 "https://html.duckduckgo.com/html/?q={}",
17 urlencoding(&query)
18 );
19 let html = ctx
20 .data()
21 .http_client
22 .get(&url)
23 .send()
24 .await?
25 .text()
26 .await?;
27
28 let results = {
29 let document = Html::parse_document(&html);
30
31 let result_selector = Selector::parse(".result").unwrap();
32 let title_selector = Selector::parse(".result__title a").unwrap();
33 let snippet_selector = Selector::parse(".result__snippet").unwrap();
34
35 let mut results = Vec::new();
36 for result in document.select(&result_selector).take(5) {
37 let title = result
38 .select(&title_selector)
39 .next()
40 .map(|a| a.text().collect::<String>())
41 .unwrap_or_default();
42
43 let url = result
44 .select(&title_selector)
45 .next()
46 .and_then(|a| a.attr("href"))
47 .unwrap_or("")
48 .to_string();
49
50 let snippet = result
51 .select(&snippet_selector)
52 .next()
53 .map(|s| s.text().collect::<String>())
54 .unwrap_or_default();
55
56 let clean_url = if let Some(encoded) = url.split("uddg=").nth(1) {
57 let encoded = encoded.split('&').next().unwrap_or(encoded);
58 url_decode(encoded)
59 } else {
60 url.to_string()
61 };
62
63 results.push((title, clean_url, snippet));
64 }
65 results
66 };
67
68 if results.is_empty() {
69 ctx.say(lang::L_WEB_NO_RESULTS).await?;
70 return Ok(());
71 }
72
73 let mut embed = serenity::CreateEmbed::default()
74 .title(lang::fmt_web_title(&query))
75 .color(0x00aaff);
76
77 for (i, (title, url, snippet)) in results.iter().enumerate() {
78 embed = embed.field(
79 format!("{}. {title}", i + 1),
80 format!("{url}\n{snippet}"),
81 false,
82 );
83 }
84
85 ctx.send(poise::CreateReply::default().embed(embed)).await?;
86 Ok(())
87}
88
89fn url_decode(s: &str) -> String {
90 let mut result = String::new();
91 let mut chars = s.chars();
92 while let Some(c) = chars.next() {
93 match c {
94 '+' => result.push(' '),
95 '%' => {
96 let hi = chars.next().and_then(|c| c.to_digit(16));
97 let lo = chars.next().and_then(|c| c.to_digit(16));
98 if let (Some(hi), Some(lo)) = (hi, lo) {
99 result.push(char::from_u32((hi << 4) | lo).unwrap_or('�'));
100 } else {
101 result.push('%');
102 }
103 }
104 _ => result.push(c),
105 }
106 }
107 result
108}
109
110fn urlencoding(s: &str) -> String {
111 s.chars()
112 .map(|c| match c {
113 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '~' => c.to_string(),
114 ' ' => "+".to_string(),
115 _ => format!("%{:02X}", c as u8),
116 })
117 .collect()
118}
119
120pub fn commands() -> Vec<poise::Command<Data, Error>> {
121 vec![web()]
122}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..96a6849
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,145 @@
1use std::env;
2
3pub const TEMP_PATH: &str = "/tmp/senator_sperma";
4
5#[derive(Debug, Clone)]
6pub struct Config {
7 pub token: String,
8 pub status_channel_id: Option<u64>,
9 pub assistant_keyword: String,
10 pub copyparty_url: String,
11 pub copyparty_url_upload: String,
12 pub copyparty_password: Option<String>,
13 pub openrouter_api_key: Option<String>,
14 pub openrouter_model: Option<String>,
15 pub database_path: String,
16}
17
18struct EnvVar {
19 name: &'static str,
20 required: bool,
21 default: Option<&'static str>,
22 description: &'static str,
23}
24
25const ENV_VARS: &[EnvVar] = &[
26 EnvVar {
27 name: "TOKEN",
28 required: true,
29 default: None,
30 description: "Discord bot token",
31 },
32 EnvVar {
33 name: "STATUS_CHANNEL_ID",
34 required: false,
35 default: None,
36 description: "status channel ID",
37 },
38 EnvVar {
39 name: "ASSISTANT_KEYWORD",
40 required: false,
41 default: Some("jarvis"),
42 description: "keyword for AI assistant",
43 },
44 EnvVar {
45 name: "COPYPARTY_URL",
46 required: false,
47 default: Some("http://localhost:3939"),
48 description: "Copyparty base URL",
49 },
50 EnvVar {
51 name: "COPYPARTY_URL_UPLOAD",
52 required: false,
53 default: Some("http://localhost:3939"),
54 description: "Copyparty upload URL",
55 },
56 EnvVar {
57 name: "COPYPARTY_PASSWORD",
58 required: false,
59 default: None,
60 description: "Copyparty auth password",
61 },
62 EnvVar {
63 name: "OPENROUTER_API_KEY",
64 required: false,
65 default: None,
66 description: "OpenRouter API key (disables AI if missing)",
67 },
68 EnvVar {
69 name: "OPENROUTER_MODEL",
70 required: false,
71 default: Some("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"),
72 description: "OpenRouter model",
73 },
74 EnvVar {
75 name: "SENATOR_DB_PATH",
76 required: false,
77 default: Some("data/senator.db"),
78 description: "SQLite database path",
79 },
80 EnvVar {
81 name: "RUST_LOG",
82 required: false,
83 default: Some("info"),
84 description: "tracing/logging level filter",
85 },
86];
87
88pub fn check_env_vars() {
89 let mut missing_required = Vec::new();
90 let mut missing_optional = Vec::new();
91
92 for var in ENV_VARS {
93 match env::var(var.name) {
94 Ok(_) => continue,
95 Err(_) => {
96 if var.required {
97 tracing::error!("MISSING REQUIRED: {} ({})", var.name, var.description);
98 missing_required.push(var.name);
99 } else {
100 match var.default {
101 Some(default) => {
102 tracing::warn!("{} not set, using default: {}", var.name, default)
103 }
104 None => tracing::warn!("{} not set ({})", var.name, var.description),
105 }
106 missing_optional.push(var.name);
107 }
108 }
109 }
110 }
111
112 if !missing_required.is_empty() {
113 tracing::error!(
114 "Missing required environment variables: {}. The application will not function correctly.",
115 missing_required.join(", ")
116 );
117 }
118
119 if !missing_optional.is_empty() {
120 tracing::warn!(
121 "Missing optional environment variables: {}. Some features will be disabled or use defaults.",
122 missing_optional.join(", ")
123 );
124 }
125}
126
127impl Config {
128 pub fn from_env() -> Self {
129 Self {
130 token: env::var("TOKEN").expect("Missing TOKEN"),
131 status_channel_id: env::var("STATUS_CHANNEL_ID")
132 .ok()
133 .and_then(|v| v.parse().ok()),
134 assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()),
135 copyparty_url: env::var("COPYPARTY_URL")
136 .unwrap_or_else(|_| "http://localhost:3939".into()),
137 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD")
138 .unwrap_or_else(|_| "http://localhost:3939".into()),
139 copyparty_password: env::var("COPYPARTY_PASSWORD").ok(),
140 openrouter_api_key: env::var("OPENROUTER_API_KEY").ok(),
141 openrouter_model: env::var("OPENROUTER_MODEL").ok(),
142 database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()),
143 }
144 }
145}
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
new file mode 100644
index 0000000..00bfd53
--- /dev/null
+++ b/src/copyparty/mod.rs
@@ -0,0 +1,267 @@
1use reqwest::Client;
2use serde::Deserialize;
3use tracing::warn;
4
5use crate::error::Result;
6use crate::lang;
7
8#[derive(Clone)]
9pub struct CopypartyClient {
10 pub base_url: String,
11 pub upload_url: String,
12 pub password: Option<String>,
13 client: Client,
14}
15
16/// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`).
17pub fn percent_decode(s: &str) -> String {
18 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
19 let bytes = s.as_bytes();
20 let mut i = 0;
21 while i < bytes.len() {
22 let b = bytes[i];
23 if b == b'%' && i + 2 < bytes.len() {
24 if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
25 buf.push(hi << 4 | lo);
26 i += 3;
27 continue;
28 }
29 }
30 if b == b'+' {
31 buf.push(b' ');
32 } else {
33 buf.push(b);
34 }
35 i += 1;
36 }
37 String::from_utf8_lossy(&buf).into_owned()
38}
39
40/// Decode common HTML entities in a display string.
41/// Handles named (`&amp;`, `&lt;`) and numeric (`&#123;`, `&#x7B;`) forms.
42/// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8.
43pub fn html_decode(s: &str) -> String {
44 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
45 let bytes = s.as_bytes();
46 let mut i = 0;
47 while i < bytes.len() {
48 if bytes[i] == b'&' {
49 let rest = &s[i..];
50 if let Some(end) = rest.find(';') {
51 let entity = &rest[..=end];
52 match entity {
53 "&amp;" => buf.extend_from_slice(b"&"),
54 "&lt;" => buf.extend_from_slice(b"<"),
55 "&gt;" => buf.extend_from_slice(b">"),
56 "&quot;" => buf.extend_from_slice(b"\""),
57 "&apos;" => buf.extend_from_slice(b"'"),
58 "&nbsp;" => buf.extend_from_slice(b" "),
59 _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => {
60 let (num_str, radix) =
61 if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' {
62 (&entity[3..entity.len() - 1], 16u32)
63 } else {
64 (&entity[2..entity.len() - 1], 10u32)
65 };
66 let decoded = u32::from_str_radix(num_str, radix)
67 .ok()
68 .and_then(std::char::from_u32);
69 if let Some(c) = decoded {
70 let mut tmp = [0u8; 4];
71 buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
72 i += entity.len();
73 continue;
74 }
75 buf.extend_from_slice(entity.as_bytes());
76 }
77 _ => buf.extend_from_slice(entity.as_bytes()),
78 }
79 i += entity.len();
80 continue;
81 }
82 }
83 buf.push(bytes[i]);
84 i += 1;
85 }
86 String::from_utf8_lossy(&buf).into_owned()
87}
88
89fn hex_nibble(b: u8) -> Option<u8> {
90 match b {
91 b'0'..=b'9' => Some(b - b'0'),
92 b'A'..=b'F' => Some(b - b'A' + 10),
93 b'a'..=b'f' => Some(b - b'a' + 10),
94 _ => None,
95 }
96}
97
98/// A search result from copyparty's `/?srch` API.
99///
100/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`).
101/// The `name` / `url` / `path` fields are typically only present in directory
102/// listing (`?ls`) responses, not in search. Use the convenience accessors
103/// below instead of reading fields directly.
104#[derive(Debug, Clone, Deserialize)]
105pub struct SearchHit {
106 /// Relative path including filename (e.g. `/music/foo.mp3`).
107 /// This is the primary field populated by `/?srch`.
108 #[serde(default)]
109 pub rp: Option<String>,
110 /// Path component (directory listing only)
111 #[serde(default)]
112 pub path: Option<String>,
113 /// URL fragment for building a download URL
114 #[serde(default)]
115 pub url: Option<String>,
116 /// Bare filename (directory listing only)
117 #[serde(default)]
118 pub name: Option<String>,
119}
120
121impl SearchHit {
122 /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail.
123 pub fn filename(&self) -> Option<&str> {
124 self.name
125 .as_deref()
126 .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next()))
127 .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next()))
128 }
129
130 /// Best-effort URL path for building a full download URL.
131 /// Tries `url`, then `rp`.
132 pub fn url_path(&self) -> Option<&str> {
133 self.url.as_deref().or_else(|| self.rp.as_deref())
134 }
135}
136
137#[derive(Debug, Deserialize)]
138pub struct UploadResponse {
139 pub fileurl: Option<String>,
140}
141
142impl CopypartyClient {
143 pub fn new(base_url: String, upload_url: String, password: Option<String>) -> Self {
144 Self {
145 base_url,
146 upload_url,
147 password,
148 client: Client::new(),
149 }
150 }
151
152 /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD`
153 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`)
154 /// or relative (`music/foo.mp3`).
155 pub fn build_url(&self, path: &str) -> String {
156 let prefixed = if path.starts_with('/') {
157 format!("{}{}", self.base_url, path)
158 } else {
159 format!("{}/{}", self.base_url, path)
160 };
161 if let Some(pw) = &self.password {
162 format!("{prefixed}?pw={pw}")
163 } else {
164 prefixed
165 }
166 }
167
168 /// Search copyparty via the jPOST `/?srch` endpoint.
169 ///
170 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python
171 /// `_extract_files_from_response` helper), because the exact JSON shape can vary
172 /// across copyparty versions.
173 pub async fn search(
174 &self,
175 query: &str,
176 max_results: u32,
177 ext_filter: &[&str],
178 ) -> Result<Vec<SearchHit>> {
179 let tokens: Vec<&str> = query.split_whitespace().collect();
180 let conditions: Vec<String> = tokens
181 .iter()
182 .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")"))
183 .collect();
184
185 let mut q = conditions.join(" and ");
186 if !ext_filter.is_empty() {
187 let ext_conditions: Vec<String> = ext_filter
188 .iter()
189 .map(|e| format!("name like \"*.{e}\""))
190 .collect();
191 let ext_q = ext_conditions.join(" or ");
192 // When query has no tokens (e.g. empty autocomplete partial),
193 // avoid wrapping an empty string in parens → "() and ..." is invalid.
194 if q.is_empty() {
195 q = ext_q;
196 } else {
197 q = format!("({q}) and ({ext_q})");
198 }
199 }
200
201 let payload = serde_json::json!({"n": max_results, "q": q});
202
203 let mut req = self
204 .client
205 .post(format!("{}/?srch", self.base_url))
206 .json(&payload);
207 if let Some(pw) = &self.password {
208 req = req.header("PW", pw);
209 }
210
211 let resp = req.send().await?;
212 let status = resp.status();
213 let body_text = resp.text().await?;
214
215 if !status.is_success() {
216 let snippet: String = body_text.chars().take(200).collect();
217 return Err(crate::error::Error::Custom(lang::fmt_cp_search_http(
218 &status, &snippet,
219 )));
220 }
221
222 let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
223 let snippet: String = body_text.chars().take(200).collect();
224 crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet))
225 })?;
226
227 // Extract hits array – copyparty wraps results in {"hits": [...]}
228 let hits_val = match &root {
229 serde_json::Value::Object(map) => map.get("hits"),
230 _ => None,
231 };
232
233 let hits: Vec<SearchHit> = match hits_val {
234 Some(serde_json::Value::Array(arr)) => arr
235 .iter()
236 .filter_map(|v| SearchHit::deserialize(v).ok())
237 .collect(),
238 _ => {
239 warn!(
240 "copyparty search response missing 'hits' array; body starts: {:?}",
241 body_text.chars().take(100).collect::<String>()
242 );
243 Vec::new()
244 }
245 };
246
247 Ok(hits)
248 }
249
250 pub async fn upload(&self, file_name: &str, data: Vec<u8>) -> Result<String> {
251 let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string());
252 let form = reqwest::multipart::Form::new().part("f", part);
253
254 let mut req = self
255 .client
256 .post(format!("{}/?j=1&ck=b2", self.upload_url))
257 .multipart(form);
258
259 if let Some(pw) = &self.password {
260 req = req.header("Cookie", format!("cppwd={pw}"));
261 }
262
263 let resp: UploadResponse = req.send().await?.json().await?;
264 resp.fileurl
265 .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into()))
266 }
267}
diff --git a/src/database/mod.rs b/src/database/mod.rs
new file mode 100644
index 0000000..53f4bf4
--- /dev/null
+++ b/src/database/mod.rs
@@ -0,0 +1,344 @@
1use chrono::{DateTime, Utc};
2use sqlx::FromRow;
3use sqlx::SqlitePool;
4
5use crate::error::{Error, Result};
6
7pub type DbPool = SqlitePool;
8
9pub async fn init_pool(path: &str) -> Result<DbPool> {
10 if let Some(parent) = std::path::Path::new(path).parent() {
11 tokio::fs::create_dir_all(parent).await.map_err(Error::Io)?;
12 }
13 let pool = SqlitePool::connect(&format!("sqlite:{path}?mode=rwc")).await?;
14 run_migrations(&pool).await?;
15 Ok(pool)
16}
17
18async fn run_migrations(pool: &DbPool) -> Result<()> {
19 sqlx::query(
20 "CREATE TABLE IF NOT EXISTS settings (
21 key TEXT PRIMARY KEY,
22 value TEXT NOT NULL
23 )",
24 )
25 .execute(pool)
26 .await?;
27
28 sqlx::query(
29 "CREATE TABLE IF NOT EXISTS voice_activity (
30 user_id TEXT PRIMARY KEY,
31 join_time REAL NOT NULL DEFAULT 0,
32 total_active REAL NOT NULL DEFAULT 0,
33 total_afk REAL NOT NULL DEFAULT 0,
34 is_afk INTEGER NOT NULL DEFAULT 0,
35 afk_start REAL NOT NULL DEFAULT 0,
36 active_start REAL NOT NULL DEFAULT 0,
37 channel_name TEXT NOT NULL DEFAULT '',
38 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
39 )",
40 )
41 .execute(pool)
42 .await?;
43
44 sqlx::query(
45 "CREATE TABLE IF NOT EXISTS cron_jobs (
46 id INTEGER PRIMARY KEY AUTOINCREMENT,
47 cron_expression TEXT NOT NULL,
48 type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')),
49 channel_id INTEGER NOT NULL,
50 active INTEGER NOT NULL DEFAULT 1,
51 audio_filename TEXT,
52 message TEXT,
53 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
54 )",
55 )
56 .execute(pool)
57 .await?;
58
59 sqlx::query(
60 "CREATE TABLE IF NOT EXISTS triggers (
61 id INTEGER PRIMARY KEY AUTOINCREMENT,
62 name TEXT UNIQUE NOT NULL,
63 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
64 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
65 )",
66 )
67 .execute(pool)
68 .await?;
69
70 sqlx::query(
71 "CREATE TABLE IF NOT EXISTS trigger_keywords (
72 id INTEGER PRIMARY KEY AUTOINCREMENT,
73 trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
74 keyword TEXT NOT NULL,
75 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
76 UNIQUE(trigger_id, keyword)
77 )",
78 )
79 .execute(pool)
80 .await?;
81
82 sqlx::query(
83 "CREATE TABLE IF NOT EXISTS trigger_responses (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
86 response_text TEXT NOT NULL,
87 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
88 )",
89 )
90 .execute(pool)
91 .await?;
92
93 Ok(())
94}
95
96// -- Voice Activity --
97#[derive(Debug, Clone, FromRow)]
98pub struct VoiceActivity {
99 pub user_id: String,
100 pub join_time: f64,
101 pub total_active: f64,
102 pub total_afk: f64,
103 pub is_afk: i32,
104 pub afk_start: f64,
105 pub active_start: f64,
106 pub channel_name: String,
107 #[allow(dead_code)]
108 pub last_updated: Option<DateTime<Utc>>,
109}
110
111#[allow(dead_code)]
112pub async fn get_voice_activity(pool: &DbPool, user_id: &str) -> Result<Option<VoiceActivity>> {
113 Ok(
114 sqlx::query_as("SELECT * FROM voice_activity WHERE user_id = ?")
115 .bind(user_id)
116 .fetch_optional(pool)
117 .await?,
118 )
119}
120
121pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result<()> {
122 sqlx::query(
123 "INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
124 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
125 ON CONFLICT(user_id) DO UPDATE SET
126 join_time = excluded.join_time,
127 total_active = excluded.total_active,
128 total_afk = excluded.total_afk,
129 is_afk = excluded.is_afk,
130 afk_start = excluded.afk_start,
131 active_start = excluded.active_start,
132 channel_name = excluded.channel_name,
133 last_updated = CURRENT_TIMESTAMP",
134 )
135 .bind(&va.user_id)
136 .bind(va.join_time)
137 .bind(va.total_active)
138 .bind(va.total_afk)
139 .bind(va.is_afk)
140 .bind(va.afk_start)
141 .bind(va.active_start)
142 .bind(&va.channel_name)
143 .execute(pool)
144 .await?;
145 Ok(())
146}
147
148// -- Cron Jobs --
149#[derive(Debug, Clone, FromRow)]
150pub struct CronJob {
151 pub id: i64,
152 pub cron_expression: String,
153 pub r#type: String,
154 pub channel_id: i64,
155 pub active: i32,
156 pub audio_filename: Option<String>,
157 pub message: Option<String>,
158 #[allow(dead_code)]
159 pub created_at: Option<DateTime<Utc>>,
160}
161
162#[allow(dead_code)]
163pub async fn list_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> {
164 Ok(sqlx::query_as("SELECT * FROM cron_jobs ORDER BY id")
165 .fetch_all(pool)
166 .await?)
167}
168
169#[allow(dead_code)]
170pub async fn list_active_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> {
171 Ok(sqlx::query_as("SELECT * FROM cron_jobs WHERE active = 1")
172 .fetch_all(pool)
173 .await?)
174}
175
176pub async fn add_cron_job(
177 pool: &DbPool,
178 cron_expression: &str,
179 r#type: &str,
180 channel_id: i64,
181 audio_filename: Option<&str>,
182 message: Option<&str>,
183) -> Result<i64> {
184 let result = sqlx::query(
185 "INSERT INTO cron_jobs (cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?)",
186 )
187 .bind(cron_expression)
188 .bind(r#type)
189 .bind(channel_id)
190 .bind(audio_filename)
191 .bind(message)
192 .execute(pool)
193 .await?;
194 Ok(result.last_insert_rowid())
195}
196
197pub async fn delete_cron_job(pool: &DbPool, id: i64) -> Result<bool> {
198 let result = sqlx::query("DELETE FROM cron_jobs WHERE id = ?")
199 .bind(id)
200 .execute(pool)
201 .await?;
202 Ok(result.rows_affected() > 0)
203}
204
205pub async fn set_cron_job_active(pool: &DbPool, id: i64, active: bool) -> Result<bool> {
206 let result = sqlx::query("UPDATE cron_jobs SET active = ? WHERE id = ?")
207 .bind(if active { 1 } else { 0 })
208 .bind(id)
209 .execute(pool)
210 .await?;
211 Ok(result.rows_affected() > 0)
212}
213
214// -- Triggers --
215#[derive(Debug, Clone, FromRow)]
216pub struct Trigger {
217 pub id: i64,
218 pub name: String,
219 pub created_at: Option<DateTime<Utc>>,
220 pub last_updated: Option<DateTime<Utc>>,
221}
222
223#[allow(dead_code)]
224#[derive(Debug, Clone, FromRow)]
225pub struct TriggerKeyword {
226 pub id: i64,
227 pub trigger_id: i64,
228 pub keyword: String,
229}
230
231#[allow(dead_code)]
232#[derive(Debug, Clone, FromRow)]
233pub struct TriggerResponse {
234 pub id: i64,
235 pub trigger_id: i64,
236 pub response_text: String,
237}
238
239pub async fn create_trigger(pool: &DbPool, name: &str) -> Result<i64> {
240 let result = sqlx::query("INSERT INTO triggers (name) VALUES (?)")
241 .bind(name)
242 .execute(pool)
243 .await?;
244 Ok(result.last_insert_rowid())
245}
246
247pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result<bool> {
248 let result = sqlx::query("DELETE FROM triggers WHERE id = ?")
249 .bind(id)
250 .execute(pool)
251 .await?;
252 Ok(result.rows_affected() > 0)
253}
254
255pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result<Option<Trigger>> {
256 Ok(sqlx::query_as("SELECT * FROM triggers WHERE name = ?")
257 .bind(name)
258 .fetch_optional(pool)
259 .await?)
260}
261
262pub async fn list_triggers(pool: &DbPool) -> Result<Vec<Trigger>> {
263 Ok(sqlx::query_as("SELECT * FROM triggers ORDER BY name")
264 .fetch_all(pool)
265 .await?)
266}
267
268pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> {
269 sqlx::query("INSERT OR IGNORE INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)")
270 .bind(trigger_id)
271 .bind(keyword)
272 .execute(pool)
273 .await?;
274 update_trigger_timestamp(pool, trigger_id).await
275}
276
277pub async fn remove_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<bool> {
278 let result = sqlx::query("DELETE FROM trigger_keywords WHERE trigger_id = ? AND keyword = ?")
279 .bind(trigger_id)
280 .bind(keyword)
281 .execute(pool)
282 .await?;
283 update_trigger_timestamp(pool, trigger_id).await?;
284 Ok(result.rows_affected() > 0)
285}
286
287pub async fn add_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<()> {
288 sqlx::query("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)")
289 .bind(trigger_id)
290 .bind(response)
291 .execute(pool)
292 .await?;
293 update_trigger_timestamp(pool, trigger_id).await
294}
295
296pub async fn remove_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<bool> {
297 let result =
298 sqlx::query("DELETE FROM trigger_responses WHERE trigger_id = ? AND response_text = ?")
299 .bind(trigger_id)
300 .bind(response)
301 .execute(pool)
302 .await?;
303 update_trigger_timestamp(pool, trigger_id).await?;
304 Ok(result.rows_affected() > 0)
305}
306
307pub async fn get_keywords(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> {
308 let rows: Vec<(String,)> =
309 sqlx::query_as("SELECT keyword FROM trigger_keywords WHERE trigger_id = ?")
310 .bind(trigger_id)
311 .fetch_all(pool)
312 .await?;
313 Ok(rows.into_iter().map(|r| r.0).collect())
314}
315
316pub async fn get_responses(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> {
317 let rows: Vec<(String,)> =
318 sqlx::query_as("SELECT response_text FROM trigger_responses WHERE trigger_id = ?")
319 .bind(trigger_id)
320 .fetch_all(pool)
321 .await?;
322 Ok(rows.into_iter().map(|r| r.0).collect())
323}
324
325pub async fn load_all_triggers_with_keywords_and_responses(
326 pool: &DbPool,
327) -> Result<Vec<(Trigger, Vec<String>, Vec<String>)>> {
328 let triggers = list_triggers(pool).await?;
329 let mut result = Vec::new();
330 for t in triggers {
331 let keywords = get_keywords(pool, t.id).await?;
332 let responses = get_responses(pool, t.id).await?;
333 result.push((t, keywords, responses));
334 }
335 Ok(result)
336}
337
338async fn update_trigger_timestamp(pool: &DbPool, id: i64) -> Result<()> {
339 sqlx::query("UPDATE triggers SET last_updated = CURRENT_TIMESTAMP WHERE id = ?")
340 .bind(id)
341 .execute(pool)
342 .await?;
343 Ok(())
344}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..d90d2e9
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,108 @@
1use std::fmt;
2
3#[derive(Debug)]
4pub enum Error {
5 Sqlx(sqlx::Error),
6 Reqwest(reqwest::Error),
7 Serenity(serenity::Error),
8 SongbirdJoin(songbird::error::JoinError),
9 SongbirdControl(songbird::tracks::ControlError),
10 Io(std::io::Error),
11 Cron(cron::error::Error),
12 Image(image::ImageError),
13 Qr(qrcode::types::QrError),
14 Scraper(String),
15 Config(String),
16 TaskJoin(String),
17 Custom(String),
18}
19
20impl fmt::Display for Error {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 use crate::lang;
23 match self {
24 Self::Sqlx(e) => write!(f, "{}: {e}", lang::L_ERR_DATABASE),
25 Self::Reqwest(e) => write!(f, "{}: {e}", lang::L_ERR_HTTP),
26 Self::Serenity(e) => write!(f, "{}: {e}", lang::L_ERR_DISCORD),
27 Self::SongbirdJoin(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_JOIN),
28 Self::SongbirdControl(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_CONTROL),
29 Self::Io(e) => write!(f, "{}: {e}", lang::L_ERR_IO),
30 Self::Cron(e) => write!(f, "{}: {e}", lang::L_ERR_CRON),
31 Self::Image(e) => write!(f, "{}: {e}", lang::L_ERR_IMAGE),
32 Self::Qr(e) => write!(f, "{}: {e}", lang::L_ERR_QR),
33 Self::Scraper(e) => write!(f, "{}: {e}", lang::L_ERR_SCRAPER),
34 Self::Config(e) => write!(f, "{}: {e}", lang::L_ERR_CONFIG),
35 Self::TaskJoin(e) => write!(f, "{}: {e}", lang::L_ERR_TASK_JOIN),
36 Self::Custom(e) => write!(f, "{e}"),
37 }
38 }
39}
40
41impl std::error::Error for Error {
42 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
43 match self {
44 Self::Sqlx(e) => Some(e),
45 Self::Reqwest(e) => Some(e),
46 Self::Serenity(e) => Some(e),
47 Self::SongbirdJoin(e) => Some(e),
48 Self::SongbirdControl(e) => Some(e),
49 Self::Io(e) => Some(e),
50 Self::Image(e) => Some(e),
51 Self::Qr(e) => Some(e),
52 _ => None,
53 }
54 }
55}
56
57impl From<sqlx::Error> for Error {
58 fn from(e: sqlx::Error) -> Self {
59 Self::Sqlx(e)
60 }
61}
62impl From<reqwest::Error> for Error {
63 fn from(e: reqwest::Error) -> Self {
64 Self::Reqwest(e)
65 }
66}
67impl From<serenity::Error> for Error {
68 fn from(e: serenity::Error) -> Self {
69 Self::Serenity(e)
70 }
71}
72impl From<songbird::error::JoinError> for Error {
73 fn from(e: songbird::error::JoinError) -> Self {
74 Self::SongbirdJoin(e)
75 }
76}
77impl From<songbird::tracks::ControlError> for Error {
78 fn from(e: songbird::tracks::ControlError) -> Self {
79 Self::SongbirdControl(e)
80 }
81}
82impl From<std::io::Error> for Error {
83 fn from(e: std::io::Error) -> Self {
84 Self::Io(e)
85 }
86}
87impl From<cron::error::Error> for Error {
88 fn from(e: cron::error::Error) -> Self {
89 Self::Cron(e)
90 }
91}
92impl From<image::ImageError> for Error {
93 fn from(e: image::ImageError) -> Self {
94 Self::Image(e)
95 }
96}
97impl From<qrcode::types::QrError> for Error {
98 fn from(e: qrcode::types::QrError) -> Self {
99 Self::Qr(e)
100 }
101}
102impl From<tokio::task::JoinError> for Error {
103 fn from(e: tokio::task::JoinError) -> Self {
104 Self::TaskJoin(e.to_string())
105 }
106}
107
108pub type Result<T> = std::result::Result<T, Error>;
diff --git a/src/events/mod.rs b/src/events/mod.rs
new file mode 100644
index 0000000..457ebcd
--- /dev/null
+++ b/src/events/mod.rs
@@ -0,0 +1,368 @@
1use chrono::{Local, Utc};
2use poise::serenity_prelude as serenity;
3use rand::seq::IndexedRandom;
4use tracing::{info, warn};
5
6use crate::Data;
7use crate::database::{self, VoiceActivity};
8use crate::error::Result;
9use crate::lang;
10
11pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> {
12 info!("{}", lang::L_EV_READY);
13 Ok(())
14}
15
16fn format_duration(seconds: f64) -> String {
17 let total = seconds as u64;
18 let hours = total / 3600;
19 let minutes = (total % 3600) / 60;
20 let secs = total % 60;
21 if hours > 0 {
22 format!("{hours}h {minutes}m {secs}s")
23 } else if minutes > 0 {
24 format!("{minutes}m {secs}s")
25 } else {
26 format!("{secs}s")
27 }
28}
29
30async fn play_tts_in_channel(
31 _ctx: &serenity::Context,
32 data: &Data,
33 guild_id: serenity::GuildId,
34 channel_id: serenity::ChannelId,
35 text: &str,
36) {
37 let tts_url = format!(
38 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob",
39 crate::commands::tts::urlencoding(text),
40 );
41
42 let audio_data = match data.http_client.get(&tts_url).send().await {
43 Ok(resp) => match resp.bytes().await {
44 Ok(b) => b,
45 Err(e) => {
46 warn!("Failed to read TTS audio bytes: {e}");
47 return;
48 }
49 },
50 Err(e) => {
51 warn!("Failed to fetch TTS audio: {e}");
52 return;
53 }
54 };
55
56 let call = match data.voice_manager.connect(guild_id, channel_id).await {
57 Ok(c) => c,
58 Err(e) => {
59 warn!("Failed to connect to voice for TTS: {e}");
60 return;
61 }
62 };
63
64 let input = match crate::voice::decode_audio(&audio_data) {
65 Ok(i) => i,
66 Err(e) => {
67 warn!("Failed to decode TTS audio: {e}");
68 return;
69 }
70 };
71
72 let mut call_lock = call.lock().await;
73 if call_lock.current_channel().is_some() {
74 call_lock.play_input(input);
75 }
76}
77
78pub async fn handle_voice_state_update(
79 ctx: &serenity::Context,
80 data: &Data,
81 old: Option<serenity::VoiceState>,
82 new: &serenity::VoiceState,
83) -> Result<()> {
84 let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new);
85 let current_ts = Utc::now().timestamp();
86 let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
87
88 let user_id = new.user_id;
89 let guild_id = new.guild_id;
90
91 // skip bots (Python behavior)
92 let member = guild_id
93 .and_then(|gid| ctx.cache.guild(gid))
94 .and_then(|g| g.members.get(&user_id).cloned());
95 if let Some(ref m) = member {
96 if m.user.bot {
97 return Ok(());
98 }
99 }
100
101 let member_name = member
102 .as_ref()
103 .map(|m| m.user.name.clone())
104 .unwrap_or_default();
105 let display_name = member
106 .as_ref()
107 .map(|m| m.display_name().to_string())
108 .unwrap_or_default();
109
110 let now = current_ts as f64;
111
112 let user_id_str = user_id.to_string();
113 let mut va = database::get_voice_activity(&data.db_pool, &user_id_str)
114 .await?
115 .unwrap_or(VoiceActivity {
116 user_id: user_id_str.clone(),
117 join_time: 0.0,
118 total_active: 0.0,
119 total_afk: 0.0,
120 is_afk: 0,
121 afk_start: 0.0,
122 active_start: 0.0,
123 channel_name: String::new(),
124 last_updated: None,
125 });
126
127 let old_channel = old.as_ref().and_then(|v| v.channel_id);
128 let new_channel = new.channel_id;
129
130 let mut tts_text: Option<String> = None;
131 let mut tts_channel: Option<serenity::ChannelId> = None;
132
133 // ── JOIN ──
134 if old_channel.is_none() && new_channel.is_some() {
135 let cid = new_channel.unwrap();
136 let is_afk = new.self_mute || new.self_deaf;
137
138 va.join_time = now;
139 va.total_active = 0.0;
140 va.total_afk = 0.0;
141 va.is_afk = if is_afk { 1 } else { 0 };
142 va.afk_start = if is_afk { now } else { 0.0 };
143 va.active_start = if !is_afk { now } else { 0.0 };
144 va.channel_name = guild_id
145 .and_then(|gid| ctx.cache.guild(gid))
146 .map(|g| {
147 g.channels
148 .get(&cid)
149 .map(|c| c.name.clone())
150 .unwrap_or_default()
151 })
152 .unwrap_or_default();
153 database::upsert_voice_activity(&data.db_pool, &va).await?;
154 info!("User {user_id} joined voice");
155
156 if let Some(lc) = log_channel {
157 let msg = format!(
158 "[{current_time}] {member_name} dołączył na {} <t:{current_ts}:R>",
159 va.channel_name
160 );
161 lc.say(ctx, &msg).await.ok();
162 }
163 tts_text = Some(lang::fmt_ev_joined(&display_name));
164 tts_channel = Some(cid);
165 }
166 // ── LEAVE ──
167 else if old_channel.is_some() && new_channel.is_none() {
168 let ocid = old_channel.unwrap();
169 let channel_name = guild_id
170 .and_then(|gid| ctx.cache.guild(gid))
171 .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone()))
172 .unwrap_or_default();
173
174 if va.join_time > 0.0 {
175 if va.is_afk == 1 {
176 va.total_afk += now - va.afk_start;
177 } else {
178 va.total_active += (now - va.active_start).max(0.0);
179 }
180
181 let active_str = format_duration(va.total_active);
182 let afk_str = format_duration(va.total_afk);
183
184 if let Some(lc) = log_channel {
185 let msg = format!(
186 "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\
187 Aktywny: {active_str} | AFK: {afk_str}",
188 channel_name,
189 );
190 lc.say(ctx, &msg).await.ok();
191 }
192 info!("User {user_id} left voice");
193
194 database::upsert_voice_activity(&data.db_pool, &va).await?;
195 } else {
196 if let Some(lc) = log_channel {
197 let msg = format!(
198 "[{current_time}] {member_name} rozłączył się z {channel_name} <t:{current_ts}:R>"
199 );
200 lc.say(ctx, &msg).await.ok();
201 }
202 info!("User {user_id} left voice (no data)");
203 }
204
205 tts_text = Some(lang::fmt_ev_left(&display_name));
206 tts_channel = Some(ocid);
207 }
208 // ── AFK STATE CHANGE (only for tracked users already in channel) ──
209 else if new_channel.is_some() && va.join_time > 0.0 {
210 let is_afk = new.self_mute || new.self_deaf;
211 let was_afk = va.is_afk == 1;
212
213 if was_afk && !is_afk {
214 // Return from AFK
215 let afk_duration = now - va.afk_start;
216 va.total_afk += afk_duration;
217 va.is_afk = 0;
218 va.afk_start = 0.0;
219 va.active_start = now;
220 database::upsert_voice_activity(&data.db_pool, &va).await?;
221
222 let afk_str = format_duration(afk_duration);
223 // Python behavior: after.self_mute is always false here so always "odgłuchnął"
224 let activity = if new.self_mute {
225 "odciszył"
226 } else {
227 "odgłuchnął"
228 };
229
230 if let Some(lc) = log_channel {
231 let msg = format!(
232 "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\
233 AFK: {afk_str}",
234 va.channel_name,
235 );
236 lc.say(ctx, &msg).await.ok();
237 }
238 info!("User {user_id} returned from AFK");
239 } else if !was_afk && is_afk {
240 // Went AFK
241 let active_duration = if va.active_start > 0.0 {
242 (now - va.active_start).max(0.0)
243 } else {
244 0.0
245 };
246 va.total_active += active_duration;
247 va.is_afk = 1;
248 va.afk_start = now;
249 va.active_start = 0.0;
250 database::upsert_voice_activity(&data.db_pool, &va).await?;
251
252 let active_str = format_duration(active_duration);
253 let activity = if new.self_mute {
254 "wyciszył"
255 } else {
256 "ogłuchnął"
257 };
258
259 if let Some(lc) = log_channel {
260 let msg = format!(
261 "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\
262 Aktywny: {active_str}",
263 va.channel_name,
264 );
265 lc.say(ctx, &msg).await.ok();
266 }
267 info!("User {user_id} went AFK");
268 }
269 }
270
271 // ── TTS for mute/deafen (deaf takes priority with early return) ──
272 if old.as_ref().map_or(false, |v| v.self_deaf) != new.self_deaf {
273 let phrase = if new.self_deaf {
274 lang::fmt_ev_deafened(&display_name)
275 } else {
276 lang::fmt_ev_undeafened(&display_name)
277 };
278 if let Some(gid) = guild_id {
279 if let Some(cid) = tts_channel.or(new_channel) {
280 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
281 }
282 }
283 return Ok(());
284 }
285
286 if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute {
287 let phrase = if new.self_mute {
288 lang::fmt_ev_muted(&display_name)
289 } else {
290 lang::fmt_ev_unmuted(&display_name)
291 };
292 if let Some(gid) = guild_id {
293 if let Some(cid) = tts_channel.or(new_channel) {
294 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
295 }
296 }
297 return Ok(());
298 }
299
300 // ── Play join/leave TTS if no mute/deafen change ──
301 if let (Some(text), Some(cid)) = (tts_text, tts_channel) {
302 if let Some(gid) = guild_id {
303 play_tts_in_channel(ctx, data, gid, cid, &text).await;
304 }
305 }
306
307 Ok(())
308}
309
310pub async fn handle_message(
311 ctx: &serenity::Context,
312 data: &Data,
313 new_message: &serenity::Message,
314) -> Result<()> {
315 if new_message.author.id == ctx.cache.current_user().id {
316 return Ok(());
317 }
318
319 let content_lower = new_message.content.to_lowercase();
320 let response_to_send = {
321 let triggers = data.triggers.lock().await;
322 let mut chosen = None;
323 for (_trigger, keywords, responses) in triggers.iter() {
324 if keywords.iter().any(|k| content_lower.contains(k)) {
325 chosen = responses.choose(&mut rand::rng()).cloned();
326 break;
327 }
328 }
329 chosen
330 };
331 if let Some(response) = response_to_send {
332 new_message.channel_id.say(ctx, response).await.ok();
333 }
334
335 let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id);
336 let has_keyword = content_lower.contains(&data.config.assistant_keyword);
337
338 if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() {
339 let api_key = data.config.openrouter_api_key.clone().unwrap();
340 let model = data.config.openrouter_model.clone().unwrap_or_else(|| {
341 "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into()
342 });
343
344 let client = crate::openrouter::OpenRouterClient::new(api_key, model);
345 let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT;
346
347 let mut messages = Vec::new();
348 let mut current_msg = Some(new_message.clone());
349 for _ in 0..5 {
350 if let Some(msg) = current_msg {
351 messages.push(("user".into(), msg.content.clone()));
352 current_msg = match msg.message_reference.and_then(|r| r.message_id) {
353 Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(),
354 None => None,
355 };
356 } else {
357 break;
358 }
359 }
360 messages.reverse();
361
362 if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await {
363 new_message.reply(ctx, response).await.ok();
364 }
365 }
366
367 Ok(())
368}
diff --git a/src/lang.rs b/src/lang.rs
new file mode 100644
index 0000000..626f387
--- /dev/null
+++ b/src/lang.rs
@@ -0,0 +1,387 @@
1// ┌──────────────────────────────────────────────────────────────────┐
2// │ lang.rs — All user-facing messages live here. │
3// │ Translate the bot by editing this single file. │
4// │ Naming: L_<CAT>_<NAME> for simple &str; │
5// │ fn fmt_<cat>_<name>(...) -> String for parameterized. │
6// └──────────────────────────────────────────────────────────────────┘
7
8// ── Error display prefixes (used in error.rs Display impl) ────────────
9
10pub const L_ERR_DATABASE: &str = "Database error";
11pub const L_ERR_HTTP: &str = "HTTP error";
12pub const L_ERR_DISCORD: &str = "Discord error";
13pub const L_ERR_VOICE_JOIN: &str = "Voice join error";
14pub const L_ERR_VOICE_CONTROL: &str = "Voice control error";
15pub const L_ERR_IO: &str = "IO error";
16pub const L_ERR_CRON: &str = "Cron error";
17pub const L_ERR_IMAGE: &str = "Image error";
18pub const L_ERR_QR: &str = "QR error";
19pub const L_ERR_SCRAPER: &str = "Scraper error";
20pub const L_ERR_CONFIG: &str = "Config error";
21pub const L_ERR_TASK_JOIN: &str = "Task join error";
22
23// ── Common / shared error messages ────────────────────────────────────
24
25/// "Nie jestem w gildii" — bot must be invoked inside a guild
26pub const L_NOT_IN_GUILD: &str = "Nie jestem w gildii";
27/// "Nie jesteś na kanale głosowym" — user must be in a voice channel
28pub const L_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym";
29/// "Zły typ kanału" — wrong channel variant
30pub const L_BAD_CHANNEL_TYPE: &str = "Zły typ kanału";
31/// "Grupa triggerów nie znaleziona" — trigger group not found
32pub const L_TRIGGER_NOT_FOUND: &str = "Grupa triggerów nie znaleziona";
33
34// ── AI / senator command ──────────────────────────────────────────────
35
36pub const SENATOR_SYSTEM_PROMPT: &str = "Jesteś Senatorem Steavenem Sperma Armstrongiem. Jesteś rasistą, seksistą i homofobem. Odpowiadasz w języku polskim. Odpowiedź ma maksymalnie 100 słów. Nie używaj formatowania markdown.";
37
38pub const L_OPENROUTER_NO_KEY: &str = "Klucz API OpenRouter nie jest skonfigurowany w .env";
39
40// ── Cron command ──────────────────────────────────────────────────────
41
42pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana";
43pub const L_CRON_NONE: &str = "Brak zakolejkowanych prac cron";
44pub const L_CRON_TITLE: &str = "Prace cron";
45pub const L_CRON_ACTIVE: &str = "✅ Aktywna";
46pub const L_CRON_PAUSED: &str = "⏸️ Zapauzowana";
47
48pub fn fmt_cron_audio_queued(cron: &str) -> String {
49 format!("Praca audio zakolejkowana z cron'em `{cron}`")
50}
51pub fn fmt_cron_label_audio(name: &str) -> String {
52 format!("Audio: {name}")
53}
54pub fn fmt_cron_label_msg(msg: &str) -> String {
55 format!("Wiadomość: {msg}")
56}
57pub fn fmt_cron_job_status(id: i64, status: &str) -> String {
58 format!("ID {id} — {status}")
59}
60pub fn fmt_cron_field(cron: &str, chan: i64, desc: &str) -> String {
61 format!("`{cron}` — <#{chan}>\n{desc}")
62}
63pub fn fmt_cron_deleted(id: i64) -> String {
64 format!("Praca cron z ID {id} została usunięta")
65}
66pub fn fmt_cron_not_found(id: i64) -> String {
67 format!("Praca z ID {id} nie została znaleziona")
68}
69pub fn fmt_cron_paused(id: i64) -> String {
70 format!("Praca cron z ID {id} została zapauzowana")
71}
72pub fn fmt_cron_resumed(id: i64) -> String {
73 format!("Praca cron z ID {id} została wznowiona")
74}
75
76// ── Math command ──────────────────────────────────────────────────────
77
78pub const L_MATH_TOO_LONG: &str = "Ekspresja za długa (max 1000 znaków";
79pub const L_MATH_NOT_ASCII: &str = "Tylko znaki ASCII dozwolone";
80pub const L_MATH_UNBALANCED: &str = "Nawiasy są niezbalansowane";
81pub const L_MATH_DANGEROUS: &str = "Ekspresja zawiera groźne słowa";
82pub const L_MATH_NO_RESULT: &str = "Brak rezultatu";
83
84pub fn fmt_math_qalc_err(e: &dyn std::fmt::Display) -> String {
85 format!("qalc nie jest dostępny: {e}")
86}
87pub fn fmt_math_result(r: &str) -> String {
88 format!("```\n{r}\n```")
89}
90
91// ── Music command ─────────────────────────────────────────────────────
92
93pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik";
94pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty";
95pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL";
96pub const L_MUSIC_CANT_COMPRESS_LIVE: &str =
97 "Nie można skompresować transmisji na żywo, odtwarzam normalnie";
98pub const L_MUSIC_STOPPED: &str = "wyszed";
99pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel";
100
101pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String {
102 format!("Szukanie na copyparty zawiodło: {e}")
103}
104pub fn fmt_music_playing(name: &str) -> String {
105 format!("Grajcowanie: {name}")
106}
107pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String {
108 format!("Dołączon <#{cid}>")
109}
110pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String {
111 format!("Nie udało się połączyć z VC: {e}")
112}
113
114// ── TTS command ───────────────────────────────────────────────────────
115
116pub const L_TTS_SAVED: &str = "TTS zapisany";
117pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym";
118
119// ── Trigger command ───────────────────────────────────────────────────
120
121pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany";
122pub const L_TRIGGER_KW_NONE: &str = "Brak";
123
124pub fn fmt_trigger_created(name: &str) -> String {
125 format!("Grupa trigger '{name}' utworzona")
126}
127pub fn fmt_trigger_failed(e: &dyn std::fmt::Display) -> String {
128 format!("Nie udało się: {e}")
129}
130pub fn fmt_trigger_deleted(name: &str) -> String {
131 format!("Grupa trigger '{name}' usunięta")
132}
133pub fn fmt_trigger_title(name: &str) -> String {
134 format!("Trigger: {name}")
135}
136
137// ── Utility commands ──────────────────────────────────────────────────
138
139pub const L_UTIL_NEED_INPUT: &str = "Podaj plik z copyparty lub załącznik";
140pub const L_UTIL_PICK_FROM_AUTOCOMPLETE: &str = "Wybierz plik z podpowiedzi";
141pub const L_UTIL_DOWNLOAD_FAILED: &str = "Nie udało się pobrać pliku";
142pub const L_UTIL_UNSUPPORTED_TYPE: &str = "Nieobsługiwany typ pliku";
143pub const L_UTIL_COMPRESS_FAILED: &str = "Kompresja nie powiodła się";
144pub const L_UTIL_CONVERT_FAILED: &str = "Konwersja nie powiodła się";
145pub const L_UTIL_UPLOAD_FAILED: &str = "Nie udało się wysłać na serwer";
146pub const L_UTIL_NO_ATTACHMENTS: &str = "Brak załączników w wiadomości";
147pub const L_SEARCH_NO_RESULTS: &str = "No results found";
148
149pub fn fmt_util_compressed(url: &str) -> String {
150 format!("Skompresowano\n{url}")
151}
152pub fn fmt_util_converted(url: &str) -> String {
153 format!("Skonwertowano\n{url}")
154}
155pub fn fmt_util_convert_err(stderr: &str) -> String {
156 format!("Błąd konwersji: {stderr}")
157}
158
159pub fn fmt_uploaded(url: &str) -> String {
160 format!("Wysłon: {url}")
161}
162pub fn fmt_download_uploaded(url: &str) -> String {
163 format!("pobioron i wysyłon: {url}")
164}
165pub fn fmt_upload_summary(count: usize) -> String {
166 format!("Wysłano {count} plików:\n")
167}
168pub fn fmt_upload_partial_failed(count: usize) -> String {
169 format!("\n\nNie wysłano {count} plików")
170}
171pub fn fmt_message_compressed(made: usize, total: usize) -> String {
172 format!("Skompresowano {made} z {total} plików:")
173}
174pub fn fmt_search_result(name: &str, url: &str) -> String {
175 format!("**{name}**\n{url}")
176}
177pub fn fmt_download_failed(stderr: &str) -> String {
178 format!(
179 "Pobieranie się nie powiodło: {}",
180 translate_ytdlp_stderr(stderr)
181 )
182}
183pub fn fmt_ytdlp_err(e: &dyn std::fmt::Display) -> String {
184 format!("yt-dlp nie mógło się uruchomić: {e}")
185}
186pub fn fmt_ytdlp_stderr(stderr: &str) -> String {
187 translate_ytdlp_stderr(stderr)
188}
189
190/// Translate yt-dlp's verbose stderr into a short, user-friendly Polish
191/// message. yt-dlp emits lots of `[youtube] Extracting URL…`, `WARNING:`
192/// and `ERROR:` noise that is meaningless to a normal Discord user; this
193/// recognises the handful of failure modes users actually hit and falls
194/// back to the first `ERROR:`/`WARNING:` line (stripped of the
195/// `ERROR: [extractor] id: ` prefix) when nothing matches, so we never
196/// show the full multi-line trace.
197pub fn translate_ytdlp_stderr(stderr: &str) -> String {
198 let lower = stderr.to_ascii_lowercase();
199
200 // YouTube's anti-bot sign-in wall. yt-dlp can't bypass this without
201 // browser cookies configured on the host — surface that to the user
202 // rather than the cryptic "Sign in to confirm you're not a bot" line.
203 if lower.contains("sign in to confirm you")
204 || lower.contains("confirm you're not a bot")
205 || lower.contains("confirm you are not a bot")
206 {
207 return "YouTube uznaje nas za bota i blokuje pobieranie. Spróbuj ponownie później albo podaj inny link.".into();
208 }
209
210 // Generic extractor fallback means the URL didn't match any known
211 // site — combined with "Requested format is not available" this is
212 // almost always "the user pasted a non-video URL into /download".
213 if lower.contains("requested format is not available")
214 || lower.contains("url could be a direct video link")
215 || lower.contains("falling back on generic information extractor")
216 {
217 return "Ten link nie wygląda na wideo obsługiwane przez yt-dlp. Upewnij się, że to adres do filmu (YouTube / SoundCloud / Bandcamp / Twitch).".into();
218 }
219
220 if lower.contains("private video") {
221 return "Ten film jest prywatny i nie można go pobrać.".into();
222 }
223 if lower.contains("video unavailable")
224 || lower.contains("video has been removed")
225 || lower.contains("this video is unavailable")
226 {
227 return "Ten film jest niedostępny lub został usunięty.".into();
228 }
229 if lower.contains("members-only")
230 || lower.contains("premium-only")
231 || lower.contains("channel's members")
232 {
233 return "Ten film jest dostępny tylko dla subskrybujących / członków kanału.".into();
234 }
235 if lower.contains("not available in your country")
236 || lower.contains("geo-restricted")
237 || lower.contains("geo restricted")
238 {
239 return "Ten film jest zablokowany regionalnie i nie jest dostępny w naszym regionie."
240 .into();
241 }
242 if lower.contains("age-restricted")
243 || lower.contains("age restricted")
244 || lower.contains("inappropriate for some users")
245 {
246 return "Ten film jest ograniczony wiekowo i wymaga uwierzytelnienia.".into();
247 }
248 if lower.contains("http error 404") || lower.contains(" http error 404 ") {
249 return "Nie znaleziono takiego adresu (404).".into();
250 }
251 if lower.contains("unable to extract") && lower.contains("video data") {
252 return "Nie udało się wyciągnąć danych filmu — strona mogła się zmienić albo link jest niepełny.".into();
253 }
254
255 // Fallback: keep only the first ERROR: line and strip the
256 // `ERROR: [extractor] id: ` prefix so the message is short and
257 // free of yt-dlp internals.
258 for line in stderr.lines() {
259 if let Some(rest) = line.strip_prefix("ERROR:") {
260 let rest = rest.trim();
261 // Drop leading `[extractor] video_id: ` noise if present.
262 let msg = rest
263 .strip_prefix(|c: char| c == '[')
264 .and_then(|r| r.find(']').map(|i| &r[i + 1..]))
265 .map(|s| {
266 s.trim_start_matches(|c: char| {
267 c.is_alphanumeric() || c == '_' || c == '-' || c == ':'
268 })
269 .trim()
270 })
271 .filter(|s| !s.is_empty())
272 .unwrap_or(rest);
273 return format!("Pobieranie się nie powiodło: {msg}");
274 }
275 }
276 for line in stderr.lines() {
277 if let Some(rest) = line.strip_prefix("WARNING:") {
278 return format!("Pobieranie się nie powiodło: {}", rest.trim());
279 }
280 }
281
282 "Pobieranie się nie powiodło.".into()
283}
284
285// ── Web command ───────────────────────────────────────────────────────
286
287pub const L_WEB_NO_RESULTS: &str = "Brak rezultatów";
288
289pub fn fmt_web_title(query: &str) -> String {
290 format!("Wyniki wyszukiwania dla: {query}")
291}
292
293// ── QR command ────────────────────────────────────────────────────────
294
295pub const L_QR_GENERATED: &str = "Kod QR wygenerowany:";
296pub const L_QR_DECODE_FAILED: &str = "Nie udało się zdekodować kodu QR.";
297
298pub fn fmt_qr_decoded(text: &str) -> String {
299 format!("```\n{text}\n```")
300}
301
302// ── Copyparty client ──────────────────────────────────────────────────
303
304pub const L_CP_UPLOAD_NO_URL: &str = "Wysłanie zawiodło, brak URL w odpowiedzi";
305
306pub fn fmt_cp_search_http(status: &dyn std::fmt::Display, snippet: &str) -> String {
307 format!("copyparty zwróciło status HTTP {status}: {snippet}")
308}
309pub fn fmt_cp_invalid_json(e: &dyn std::fmt::Display, snippet: &str) -> String {
310 format!("copyparty zwróciło nielegalny JSON. ({e}): {snippet}")
311}
312
313// ── OpenRouter ────────────────────────────────────────────────────────
314
315pub fn fmt_openrouter_err(status: &dyn std::fmt::Display, text: &str) -> String {
316 format!("Błąd API OpenRouter. {status}: {text}")
317}
318
319// ── Voice / audio decoding ────────────────────────────────────────────
320
321pub const L_VOICE_NO_TRACK: &str = "Brak kawałka";
322pub const L_VOICE_NO_CODEC: &str = "Brak parametrów codec'a audio";
323
324pub fn fmt_voice_remove_failed(e: &dyn std::fmt::Display) -> String {
325 format!("Nie udało się usunąć klienta głosowego: {e}")
326}
327pub fn fmt_voice_probe_failed(e: &dyn std::fmt::Display) -> String {
328 format!("Próbkowanie symphonia zawiodło: {e}")
329}
330pub fn fmt_voice_decoder_failed(e: &dyn std::fmt::Display) -> String {
331 format!("Nie udało się utworzyć dekodera audio: {e}")
332}
333pub fn fmt_voice_ytdlp_launch(e: &dyn std::fmt::Display) -> String {
334 format!("yt-dlp nie mogło się uruchomić: {e}")
335}
336pub fn fmt_voice_ffmpeg_launch(e: &dyn std::fmt::Display) -> String {
337 format!("ffmpeg nie mogło się uruchomić: {e}")
338}
339
340// ── Events ────────────────────────────────────────────────────────────
341
342pub const L_EV_READY: &str = "ŁUKASZ SPERMA AKTYWACJA";
343pub const L_EV_VOICE_STATE_ERROR: &str = "Błąd stanu głosu";
344pub const L_EV_MESSAGE_HANDLER_ERROR: &str = "Błąd ustanawiacza głosu";
345
346// ── Event TTS phrases ─────────────────────────────────────────────────
347
348pub fn fmt_ev_joined(name: &str) -> String {
349 format!("{name} sie zespawnił")
350}
351pub fn fmt_ev_left(name: &str) -> String {
352 format!("{name} jest chujem")
353}
354pub fn fmt_ev_deafened(name: &str) -> String {
355 format!("{name} jest bity przez ojca")
356}
357pub fn fmt_ev_undeafened(name: &str) -> String {
358 format!("{name} uciekł od ojca")
359}
360pub fn fmt_ev_muted(name: &str) -> String {
361 format!("{name} idzie robić jajecznice")
362}
363pub fn fmt_ev_unmuted(name: &str) -> String {
364 format!("{name} zrobił jajecznice")
365}
366
367// ── AI assistant (events/message handler) ─────────────────────────────
368
369pub const ASSISTANT_SYSTEM_PROMPT: &str =
370 "Jesteś asystentem. Odpowiadasz po polsku, jesteś pomocny, nie używasz emoji.";
371
372// ── Convenience helpers for repeated Error::Custom patterns ────────────
373
374use crate::error::Error;
375
376pub fn err_not_in_guild() -> Error {
377 Error::Custom(L_NOT_IN_GUILD.into())
378}
379pub fn err_not_in_voice() -> Error {
380 Error::Custom(L_NOT_IN_VOICE.into())
381}
382pub fn err_trigger_not_found() -> Error {
383 Error::Custom(L_TRIGGER_NOT_FOUND.into())
384}
385pub fn err_bad_channel_type() -> Error {
386 Error::Custom(L_BAD_CHANNEL_TYPE.into())
387}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4a89315
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,190 @@
1use std::collections::HashMap;
2
3use poise::serenity_prelude as serenity;
4use songbird::SerenityInit;
5use tracing_subscriber::filter::EnvFilter;
6
7mod commands;
8mod config;
9mod copyparty;
10mod database;
11mod error;
12mod events;
13mod lang;
14mod openrouter;
15mod tasks;
16mod voice;
17
18use database::DbPool;
19
20pub struct Data {
21 pub db_pool: DbPool,
22 pub config: config::Config,
23 pub voice_manager: voice::VoiceManager,
24 pub copyparty: copyparty::CopypartyClient,
25 pub http_client: reqwest::Client,
26 pub volume: std::sync::atomic::AtomicU64,
27 pub triggers: tokio::sync::Mutex<Vec<(database::Trigger, Vec<String>, Vec<String>)>>,
28 /// Autocomplete lookup, keyed per-user to avoid cross-command collisions.
29 ///
30 /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users
31 /// search the same filename simultaneously, each invocation must resolve its
32 /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`.
33 pub ac_cache: tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>,
34 /// Active music track per guild (only one music source at a time).
35 pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>,
36}
37
38impl Data {
39 pub async fn reload_triggers(&self) {
40 let triggers = database::load_all_triggers_with_keywords_and_responses(&self.db_pool)
41 .await
42 .unwrap_or_default();
43 *self.triggers.lock().await = triggers;
44 }
45
46 pub async fn ac_stash(
47 &self,
48 user_id: serenity::UserId,
49 key: String,
50 hit: copyparty::SearchHit,
51 ) {
52 self.ac_cache.lock().await.insert((user_id, key), hit);
53 }
54
55 pub async fn ac_lookup(
56 &self,
57 user_id: serenity::UserId,
58 key: &str,
59 ) -> Option<copyparty::SearchHit> {
60 self.ac_cache
61 .lock()
62 .await
63 .get(&(user_id, key.to_string()))
64 .cloned()
65 }
66}
67
68#[allow(dead_code)]
69type Error = error::Error;
70
71#[tokio::main]
72async fn main() {
73 let _ = rustls::crypto::ring::default_provider().install_default();
74
75 tracing_subscriber::fmt()
76 .with_env_filter(
77 EnvFilter::builder()
78 .parse(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
79 .unwrap(),
80 )
81 .init();
82
83 dotenvy::dotenv().ok();
84 config::check_env_vars();
85 let config = config::Config::from_env();
86
87 let db_pool = database::init_pool(&config.database_path)
88 .await
89 .expect("Failed to initialize database");
90
91 let http_client = reqwest::Client::new();
92
93 let copyparty = copyparty::CopypartyClient::new(
94 config.copyparty_url.clone(),
95 config.copyparty_url_upload.clone(),
96 config.copyparty_password.clone(),
97 );
98
99 let triggers = database::load_all_triggers_with_keywords_and_responses(&db_pool)
100 .await
101 .unwrap_or_default();
102
103 let commands_list = commands::all_commands();
104 let config_for_client = config.clone();
105
106 let framework = poise::Framework::builder()
107 .options(poise::FrameworkOptions {
108 commands: commands_list,
109 event_handler: |ctx, event, _framework, user_data| {
110 Box::pin(async move {
111 handle_event(ctx, event, user_data).await;
112 Ok(())
113 })
114 },
115 ..Default::default()
116 })
117 .setup(move |ctx, _ready, framework| {
118 let config = config.clone();
119 let db_pool = db_pool.clone();
120 let http_client = http_client.clone();
121 let copyparty = copyparty.clone();
122 let triggers = triggers.clone();
123 Box::pin(async move {
124 let vm = voice::VoiceManager::new(ctx).await;
125
126 let data = Data {
127 config,
128 db_pool,
129 voice_manager: vm,
130 copyparty,
131 http_client,
132 volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()),
133 triggers: tokio::sync::Mutex::new(triggers),
134 ac_cache: tokio::sync::Mutex::new(HashMap::new()),
135 music_tracks: tokio::sync::Mutex::new(HashMap::new()),
136 };
137
138 // Spawn cron scheduler loop.
139 {
140 let cron_ctx = crate::tasks::CronContext {
141 pool: data.db_pool.clone(),
142 http: ctx.http.clone(),
143 voice_manager: data.voice_manager.clone(),
144 copyparty: data.copyparty.clone(),
145 };
146 tokio::spawn(async move {
147 crate::tasks::cron_scheduler_loop(cron_ctx).await;
148 });
149 }
150
151 poise::builtins::register_globally(ctx, &framework.options().commands).await?;
152
153 events::handle_ready(ctx).await.map_err(|e| {
154 tracing::error!("on_ready handler: {e}");
155 e
156 })?;
157
158 Ok(data)
159 })
160 })
161 .build();
162
163 let mut client =
164 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all())
165 .framework(framework)
166 .register_songbird()
167 .await
168 .expect("Error creating client");
169
170 client.start().await.expect("Error starting client");
171}
172
173async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) {
174 match event {
175 serenity::FullEvent::Ready {
176 data_about_bot: _, ..
177 } => {}
178 serenity::FullEvent::VoiceStateUpdate { old, new } => {
179 events::handle_voice_state_update(ctx, user_data, old.clone(), new)
180 .await
181 .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_VOICE_STATE_ERROR));
182 }
183 serenity::FullEvent::Message { new_message } => {
184 events::handle_message(ctx, user_data, new_message)
185 .await
186 .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_MESSAGE_HANDLER_ERROR));
187 }
188 _ => {}
189 }
190}
diff --git a/src/openrouter/mod.rs b/src/openrouter/mod.rs
new file mode 100644
index 0000000..31cee85
--- /dev/null
+++ b/src/openrouter/mod.rs
@@ -0,0 +1,134 @@
1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3
4use crate::error::Result;
5use crate::lang;
6
7pub struct OpenRouterClient {
8 api_key: String,
9 model: String,
10 client: Client,
11}
12
13#[derive(Serialize)]
14struct ChatRequest {
15 model: String,
16 messages: Vec<Message>,
17 max_tokens: u32,
18}
19
20#[derive(Serialize)]
21struct Message {
22 role: String,
23 content: String,
24}
25
26#[derive(Deserialize)]
27struct ChatResponse {
28 choices: Vec<Choice>,
29}
30
31#[derive(Deserialize)]
32struct Choice {
33 message: MessageContent,
34}
35
36#[derive(Deserialize)]
37struct MessageContent {
38 content: String,
39}
40
41impl OpenRouterClient {
42 pub fn new(api_key: String, model: String) -> Self {
43 Self {
44 api_key,
45 model,
46 client: Client::new(),
47 }
48 }
49
50 pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result<String> {
51 let body = ChatRequest {
52 model: self.model.clone(),
53 messages: vec![
54 Message {
55 role: "system".into(),
56 content: system_prompt.to_string(),
57 },
58 Message {
59 role: "user".into(),
60 content: user_message.to_string(),
61 },
62 ],
63 max_tokens: 500,
64 };
65
66 let resp = self
67 .client
68 .post("https://openrouter.ai/api/v1/chat/completions")
69 .header("Authorization", format!("Bearer {}", self.api_key))
70 .json(&body)
71 .send()
72 .await?;
73
74 if !resp.status().is_success() {
75 let status = resp.status();
76 let text = resp.text().await.unwrap_or_default();
77 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
78 &status, &text,
79 )));
80 }
81
82 let chat_resp: ChatResponse = resp.json().await?;
83 Ok(chat_resp
84 .choices
85 .into_iter()
86 .next()
87 .map(|c| c.message.content)
88 .unwrap_or_default())
89 }
90
91 pub async fn chat_with_history(
92 &self,
93 system_prompt: &str,
94 messages: Vec<(String, String)>, // (role, content) pairs
95 ) -> Result<String> {
96 let mut msg_vec = vec![Message {
97 role: "system".into(),
98 content: system_prompt.to_string(),
99 }];
100 for (role, content) in messages {
101 msg_vec.push(Message { role, content });
102 }
103
104 let body = ChatRequest {
105 model: self.model.clone(),
106 messages: msg_vec,
107 max_tokens: 500,
108 };
109
110 let resp = self
111 .client
112 .post("https://openrouter.ai/api/v1/chat/completions")
113 .header("Authorization", format!("Bearer {}", self.api_key))
114 .json(&body)
115 .send()
116 .await?;
117
118 if !resp.status().is_success() {
119 let status = resp.status();
120 let text = resp.text().await.unwrap_or_default();
121 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
122 &status, &text,
123 )));
124 }
125
126 let chat_resp: ChatResponse = resp.json().await?;
127 Ok(chat_resp
128 .choices
129 .into_iter()
130 .next()
131 .map(|c| c.message.content)
132 .unwrap_or_default())
133 }
134}
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs
new file mode 100644
index 0000000..f3f8811
--- /dev/null
+++ b/src/tasks/mod.rs
@@ -0,0 +1,172 @@
1use std::sync::Arc;
2
3use chrono::{Duration, Timelike, Utc};
4use cron::Schedule;
5use poise::serenity_prelude as serenity;
6use std::str::FromStr;
7use tokio::time::sleep;
8use tracing::{error, info};
9
10use crate::copyparty::CopypartyClient;
11use crate::database::{self, DbPool};
12use crate::voice::VoiceManager;
13
14/// Shared resources the cron scheduler needs.
15#[derive(Clone)]
16pub struct CronContext {
17 pub pool: DbPool,
18 pub http: Arc<serenity::Http>,
19 pub voice_manager: VoiceManager,
20 pub copyparty: CopypartyClient,
21}
22
23/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects.
24/// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow).
25fn parse_cron(raw: &str) -> Result<Schedule, cron::error::Error> {
26 let fields: Vec<&str> = raw.split_whitespace().collect();
27 let normalized = match fields.len() {
28 7 => raw.to_string(),
29 6 => format!("{} *", raw), // sec min hour dom month dow → +year
30 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year
31 _ => raw.to_string(), // let the crate reject it
32 };
33 Schedule::from_str(&normalized)
34}
35
36/// Check window: look for cron fires in the last WINDOW_SECONDS.
37/// 61s gives 1s overlap with 60s tick to avoid missing exact-boundary fires.
38const WINDOW_SECONDS: i64 = 61;
39/// Tick interval for the scheduler loop.
40const TICK_SECONDS: u64 = 60;
41
42pub async fn check_and_execute_jobs(ctx: &CronContext) {
43 let jobs = match database::list_active_cron_jobs(&ctx.pool).await {
44 Ok(jobs) => jobs,
45 Err(e) => {
46 error!("Failed to load cron jobs: {e}");
47 return;
48 }
49 };
50
51 let now = Utc::now();
52 let window_start = now - Duration::seconds(WINDOW_SECONDS);
53
54 for job in jobs {
55 let schedule = match parse_cron(&job.cron_expression) {
56 Ok(s) => s,
57 Err(e) => {
58 error!("Invalid cron expression '{}': {e}", job.cron_expression);
59 continue;
60 }
61 };
62
63 // Check if any fire time fell within the last WINDOW_SECONDS.
64 let mut has_fired = false;
65 for next_fire in schedule.after(&window_start) {
66 if next_fire > now {
67 break;
68 }
69 has_fired = true;
70 break; // one fire per window is enough
71 }
72
73 if !has_fired {
74 continue;
75 }
76
77 info!("Executing cron job {}", job.id);
78 match job.r#type.as_str() {
79 "audio" => execute_audio_job(ctx, &job).await,
80 "wiadomość" => execute_message_job(ctx, &job).await,
81 _ => {}
82 }
83 }
84}
85
86async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) {
87 let Some(ref msg_text) = job.message else {
88 return;
89 };
90 let channel_id = serenity::ChannelId::new(job.channel_id as u64);
91 if let Err(e) = channel_id.say(&ctx.http, msg_text).await {
92 error!("Cron job {} failed to send message: {e}", job.id);
93 } else {
94 info!("Cron job {} sent message to {}", job.id, job.channel_id);
95 }
96}
97
98async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
99 let Some(ref audio_path) = job.audio_filename else {
100 error!("Cron audio job {} has no audio_filename", job.id);
101 return;
102 };
103
104 let channel_id = serenity::ChannelId::new(job.channel_id as u64);
105
106 // Resolve guild + build URL concurrently.
107 let url = ctx.copyparty.build_url(audio_path);
108 info!("Cron audio job {} streaming via ffmpeg: {url}", job.id);
109 let guild_channel_fut = channel_id.to_channel(&ctx.http);
110 let input_fut = crate::voice::ffmpeg_stream_input(&url);
111
112 let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut);
113
114 let guild_id = match channel_result {
115 Ok(serenity::Channel::Guild(ch)) => ch.guild_id,
116 other => {
117 error!(
118 "Cron audio job {}: channel {} is not a guild voice channel ({:?})",
119 job.id,
120 job.channel_id,
121 other.map(|_| ())
122 );
123 return;
124 }
125 };
126
127 let input = match input_result {
128 Ok(i) => i,
129 Err(e) => {
130 error!(
131 "Cron audio job {} ffmpeg stream failed ({url}): {e}",
132 job.id
133 );
134 return;
135 }
136 };
137
138 // Connect to voice and play.
139 let call = match ctx.voice_manager.connect(guild_id, channel_id).await {
140 Ok(c) => c,
141 Err(e) => {
142 error!("Cron audio job {} voice connect failed: {e}", job.id);
143 return;
144 }
145 };
146
147 {
148 let mut call_lock = call.lock().await;
149 call_lock.play_input(input);
150 }
151
152 info!(
153 "Cron audio job {} playing '{}' (url: {url}) in guild {}",
154 job.id, audio_path, guild_id
155 );
156}
157
158pub async fn cron_scheduler_loop(ctx: CronContext) {
159 // Align first tick to the start of the next minute, then run every TICK_SECONDS.
160 let now = Utc::now();
161 let next_minute = (now + Duration::minutes(1))
162 .with_second(0)
163 .and_then(|t| t.with_nanosecond(0))
164 .unwrap_or(now);
165 let initial_delay = (next_minute - now).to_std().unwrap_or_default();
166 sleep(initial_delay).await;
167
168 loop {
169 check_and_execute_jobs(&ctx).await;
170 sleep(std::time::Duration::from_secs(TICK_SECONDS)).await;
171 }
172}
diff --git a/src/voice/mod.rs b/src/voice/mod.rs
new file mode 100644
index 0000000..0d5b89e
--- /dev/null
+++ b/src/voice/mod.rs
@@ -0,0 +1,404 @@
1use std::io::Cursor;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Duration;
6
7use serenity::all::{ChannelId, GuildId};
8use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter};
9use songbird::{Call, Songbird};
10use symphonia::core::audio::sample::Sample;
11use symphonia::core::codecs::audio::AudioDecoderOptions;
12use symphonia::core::formats::probe::Hint;
13use symphonia::core::formats::{FormatOptions, TrackType};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use tokio::sync::Mutex;
17use tracing::{info, warn};
18
19use crate::error::{Error, Result};
20use crate::lang;
21
22#[derive(Clone)]
23pub struct VoiceManager {
24 pub songbird: Arc<Songbird>,
25}
26
27impl VoiceManager {
28 pub async fn new(ctx: &serenity::client::Context) -> Self {
29 Self {
30 songbird: songbird::get(ctx)
31 .await
32 .expect("Songbird not registered via register_songbird()"),
33 }
34 }
35
36 pub async fn connect(
37 &self,
38 guild_id: GuildId,
39 channel_id: ChannelId,
40 ) -> Result<Arc<Mutex<Call>>> {
41 let mut last_err = None;
42 for attempt in 1..=3 {
43 match self.songbird.join(guild_id, channel_id).await {
44 Ok(call) => {
45 tokio::time::sleep(Duration::from_millis(500)).await;
46 return Ok(call);
47 }
48 Err(e) => {
49 warn!(
50 "Voice join attempt {attempt}/3 failed for guild {}: {e}",
51 guild_id
52 );
53 if let songbird::error::JoinError::Driver(ref driver_err) = e {
54 warn!("Driver error details: {driver_err:?}");
55 }
56 last_err = Some(e);
57 tokio::time::sleep(Duration::from_secs(1)).await;
58 }
59 }
60 }
61 Err(Error::SongbirdJoin(last_err.unwrap()))
62 }
63
64 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> {
65 self.songbird
66 .remove(guild_id)
67 .await
68 .map_err(|e| Error::Custom(lang::fmt_voice_remove_failed(&e)))?;
69 Ok(())
70 }
71
72 pub fn get_call(&self, guild_id: GuildId) -> Option<Arc<Mutex<Call>>> {
73 self.songbird.get(guild_id)
74 }
75}
76
77// ── Audio decoding ─────────────────────────────────────────────────────
78
79/// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird.
80/// Supports mp3, m4a/aac, flac, ogg, wav, opus via symphonia auto-detection.
81pub fn decode_audio(data: &[u8]) -> Result<songbird::input::Input> {
82 let mss = MediaSourceStream::new(Box::new(Cursor::new(data.to_vec())), Default::default());
83 decode_stream(mss)
84}
85
86fn decode_stream(mss: MediaSourceStream) -> Result<songbird::input::Input> {
87 let hint = Hint::new();
88 let fmt_opts = FormatOptions::default();
89 let meta_opts = MetadataOptions::default();
90
91 let mut format = symphonia::default::get_probe()
92 .probe(&hint, mss, fmt_opts, meta_opts)
93 .map_err(|e| Error::Custom(lang::fmt_voice_probe_failed(&e)))?;
94
95 let track = format
96 .default_track(TrackType::Audio)
97 .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_TRACK.into()))?;
98 let track_id = track.id;
99
100 let codec_params = track
101 .codec_params
102 .as_ref()
103 .and_then(|p| p.audio())
104 .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_CODEC.into()))?;
105
106 let sample_rate = codec_params.sample_rate.unwrap_or(48000);
107 let channels = codec_params
108 .channels
109 .clone()
110 .map(|c| c.count() as u32)
111 .unwrap_or(2);
112
113 let dec_opts = AudioDecoderOptions::default();
114 let mut decoder = symphonia::default::get_codecs()
115 .make_audio_decoder(codec_params, &dec_opts)
116 .map_err(|e| Error::Custom(lang::fmt_voice_decoder_failed(&e)))?;
117
118 let mut pcm_f32: Vec<f32> = Vec::new();
119
120 loop {
121 match format.next_packet() {
122 Ok(Some(packet)) => {
123 if packet.track_id != track_id {
124 continue;
125 }
126 if let Ok(buf) = decoder.decode(&packet) {
127 let n = buf.samples_interleaved();
128 let start = pcm_f32.len();
129 pcm_f32.resize(start + n, f32::MID);
130 buf.copy_to_slice_interleaved(&mut pcm_f32[start..]);
131 }
132 }
133 Ok(None) => break,
134 Err(_) => break,
135 }
136 }
137
138 let pcm_bytes: Vec<u8> = pcm_f32.iter().flat_map(|&s| s.to_le_bytes()).collect();
139 let cursor = Cursor::new(pcm_bytes);
140 let adapter = RawAdapter::new(cursor, sample_rate, channels);
141
142 Ok(adapter.into())
143}
144
145// ── yt-dlp helper ──────────────────────────────────────────────────────
146
147/// Download audio via yt-dlp, collect stdout into memory, return the raw bytes.
148/// Works for both finite media and live streams (yt-dlp handles the difference).
149pub async fn ytdlp_download(url: &str) -> Result<Vec<u8>> {
150 let output = tokio::process::Command::new("yt-dlp")
151 .args(["-f", "bestaudio", "-o", "-", "--no-playlist", url])
152 .stdout(std::process::Stdio::piped())
153 .stderr(std::process::Stdio::piped())
154 .output()
155 .await
156 .map_err(|e| Error::Custom(lang::fmt_voice_ytdlp_launch(&e)))?;
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 // Keep the full yt-dlp trace in the logs for debugging — the user
161 // only ever sees the short, translated `fmt_ytdlp_stderr` message.
162 warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end());
163 return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr)));
164 }
165
166 Ok(output.stdout)
167}
168
169// ── Compression helpers ───────────────────────────────────────────────
170
171/// Download a URL into memory via reqwest.
172pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
173 let resp = client
174 .get(url)
175 .send()
176 .await
177 .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?;
178 let bytes = resp
179 .bytes()
180 .await
181 .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?;
182 Ok(bytes.to_vec())
183}
184
185/// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM.
186/// If this plays, the streaming pipeline works; bug is in Opus roundtrip.
187pub async fn degrade_audio(input: &[u8]) -> Result<songbird::input::Input> {
188 use std::process::Stdio;
189
190 let mut child = tokio::process::Command::new("ffmpeg")
191 .args([
192 "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500",
193 "pipe:1",
194 ])
195 .stdin(Stdio::piped())
196 .stdout(Stdio::piped())
197 .stderr(Stdio::null())
198 .kill_on_drop(true)
199 .spawn()
200 .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?;
201
202 let mut stdin = child.stdin.take().unwrap();
203 let stdout = child
204 .stdout
205 .take()
206 .ok_or_else(|| Error::Custom("stdout not captured".into()))?;
207
208 let owned_input = input.to_vec();
209 tokio::spawn(async move {
210 use tokio::io::AsyncWriteExt;
211 let _ = stdin.write_all(&owned_input).await;
212 });
213
214 let ffmpeg_child = FfmpegChild {
215 _child: child,
216 stdout,
217 };
218
219 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
220 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
221 let raw = RawAdapter::new(adapter, 48000, 2);
222 Ok(raw.into())
223}
224
225// ── HTTP streaming via songbird's built-in HttpRequest (legacy) ─────
226
227/// Create a songbird `Input` that streams audio directly from an HTTP(S) URL.
228/// Works for both finite files and infinite Icecast/Shoutcast live streams.
229///
230/// Superseded by `ffmpeg_stream_input` for the `/play` command (which needs
231/// HLS/playlist/Icecast-metadata handling that symphonia can't provide), but
232/// retained for other callers that want lightweight symphonia-based streaming.
233#[allow(dead_code)]
234pub fn http_stream_input(client: reqwest::Client, url: &str) -> songbird::input::Input {
235 info!(%url, "streaming via HttpRequest");
236 songbird::input::HttpRequest::new(client, url.to_string()).into()
237}
238
239// ── Live streaming via ffmpeg subprocess ─────────────────────────────
240
241/// Adapter holding an ffmpeg child process and its stdout pipe.
242///
243/// Implements `AsyncRead` by delegating to `ChildStdout`. The `Child` is kept
244/// alive for the lifetime of the stream and killed on drop via
245/// `kill_on_drop(true)`, so stopping playback terminates ffmpeg.
246///
247/// `Sync`: the `Child` handle is only touched on `Drop` (a single, one-shot
248/// operation); `ChildStdout` is only read by the single `AsyncAdapterSink`
249/// task spawned inside `AsyncAdapterStream`. No field is accessed concurrently.
250struct FfmpegChild {
251 _child: tokio::process::Child,
252 stdout: tokio::process::ChildStdout,
253}
254
255impl tokio::io::AsyncRead for FfmpegChild {
256 fn poll_read(
257 mut self: Pin<&mut Self>,
258 cx: &mut Context<'_>,
259 buf: &mut tokio::io::ReadBuf<'_>,
260 ) -> Poll<std::io::Result<()>> {
261 Pin::new(&mut self.stdout).poll_read(cx, buf)
262 }
263}
264
265/// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird
266/// `Input` of raw f32 PCM at 48 kHz stereo — NO full download.
267///
268/// ffmpeg handles every streaming scenario the Python bot relied on it for:
269/// Icecast/Shoutcast radio (incl. inline metadata), HLS `.m3u8` (segment
270/// fetching), `.m3u`/`.pls` playlists, and direct media files. The
271/// `-reconnect` flags let ffmpeg recover from dropped connections.
272///
273/// Byte path: `ffmpeg stdout` → `AsyncReadOnlySource` (unseekable async
274/// media source) → `AsyncAdapterStream` (async→sync ring buffer) →
275/// `RawAdapter` (prepends `SbirdRaw` f32 header) → songbird `Input`.
276pub async fn ffmpeg_stream_input(url: &str) -> Result<songbird::input::Input> {
277 use std::process::Stdio;
278
279 let mut child = tokio::process::Command::new("ffmpeg")
280 .args([
281 "-reconnect",
282 "1",
283 "-reconnect_streamed",
284 "1",
285 "-reconnect_delay_max",
286 "5",
287 "-i",
288 url,
289 "-vn",
290 "-f",
291 "f32le",
292 "-ar",
293 "48000",
294 "-ac",
295 "2",
296 "pipe:1",
297 ])
298 .stdout(Stdio::piped())
299 .stderr(Stdio::piped())
300 .kill_on_drop(true)
301 .spawn()
302 .map_err(|e| Error::Custom(lang::fmt_voice_ffmpeg_launch(&e)))?;
303
304 let stdout = child
305 .stdout
306 .take()
307 .ok_or_else(|| Error::Custom("ffmpeg stdout was not captured".into()))?;
308
309 // Drain stderr in background so ffmpeg doesn't block; log first 4 KiB on drop.
310 if let Some(stderr) = child.stderr.take() {
311 let url_owned = url.to_string();
312 tokio::spawn(async move {
313 use tokio::io::AsyncReadExt;
314 let mut buf = Vec::with_capacity(4096);
315 let mut reader = tokio::io::BufReader::new(stderr);
316 let _ = reader.read_to_end(&mut buf).await;
317 let text = String::from_utf8_lossy(&buf);
318 if !text.is_empty() {
319 warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end());
320 }
321 });
322 }
323
324 let ffmpeg_child = FfmpegChild {
325 _child: child,
326 stdout,
327 };
328
329 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
330 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
331 let raw = RawAdapter::new(adapter, 48000, 2);
332 Ok(raw.into())
333}
334
335/// Smoke-test an HTTP stream URL: creates a raw reqwest GET, reads the first
336/// chunk directly from the async bytes stream (bypasses AsyncAdapterStream).
337/// Logs whether data arrives — isolates the hang to songbird's sync adapter.
338#[allow(dead_code)]
339pub async fn smoke_test_stream(client: reqwest::Client, url: String) {
340 use futures_util::StreamExt;
341
342 info!(%url, "smoke_test: sending GET");
343 let resp = match client.get(url.as_str()).send().await {
344 Ok(r) => r,
345 Err(e) => {
346 warn!(%url, error = %e, "smoke_test: GET FAILED");
347 return;
348 }
349 };
350 info!(%url, status = %resp.status(), "smoke_test: response received");
351
352 let mut stream = resp.bytes_stream();
353 info!(%url, "smoke_test: awaiting first chunk");
354 match stream.next().await {
355 Some(Ok(chunk)) => {
356 info!(%url, len = chunk.len(), "smoke_test: first chunk arrived");
357 }
358 Some(Err(e)) => {
359 warn!(%url, error = %e, "smoke_test: chunk error");
360 }
361 None => {
362 info!(%url, "smoke_test: stream ended immediately (0 bytes)");
363 }
364 }
365}
366
367/// Diagnostic: fetch headers and full body for a URL, logging key metrics.
368/// Helps debug symphonia probe EOF issues (Content-Length vs actual bytes).
369///
370/// **Warning**: hangs forever on infinite streams (Icecast). Finite URLs only.
371#[allow(dead_code)]
372pub async fn debug_fetch(client: &reqwest::Client, url: &str) {
373 match client.get(url).send().await {
374 Ok(resp) => {
375 let status = resp.status();
376 let headers = resp.headers().clone();
377 let content_length = headers
378 .get("content-length")
379 .and_then(|v| v.to_str().ok())
380 .and_then(|v| v.parse::<u64>().ok());
381 let content_type = headers.get("content-type").and_then(|v| v.to_str().ok());
382
383 info!(
384 %url, %status,
385 ?content_length, ?content_type,
386 "HTTP response headers"
387 );
388
389 match resp.bytes().await {
390 Ok(body) => {
391 let actual_len = body.len() as u64;
392 let reported_len = content_length.unwrap_or(0);
393 info!(
394 %url, reported = reported_len, actual = actual_len,
395 delta = actual_len as i64 - reported_len as i64,
396 "buffered .bytes(): reported vs actual"
397 );
398 }
399 Err(e) => warn!(%url, error = %e, ".bytes() failed"),
400 }
401 }
402 Err(e) => warn!(%url, error = %e, "HTTP GET failed"),
403 }
404}
diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py
deleted file mode 100644
index 6d92f55..0000000
--- a/tests/cogs/test_ai.py
+++ /dev/null
@@ -1,83 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4import httpx
5from conftest import MockApplicationContext
6
7
8@pytest.fixture
9def ai_cog(bot):
10 from cogs.ai import AI
11 cog = AI(bot)
12 return cog
13
14
15@pytest.fixture
16def mock_ai_response():
17 return {
18 "choices": [
19 {
20 "message": {
21 "content": "Test response from AI"
22 }
23 }
24 ]
25 }
26
27
28@pytest.mark.asyncio
29async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mock_ai_response):
30 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
31
32 mock_response = MagicMock()
33 mock_response.json = MagicMock(return_value=mock_ai_response)
34
35 mock_client = MagicMock()
36 mock_client.post = AsyncMock(return_value=mock_response)
37 mock_client.__aenter__ = AsyncMock(return_value=mock_response)
38 mock_client.__aexit__ = AsyncMock(return_value=None)
39
40 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx:
41 instance = mock_httpx.return_value.__aenter__.return_value
42 instance.post = AsyncMock(return_value=mock_response)
43 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
44 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
45 mock_followup.assert_called_once_with("Test response from AI")
46
47
48@pytest.mark.asyncio
49async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot):
50 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
51
52 mock_response = MagicMock()
53 mock_response.json = MagicMock(return_value={"choices": []})
54
55 mock_client = MagicMock()
56 mock_client.post = AsyncMock(return_value=mock_response)
57 mock_client.__aenter__ = AsyncMock(return_value=mock_response)
58 mock_client.__aexit__ = AsyncMock(return_value=None)
59
60 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx:
61 instance = mock_httpx.return_value.__aenter__.return_value
62 instance.post = AsyncMock(return_value=mock_response)
63 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
64 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
65 mock_followup.assert_called_once_with("bot siem zesroł")
66
67
68@pytest.mark.asyncio
69async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot):
70 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
71
72 mock_response = MagicMock()
73 mock_response.json = MagicMock(return_value={})
74
75 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx:
76 instance = mock_httpx.return_value.__aenter__.return_value
77 instance.post = AsyncMock(return_value=mock_response)
78 with patch('cogs.ai.logger') as mock_logger:
79 try:
80 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
81 except KeyError:
82 pass
83 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from conftest import MockApplicationContext
4
5
6@pytest.fixture
7def assistant_cog():
8 with patch('os.getenv', return_value="test_keyword"):
9 from cogs.assistant import Jarvis
10
11 mock_bot = MagicMock()
12 mock_bot.user = MagicMock()
13 mock_bot.user.id = 111
14
15 cog = Jarvis(mock_bot)
16 return cog
17
18
19def test_assistant_keyword_default(assistant_cog):
20 assert assistant_cog.keyword == "test_keyword"
21
22
23@pytest.mark.asyncio
24async def test_assistant_on_message_ignored_bot_message(assistant_cog):
25 mock_author = MagicMock()
26 mock_author.id = 456
27 mock_author.bot = True
28
29 mock_message = MagicMock()
30 mock_message.author = mock_author
31
32 with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant:
33 await assistant_cog.on_message(mock_message)
34 mock_assistant.assert_not_called()
35
36
37@pytest.mark.asyncio
38async def test_assistant_on_message_mentions_bot(assistant_cog):
39 mock_message = MagicMock()
40 mock_message.author = MagicMock()
41 mock_message.author.id = 123
42
43 mock_mention = MagicMock()
44 mock_mention.id = 111
45 mock_message.mentions = [mock_mention]
46 mock_message.content = "test"
47
48 with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant:
49 await assistant_cog.on_message(mock_message)
50 mock_assistant.assert_called_once_with(mock_message)
51
52
53@pytest.mark.asyncio
54async def test_assistant_on_message_keyword_match(assistant_cog):
55 assistant_cog.keyword = "test"
56
57 mock_message = MagicMock()
58 mock_message.author = MagicMock()
59 mock_message.author.id = 123
60
61 mock_message.mentions = []
62 mock_message.content = "test message with test keyword"
63
64 with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant:
65 await assistant_cog.on_message(mock_message)
66 mock_assistant.assert_called_once_with(mock_message)
diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py
deleted file mode 100644
index 892482b..0000000
--- a/tests/cogs/test_compression_conversion.py
+++ /dev/null
@@ -1,139 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4import discord
5from conftest import MockApplicationContext
6
7
8def test_compression_filetype_video():
9 from cogs.compression import filetype
10 assert filetype("test.mp4") == "Video"
11 assert filetype("test.avi") == "Video"
12
13
14def test_compression_filetype_audio():
15 from cogs.compression import filetype
16 assert filetype("test.mp3") == "Audio"
17 assert filetype("test.wav") == "Audio"
18
19
20def test_compression_filetype_image():
21 from cogs.compression import filetype
22 assert filetype("test.jpg") == "Image"
23 assert filetype("test.png") == "Image"
24
25
26def test_conversion_filetype():
27 from cogs.conversion import filetype
28 assert filetype("test.mp4") == "Video"
29 assert filetype("test.mp3") == "Audio"
30 assert filetype("test.jpg") == "Image"
31 assert filetype("test.xyz") == "Unknown"
32
33
34@pytest.mark.asyncio
35async def test_build_conversion_command_video():
36 with patch('os.getenv', return_value="https://example.com"):
37 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
38 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
39 import importlib
40 import cogs.conversion
41 importlib.reload(cogs.conversion)
42 from cogs.conversion import Conversion
43
44 cog = Conversion.__new__(Conversion)
45 cog._baseurl = "https://example.com"
46 cog._uploadurl = "https://example.com"
47
48 cmd = cog._build_conversion_command("Video", "/tmp/test.mp4", "/tmp/output.mp4", "")
49 assert cmd is not None
50 assert "ffmpeg" in cmd
51
52
53@pytest.mark.asyncio
54async def test_build_conversion_command_image():
55 with patch('os.getenv', return_value="https://example.com"):
56 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
57 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
58 import importlib
59 import cogs.conversion
60 importlib.reload(cogs.conversion)
61 from cogs.conversion import Conversion
62
63 cog = Conversion.__new__(Conversion)
64 cog._baseurl = "https://example.com"
65 cog._uploadurl = "https://example.com"
66
67 cmd = cog._build_conversion_command("Image", "/tmp/test.jpg", "/tmp/output.png", "")
68 assert cmd is not None
69 assert "magick" in cmd
70
71
72@pytest.mark.asyncio
73async def test_build_conversion_command_unknown():
74 with patch('os.getenv', return_value="https://example.com"):
75 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
76 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
77 import importlib
78 import cogs.conversion
79 importlib.reload(cogs.conversion)
80 from cogs.conversion import Conversion
81
82 cog = Conversion.__new__(Conversion)
83 cog._baseurl = "https://example.com"
84 cog._uploadurl = "https://example.com"
85
86 cmd = cog._build_conversion_command("Unknown", "/tmp/test.xyz", "/tmp/output.abc", "")
87 assert cmd is None
88
89
90@pytest.mark.asyncio
91async def test_compression_get_command_video():
92 with patch('os.getenv', return_value="https://example.com"):
93 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
94 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
95 import importlib
96 import cogs.compression
97 importlib.reload(cogs.compression)
98 from cogs.compression import Compression
99
100 cog = Compression.__new__(Compression)
101 cog.temp_dir = "/tmp"
102
103 cmd = cog._get_compression_command("Video", "/tmp/test.mp4", "/tmp/output.mp4")
104 assert cmd is not None
105 assert "ffmpeg" in cmd
106
107
108@pytest.mark.asyncio
109async def test_compression_get_command_image():
110 with patch('os.getenv', return_value="https://example.com"):
111 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
112 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
113 import importlib
114 import cogs.compression
115 importlib.reload(cogs.compression)
116 from cogs.compression import Compression
117
118 cog = Compression.__new__(Compression)
119 cog.temp_dir = "/tmp"
120
121 cmd = cog._get_compression_command("Image", "/tmp/test.jpg", "/tmp/output.jpg")
122 assert cmd is not None
123
124
125@pytest.mark.asyncio
126async def test_compression_get_command_unknown():
127 with patch('os.getenv', return_value="https://example.com"):
128 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
129 with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"):
130 import importlib
131 import cogs.compression
132 importlib.reload(cogs.compression)
133 from cogs.compression import Compression
134
135 cog = Compression.__new__(Compression)
136 cog.temp_dir = "/tmp"
137
138 cmd = cog._get_compression_command("Unknown", "/tmp/test.xyz", "/tmp/output.xyz")
139 assert cmd is None
diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py
deleted file mode 100644
index 28ccfe1..0000000
--- a/tests/cogs/test_cron.py
+++ /dev/null
@@ -1,147 +0,0 @@
1import pytest
2from datetime import datetime
3from unittest.mock import MagicMock, patch
4
5from cogs.cron import Cron
6from cogs.utils.database import _ConnectionContextManager
7
8
9@pytest.fixture
10def mock_bot():
11 bot = MagicMock()
12 bot.loop = MagicMock()
13 bot.is_closed = MagicMock(return_value=False)
14 bot.wait_until_ready = MagicMock()
15 bot.loop.create_task = MagicMock(return_value=MagicMock())
16 return bot
17
18
19def create_cog(mock_bot):
20 with patch.object(Cron, "__init__", lambda self, bot: None):
21 with patch("cogs.cron.init_database"):
22 cog = Cron.__new__(Cron)
23 cog.bot = mock_bot
24 cog.scheduled_tasks = []
25 cog.task = MagicMock()
26 return cog
27
28
29class TestLoadTasks:
30 def test_load_tasks_with_data(self, mock_bot):
31 mock_conn = MagicMock()
32 mock_cursor = MagicMock()
33 mock_cursor.fetchall.return_value = [
34 (1, "* * * * *", "audio", 123, 1, None, "test message")
35 ]
36 mock_conn.cursor.return_value = mock_cursor
37
38 # Create a context manager mock
39 mock_ctx = MagicMock()
40 mock_ctx.__enter__ = MagicMock(return_value=mock_conn)
41 mock_ctx.__exit__ = MagicMock(return_value=False)
42
43 with patch("cogs.cron.get_connection", return_value=mock_ctx):
44 cog = Cron.__new__(Cron)
45 cog.bot = mock_bot
46 cog.scheduled_tasks = []
47 cog.task = MagicMock()
48 cog.load_tasks()
49 assert len(cog.scheduled_tasks) == 1
50 assert cog.scheduled_tasks[0]["id"] == 1
51
52class TestSaveTasks:
53 def test_save_tasks(self, mock_bot):
54 cog = create_cog(mock_bot)
55 cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}]
56
57 mock_conn = MagicMock()
58 mock_cursor = MagicMock()
59 mock_conn.cursor.return_value = mock_cursor
60
61 # Create a context manager mock
62 mock_ctx = MagicMock()
63 mock_ctx.__enter__ = MagicMock(return_value=mock_conn)
64 mock_ctx.__exit__ = MagicMock(return_value=False)
65
66 with patch("cogs.cron.get_connection", return_value=mock_ctx):
67 cog.save_tasks()
68 assert len(cog.scheduled_tasks) == 1
69
70class TestShouldRunTask:
71 def test_should_run_task_invalid_cron(self, mock_bot):
72 cog = create_cog(mock_bot)
73 task = {"cron_expression": "invalid"}
74 now = datetime.now()
75 result = cog.should_run_task(task, now)
76 assert result is False
77
78 def test_should_run_task_missing_cron_expression(self, mock_bot):
79 cog = create_cog(mock_bot)
80 task = {}
81 now = datetime.now()
82 result = cog.should_run_task(task, now)
83 assert result is False
84
85class TestCogUnload:
86 def test_cog_unload_cancels_task(self, mock_bot):
87 with patch.object(Cron, "__init__", lambda self, bot: None):
88 cog = Cron.__new__(Cron)
89 cog.bot = mock_bot
90 cog.task = MagicMock()
91
92 cog.cog_unload()
93 cog.task.cancel.assert_called_once()
94
95class TestTaskManagement:
96 def test_add_task_increments_id(self, mock_bot):
97 cog = create_cog(mock_bot)
98 cog.scheduled_tasks = [
99 {"id": 1, "cron_expression": "* * * * *"},
100 {"id": 2, "cron_expression": "0 * * * *"},
101 ]
102
103 new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1
104 assert new_id == 3
105
106 def test_find_task_by_id(self, mock_bot):
107 cog = create_cog(mock_bot)
108 cog.scheduled_tasks = [
109 {"id": 1, "cron_expression": "* * * * *"},
110 {"id": 2, "cron_expression": "0 * * * *"},
111 ]
112
113 found_task = next((t for t in cog.scheduled_tasks if t["id"] == 2), None)
114 assert found_task is not None
115 assert found_task["cron_expression"] == "0 * * * *"
116
117 def test_remove_task_by_index(self, mock_bot):
118 cog = create_cog(mock_bot)
119 cog.scheduled_tasks = [
120 {"id": 1, "cron_expression": "* * * * *"},
121 {"id": 2, "cron_expression": "0 * * * *"},
122 ]
123
124 cog.scheduled_tasks.pop(0)
125 assert len(cog.scheduled_tasks) == 1
126 assert cog.scheduled_tasks[0]["id"] == 2
127
128 def test_toggle_task_active(self, mock_bot):
129 cog = create_cog(mock_bot)
130 cog.scheduled_tasks = [{"id": 1, "active": True}]
131
132 cog.scheduled_tasks[0]["active"] = False
133 assert cog.scheduled_tasks[0]["active"] is False
134
135 cog.scheduled_tasks[0]["active"] = True
136 assert cog.scheduled_tasks[0]["active"] is True
137
138class TestTaskTypes:
139 def test_task_type_audio_aliases(self, mock_bot):
140 audio_types = ("zagraj_audio", "audio")
141 for task_type in audio_types:
142 assert task_type in ("zagraj_audio", "audio")
143
144 def test_task_type_message_aliases(self, mock_bot):
145 message_types = ("wyślij_wiadomość", "send_message")
146 for task_type in message_types:
147 assert task_type in ("wyślij_wiadomość", "send_message")
diff --git a/tests/cogs/test_cron_integration.py b/tests/cogs/test_cron_integration.py
deleted file mode 100644
index f56fc46..0000000
--- a/tests/cogs/test_cron_integration.py
+++ /dev/null
@@ -1,39 +0,0 @@
1import pytest
2import time
3from unittest.mock import MagicMock, AsyncMock, patch
4
5
6@pytest.fixture
7def cron_cog(bot):
8 # Patch create_task to prevent background task creation
9 with patch.object(bot.loop, 'create_task', return_value=MagicMock()):
10 from cogs.cron import Cron
11 cog = Cron(bot)
12 yield cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_cron_job_execution(cron_cog):
18 """Test cron job executes when due."""
19 cog = cron_cog
20
21 job = {
22 "id": "test_job",
23 "cron_expression": "* * * * *",
24 "type": "message",
25 "channel_id": 456,
26 "active": True,
27 "message": "Test",
28 }
29 cog.scheduled_tasks.append(job)
30
31 # Mock the execute_task method
32 with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec:
33 # Mock should_run_task to return True
34 with patch.object(cog, 'should_run_task', return_value=True):
35 # Test single iteration of schedule_checker logic
36 for task in cog.scheduled_tasks:
37 if task.get("active", True) and cog.should_run_task(task, None):
38 await cog.execute_task(task)
39 mock_exec.assert_called_once_with(job) \ No newline at end of file
diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py
deleted file mode 100644
index 125a103..0000000
--- a/tests/cogs/test_downloader.py
+++ /dev/null
@@ -1,93 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from conftest import MockApplicationContext, MockMessage
5
6
7@pytest.fixture
8def downloader_cog(bot):
9 from cogs.downloader import Downloader
10 cog = Downloader(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async def test_download_command_success_file(downloader_cog, mock_channel, mock_guild, bot):
16 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
17
18 mock_file_path = "/tmp/test_video.mp4"
19
20 with patch('cogs.downloader.Downloader.download_standalone', return_value={
21 "status": "success",
22 "type": "file",
23 "content": mock_file_path,
24 "filename": "test_video.mp4"
25 }):
26 with patch('discord.File', return_value=MagicMock()) as mock_file:
27 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
28 await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=False, fformat="mp4", filename_user=None)
29 mock_respond.assert_called_once()
30 call_args = mock_respond.call_args
31 assert 'file' in str(call_args) or mock_file.called
32
33
34@pytest.mark.asyncio
35async def test_download_command_success_url(downloader_cog, mock_channel, mock_guild, bot):
36 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
37
38 with patch('cogs.downloader.Downloader.download_standalone', return_value={
39 "status": "success",
40 "type": "url",
41 "content": "https://catbox.2137697.xyz/sperma/test.mp4"
42 }):
43 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
44 await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=True, fformat="mp4", filename_user=None)
45 mock_respond.assert_called_once()
46 call_args = mock_respond.call_args
47 assert "pobioron" in str(call_args)
48
49
50@pytest.mark.asyncio
51async def test_download_command_error(downloader_cog, mock_channel, mock_guild, bot):
52 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
53
54 with patch('cogs.downloader.Downloader.download_standalone', return_value={
55 "status": "error",
56 "type": "error",
57 "content": "Download failed",
58 "details": "Connection timeout"
59 }):
60 with patch('cogs.downloader.logger') as mock_logger:
61 await downloader_cog.download.callback(downloader_cog, ctx, url="https://badurl.com", catbox=False, fformat="mp4", filename_user=None)
62 assert mock_logger.error.called
63
64
65@pytest.mark.asyncio
66async def test_message_download_with_url(downloader_cog, mock_channel, mock_guild, bot, mock_user):
67 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
68
69 mock_message = MagicMock()
70 mock_message.content = "Check this out https://example.com/video"
71
72 with patch('cogs.downloader.Downloader.download_standalone', return_value={
73 "status": "success",
74 "type": "file",
75 "content": "/tmp/test.mp4"
76 }):
77 with patch.object(downloader_cog, 'download', new_callable=AsyncMock) as mock_download:
78 await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message)
79 mock_download.assert_called_once()
80
81
82@pytest.mark.asyncio
83async def test_message_download_no_url(downloader_cog, mock_channel, mock_guild, bot):
84 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
85
86 mock_message = MagicMock()
87 mock_message.content = "No URL here"
88
89 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
90 await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message)
91 mock_respond.assert_called_once()
92 call_args = mock_respond.call_args
93 assert "Brak" in str(call_args)
diff --git a/tests/cogs/test_downloader_integration.py b/tests/cogs/test_downloader_integration.py
deleted file mode 100644
index c7c9790..0000000
--- a/tests/cogs/test_downloader_integration.py
+++ /dev/null
@@ -1,37 +0,0 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch
3
4
5@pytest.fixture
6def downloader_cog(bot):
7 from cogs.downloader import Downloader
8 cog = Downloader(bot)
9 return cog
10
11
12@pytest.mark.slow
13@pytest.mark.asyncio
14async def test_download_command_flow(downloader_cog, bot):
15 """Test download command with URL processing."""
16 cog = downloader_cog
17
18 ctx = MagicMock()
19 ctx.author = MagicMock()
20 ctx.guild = MagicMock()
21 ctx.channel = MagicMock()
22 ctx.respond = AsyncMock()
23 ctx.defer = AsyncMock()
24 ctx.followup = AsyncMock()
25 ctx.voice_client = None
26
27 # Mock the download_standalone static method
28 mock_response = {
29 "status": "success",
30 "type": "url",
31 "content": "http://example.com/video.mp4"
32 }
33
34 with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response):
35 await cog.download.callback(cog, ctx, url="http://youtube.com/test")
36
37 ctx.respond.assert_called_once()
diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py
deleted file mode 100644
index 21e9a2e..0000000
--- a/tests/cogs/test_math_commands.py
+++ /dev/null
@@ -1,55 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from conftest import MockApplicationContext
4
5
6@pytest.fixture
7def math_cog(bot):
8 from cogs.math_commands import Math
9 cog = Math(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async def test_math_command_valid_expression(math_cog, mock_channel, mock_guild, bot):
15 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
16
17 mock_process = MagicMock()
18 mock_process.returncode = 0
19 mock_process.communicate = AsyncMock(return_value=(b"4", b""))
20
21 with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process) as mock_subprocess:
22 with patch('cogs.math_commands.re.search', return_value=None):
23 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
24 await math_cog.math.callback(math_cog, ctx, equation="2+2")
25 mock_subprocess.assert_called_once()
26 mock_respond.assert_called_once()
27
28
29@pytest.mark.asyncio
30async def test_math_command_invalid_expression(math_cog, mock_channel, mock_guild, bot):
31 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
32
33 with patch('cogs.math_commands.re.search', return_value=MagicMock()):
34 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
35 await math_cog.math.callback(math_cog, ctx, equation="eval(1)")
36 mock_respond.assert_called_once()
37 args = mock_respond.call_args
38 assert "Invalid" in str(args)
39
40
41@pytest.mark.asyncio
42async def test_math_command_process_error(math_cog, mock_channel, mock_guild, bot):
43 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
44
45 mock_process = MagicMock()
46 mock_process.returncode = 1
47 mock_process.communicate = AsyncMock(return_value=(b"", b"Error: invalid expression"))
48
49 with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process):
50 with patch('cogs.math_commands.re.search', return_value=None):
51 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
52 await math_cog.math.callback(math_cog, ctx, equation="2++")
53 mock_respond.assert_called_once()
54 args = mock_respond.call_args
55 assert "error" in str(args).lower() or "Error" in str(args)
diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py
deleted file mode 100644
index 98c6f6b..0000000
--- a/tests/cogs/test_music_audio.py
+++ /dev/null
@@ -1,93 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4from conftest import MockApplicationContext
5
6
7@pytest.mark.asyncio
8async def test_validate_voice_channel_not_in_voice():
9 from cogs.voice import VoiceManager as AudioPlaybackManager
10
11 mock_ctx = MagicMock()
12 mock_ctx.author.voice = None
13 mock_ctx.respond = AsyncMock()
14
15 result = await AudioPlaybackManager.validate_voice_channel(mock_ctx)
16
17 assert result is None
18 mock_ctx.respond.assert_called_once()
19
20
21@pytest.mark.asyncio
22async def test_validate_voice_channel_in_voice():
23 from cogs.voice import VoiceManager as AudioPlaybackManager
24
25 mock_channel = MagicMock()
26 mock_ctx = MagicMock()
27 mock_ctx.author.voice = MagicMock()
28 mock_ctx.author.voice.channel = mock_channel
29 mock_ctx.respond = AsyncMock()
30
31 result = await AudioPlaybackManager.validate_voice_channel(mock_ctx)
32
33 assert result == mock_channel
34
35
36@pytest.mark.asyncio
37async def test_prepare_file_source_file_not_found():
38 from cogs.voice import VoiceManager as AudioPlaybackManager
39
40 display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3")
41
42 assert display_name is None
43 assert absolute_path is None
44
45
46@pytest.mark.asyncio
47async def test_prepare_file_source_absolute_path():
48 from cogs.voice import VoiceManager as AudioPlaybackManager
49
50 with patch('os.path.exists', return_value=True):
51 with patch('os.path.isabs', return_value=True):
52 display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/tmp/test.mp3")
53
54 assert display_name == "test"
55 assert absolute_path == "/tmp/test.mp3"
56
57
58@pytest.mark.asyncio
59async def test_prepare_source_url():
60 from cogs.voice import VoiceManager as AudioPlaybackManager
61
62 source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream")
63
64 assert is_url == True
65 assert source == "https://example.com/stream"
66
67
68@pytest.mark.asyncio
69async def test_prepare_source_local_file():
70 from cogs.voice import VoiceManager as AudioPlaybackManager
71
72 with patch('os.path.exists', return_value=True):
73 source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3")
74
75 assert is_url == False
76 assert display_name == "test"
77
78
79@pytest.mark.asyncio
80async def test_cleanup_temp_file():
81 from cogs.voice import VoiceManager as AudioPlaybackManager
82
83 with patch('os.path.exists', return_value=True):
84 with patch('os.remove'):
85 await AudioPlaybackManager.cleanup_temp_file("/tmp/test.mp3")
86
87
88@pytest.mark.asyncio
89async def test_cleanup_temp_file_not_exists():
90 from cogs.voice import VoiceManager as AudioPlaybackManager
91
92 with patch('os.path.exists', return_value=False):
93 await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3")
diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py
deleted file mode 100644
index 8e19423..0000000
--- a/tests/cogs/test_music_external_play.py
+++ /dev/null
@@ -1,109 +0,0 @@
1import pytest
2import os
3import asyncio
4import tempfile
5from unittest.mock import AsyncMock, patch, MagicMock
6from conftest import MockApplicationContext
7
8
9@pytest.fixture
10def play_commands_cog(bot):
11 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
12 from cogs.music.play_commands import PlayCommands
13 cog = PlayCommands(bot)
14 return cog
15
16
17@pytest.fixture
18def temp_mp3_file():
19 with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
20 f.write(b'fake mp3 content')
21 temp_path = f.name
22 yield temp_path
23 if os.path.exists(temp_path):
24 os.remove(temp_path)
25
26
27@pytest.mark.asyncio
28async def test_external_url_pattern_matches_youtube(play_commands_cog):
29 from cogs.music.play_commands import EXTERNAL_URL_PATTERN
30
31 assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test")
32 assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test")
33 assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test")
34
35
36@pytest.mark.asyncio
37async def test_external_url_pattern_matches_soundcloud(play_commands_cog):
38 from cogs.music.play_commands import EXTERNAL_URL_PATTERN
39
40 assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track")
41 assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks")
42
43
44@pytest.mark.asyncio
45async def test_external_url_pattern_matches_bandcamp(play_commands_cog):
46 from cogs.music.play_commands import EXTERNAL_URL_PATTERN
47
48 assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track")
49
50
51@pytest.mark.asyncio
52async def test_external_url_pattern_rejects_copyparty(play_commands_cog):
53 from cogs.music.play_commands import EXTERNAL_URL_PATTERN
54
55 assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3")
56 assert not EXTERNAL_URL_PATTERN.match("some search query")
57
58
59@pytest.mark.asyncio
60async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot):
61 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
62
63 with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler:
64 mock_handler.return_value = None
65 mock_defer = AsyncMock()
66 ctx.defer = mock_defer
67 ctx.respond = AsyncMock()
68
69 await play_commands_cog.play.callback(
70 play_commands_cog, ctx,
71 melodia="https://youtube.com/watch?v=test",
72 file=None, compressed=False
73 )
74
75 mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test")
76
77
78@pytest.mark.asyncio
79async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot):
80 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
81
82 with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler:
83 mock_handler.return_value = None
84 mock_defer = AsyncMock()
85 ctx.defer = mock_defer
86 ctx.respond = AsyncMock()
87
88 await play_commands_cog.play.callback(
89 play_commands_cog, ctx,
90 melodia="some search query",
91 file=None, compressed=False
92 )
93
94 mock_handler.assert_called_once_with(ctx, "some search query", False)
95
96
97@pytest.mark.asyncio
98async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch):
99 monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/")
100 monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword")
101
102 mock_response = MagicMock()
103 mock_response.status_code = 200
104 mock_response.text = '{"fileurl": "https://example.com/file.mp3"}'
105 mock_response.json = MagicMock(return_value={"fileurl": "https://example.com/file.mp3"})
106
107 with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post:
108 await play_commands_cog._upload_to_copyparty_async(temp_mp3_file)
109 mock_post.assert_called_once()
diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py
deleted file mode 100644
index 692e1c1..0000000
--- a/tests/cogs/test_music_integration.py
+++ /dev/null
@@ -1,46 +0,0 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
3import sys
4sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests')
5from conftest import MockApplicationContext
6
7
8@pytest.fixture
9def music_cog(bot):
10 from cogs.music.play_commands import PlayCommands
11 cog = PlayCommands(bot)
12 return cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_music_join_stop(music_cog, bot):
18 """Test music join and stop commands."""
19 from cogs.music.join import Join
20 from cogs.music.stop import Stop
21
22 join_cog = Join(bot)
23 stop_cog = Stop(bot)
24
25 ctx = MockApplicationContext(bot=bot)
26 ctx.author.voice = MagicMock()
27 ctx.author.voice.channel = MagicMock()
28 ctx.guild = MagicMock()
29
30 # Test join
31 mock_vm = MagicMock()
32 mock_vm.connect = AsyncMock()
33 with patch('cogs.music.join.get_voice_manager', return_value=mock_vm):
34 with patch.object(ctx, 'respond', new_callable=AsyncMock):
35 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
36 await join_cog.join.callback(join_cog, ctx)
37 mock_vm.connect.assert_called_once_with(ctx.author.voice.channel)
38
39 # Test stop
40 mock_vm_stop = MagicMock()
41 mock_vm_stop.disconnect = AsyncMock()
42 with patch('cogs.music.stop.get_voice_manager', return_value=mock_vm_stop):
43 with patch.object(ctx, 'respond', new_callable=AsyncMock):
44 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
45 await stop_cog.stop.callback(stop_cog, ctx)
46 mock_vm_stop.disconnect.assert_called_once_with(ctx.guild)
diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py
deleted file mode 100644
index b40d93a..0000000
--- a/tests/cogs/test_music_join.py
+++ /dev/null
@@ -1,56 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from conftest import MockApplicationContext
4
5
6@pytest.fixture
7def join_cog(bot):
8 from cogs.music.join import Join
9 cog = Join(bot)
10 return cog
11
12
13@pytest.fixture
14def stop_cog(bot):
15 from cogs.music.stop import Stop
16 cog = Stop(bot)
17 return cog
18
19
20@pytest.mark.asyncio
21async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, mock_user):
22 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
23 ctx.user = mock_user
24 ctx.author = mock_user
25
26 mock_voice_channel = MagicMock()
27 ctx.author.voice = MagicMock()
28 ctx.author.voice.channel = mock_voice_channel
29
30 with patch('cogs.music.join.get_voice_manager') as mock_get_vm:
31 mock_vm = MagicMock()
32 mock_vm.connect = AsyncMock()
33 mock_get_vm.return_value = mock_vm
34
35 with patch.object(ctx, 'respond', new_callable=AsyncMock):
36 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
37 await join_cog.join.callback(join_cog, ctx)
38 mock_vm.connect.assert_called_once_with(mock_voice_channel)
39
40
41@pytest.mark.asyncio
42async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, mock_user):
43 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
44 ctx.user = mock_user
45 ctx.author = mock_user
46
47 with patch('cogs.music.stop.get_voice_manager') as mock_get_vm:
48 mock_vm = MagicMock()
49 mock_vm.disconnect = AsyncMock()
50 mock_get_vm.return_value = mock_vm
51
52 with patch.object(ctx, 'respond', new_callable=AsyncMock):
53 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
54 await stop_cog.stop.callback(stop_cog, ctx)
55
56 mock_vm.disconnect.assert_called_once_with(mock_guild)
diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py
deleted file mode 100644
index 7684729..0000000
--- a/tests/cogs/test_music_play.py
+++ /dev/null
@@ -1,104 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def play_commands_cog(bot):
9 with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"):
10 from cogs.music.play_commands import PlayCommands
11 cog = PlayCommands(bot)
12 return cog
13
14
15@pytest.fixture
16def loop_cog(bot):
17 from cogs.music.loop import Loop
18 cog = Loop(bot)
19 return cog
20
21
22@pytest.mark.asyncio
23async def test_create_search_embed_empty(play_commands_cog):
24 embed, view = await play_commands_cog._create_search_embed([])
25
26 assert embed.title == "Wyniki wyszukiwania"
27 assert len(view.children) == 0
28
29
30@pytest.mark.asyncio
31async def test_create_search_embed_with_candidates(play_commands_cog):
32 candidates = [
33 "https://example.com/test1.mp3",
34 "https://example.com/test2.mp3"
35 ]
36
37 embed, view = await play_commands_cog._create_search_embed(candidates)
38
39 assert embed.title == "Wyniki wyszukiwania"
40 assert len(embed.fields) == 2
41
42
43@pytest.mark.asyncio
44async def test_play_command_no_file_or_search(play_commands_cog, mock_channel, mock_guild, bot):
45 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
46
47 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
48 await play_commands_cog.play.callback(play_commands_cog, ctx, melodia=None, file=None, compressed=False)
49 mock_respond.assert_called_once()
50 args = mock_respond.call_args
51 assert "Musisz podać" in str(args)
52
53
54@pytest.mark.asyncio
55async def test_play_command_both_file_and_search(play_commands_cog, mock_channel, mock_guild, bot):
56 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
57
58 mock_file = MagicMock()
59 mock_file.filename = "test.mp3"
60
61 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
62 await play_commands_cog.play.callback(play_commands_cog, ctx, melodia="test", file=mock_file, compressed=False)
63 mock_respond.assert_called_once()
64 args = mock_respond.call_args
65 assert "nie oba" in str(args) or "LUB" in str(args)
66
67
68def test_loop_cog_is_looping_initial(loop_cog):
69 assert loop_cog.is_looping(123) == False
70
71
72def test_loop_cog_set_looping(loop_cog):
73 loop_cog.set_looping(123, True)
74 assert loop_cog.is_looping(123) == True
75
76
77@pytest.mark.asyncio
78async def test_loop_command_toggle_on(loop_cog, mock_channel, mock_guild, bot, mock_user):
79 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
80 ctx.user = mock_user
81 ctx.author = mock_user
82 ctx.guild.id = 123
83
84 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
85 await loop_cog.loop.callback(loop_cog, ctx)
86 mock_respond.assert_called_once()
87 args = mock_respond.call_args
88 assert "włączony" in str(args)
89
90
91@pytest.mark.asyncio
92async def test_loop_command_toggle_off(loop_cog, mock_channel, mock_guild, bot, mock_user):
93 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
94 ctx.user = mock_user
95 ctx.author = mock_user
96 ctx.guild.id = 456
97
98 loop_cog.set_looping(456, True)
99
100 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
101 await loop_cog.loop.callback(loop_cog, ctx)
102 mock_respond.assert_called_once()
103 args = mock_respond.call_args
104 assert "wyłączony" in str(args)
diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py
deleted file mode 100644
index b5cb31f..0000000
--- a/tests/cogs/test_qr.py
+++ /dev/null
@@ -1,75 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock, mock_open
3import os
4from cogs.qr import QR
5from conftest import MockApplicationContext
6
7
8@pytest.fixture
9def qr_cog(bot):
10 cog = QR(bot)
11 return cog
12
13
14@pytest.fixture
15def mock_temp_file():
16 with patch('tempfile.NamedTemporaryFile') as mock:
17 mock_file = MagicMock()
18 mock_file.name = "/tmp/test_qr.png"
19 mock.return_value = mock_file
20 yield mock_file
21
22
23@pytest.mark.asyncio
24async def test_qr_generate_success(qr_cog, mock_channel, mock_guild, bot, mock_temp_file):
25 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
26
27 with patch('cogs.qr.QR._generate_qr') as mock_generate:
28 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
29 with patch('os.path.exists', return_value=True):
30 with patch('os.unlink'):
31 await qr_cog.qr.callback(qr_cog, ctx, text="https://example.com")
32 mock_generate.assert_called_once()
33 mock_respond.assert_called_once()
34
35
36@pytest.mark.asyncio
37async def test_qr_generate_empty_text(qr_cog, mock_channel, mock_guild, bot):
38 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
39
40 with patch('cogs.qr.QR._generate_qr'):
41 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
42 with patch('os.path.exists', return_value=False):
43 await qr_cog.qr.callback(qr_cog, ctx, text="")
44 mock_respond.assert_called_once()
45
46
47@pytest.mark.asyncio
48async def test_qr_decode_not_available(qr_cog, mock_channel, mock_guild, bot):
49 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
50
51 mock_attachment = MagicMock()
52 mock_attachment.url = "https://example.com/qr.png"
53
54 with patch('cogs.qr._ensure_qr', return_value=False):
55 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
56 await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment)
57 mock_respond.assert_called_once()
58 args = mock_respond.call_args
59 assert "nie jest dostępne" in str(args) or "not available" in str(args).lower()
60
61
62@pytest.mark.asyncio
63async def test_qr_decode_download_failure(qr_cog, mock_channel, mock_guild, bot):
64 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
65
66 mock_attachment = MagicMock()
67 mock_attachment.url = "https://example.com/qr.png"
68
69 with patch('cogs.qr._ensure_qr', return_value=True):
70 with patch.object(qr_cog, '_download_image', return_value=None):
71 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
72 await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment)
73 mock_respond.assert_called_once()
74 args = mock_respond.call_args
75 assert "Nie udało się" in str(args) or "failed" in str(args).lower()
diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py
deleted file mode 100644
index 76f1b94..0000000
--- a/tests/cogs/test_say.py
+++ /dev/null
@@ -1,38 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch
3from cogs.say import Say
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def say_cog(bot):
9 cog = Say(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async def test_say_command(say_cog, mock_channel, mock_guild, bot):
15 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
16
17 with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send:
18 await say_cog.say.callback(say_cog, ctx, "Hello World")
19 mock_send.assert_called_once_with(content="Hello World")
20
21
22@pytest.mark.asyncio
23async def test_say_command_empty_text(say_cog, mock_channel, mock_guild, bot):
24 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
25
26 with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send:
27 await say_cog.say.callback(say_cog, ctx, "")
28 mock_send.assert_called_once_with(content="")
29
30
31@pytest.mark.asyncio
32async def test_say_command_long_text(say_cog, mock_channel, mock_guild, bot):
33 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
34 long_text = "A" * 2000
35
36 with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send:
37 await say_cog.say.callback(say_cog, ctx, long_text)
38 mock_send.assert_called_once_with(content=long_text)
diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py
deleted file mode 100644
index c9c7d0e..0000000
--- a/tests/cogs/test_search.py
+++ /dev/null
@@ -1,83 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def search_cog(bot):
9 from cogs.search import Search
10 cog = Search(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async def test_search_command_success(search_cog, mock_channel, mock_guild, bot, mock_user):
16 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
17 ctx.user = mock_user
18 ctx.author = mock_user
19
20 search_cog._cached_results[mock_user.id] = {
21 "testfile.mp4": "https://example.com/files/testfile.mp4"
22 }
23
24 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
25 await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4")
26 mock_followup.assert_called_once_with("https://example.com/files/testfile.mp4")
27 assert mock_user.id not in search_cog._cached_results
28
29
30@pytest.mark.asyncio
31async def test_search_command_no_cache(search_cog, mock_channel, mock_guild, bot, mock_user):
32 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
33 ctx.user = mock_user
34 ctx.author = mock_user
35
36 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
37 await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4")
38 mock_followup.assert_called_once_with("Search results expired. Please search again.")
39
40
41@pytest.mark.asyncio
42async def test_search_command_file_not_found(search_cog, mock_channel, mock_guild, bot, mock_user):
43 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
44 ctx.user = mock_user
45 ctx.author = mock_user
46
47 search_cog._cached_results[mock_user.id] = {
48 "testfile.mp4": "https://example.com/files/testfile.mp4"
49 }
50
51 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
52 await search_cog.search.callback(search_cog, ctx, filename="nonexistent.mp4")
53 mock_followup.assert_called_once_with("File 'nonexistent.mp4' not found.")
54
55
56@pytest.mark.asyncio
57async def test_search_command_empty_filename(search_cog, mock_channel, mock_guild, bot, mock_user):
58 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
59 ctx.user = mock_user
60 ctx.author = mock_user
61
62 with patch.object(ctx, 'reply', new_callable=AsyncMock) as mock_reply:
63 await search_cog.search.callback(search_cog, ctx, filename="")
64 mock_reply.assert_called_once_with("Please select a file from the autocomplete options.")
65
66
67@pytest.mark.asyncio
68async def test_file_autocomplete(search_cog, mock_channel, mock_guild, bot, mock_user):
69 mock_autocomplete_ctx = MagicMock()
70 mock_autocomplete_ctx.value = "test"
71 mock_autocomplete_ctx.interaction = MagicMock()
72 mock_autocomplete_ctx.interaction.user = mock_user
73
74 with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search:
75 mock_search.return_value = [
76 "https://example.com/files/test1.mp4",
77 "https://example.com/files/test2.mp4"
78 ]
79
80 choices = await search_cog.file_autocomplete(mock_autocomplete_ctx)
81
82 assert len(choices) == 2
83 assert mock_user.id in search_cog._cached_results
diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py
deleted file mode 100644
index a60a139..0000000
--- a/tests/cogs/test_trigger.py
+++ /dev/null
@@ -1,106 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import sqlite3
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def trigger_cog(bot):
9 with patch('cogs.trigger.get_connection') as mock_conn:
10 mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock())
11 mock_conn.return_value.__exit__ = MagicMock(return_value=False)
12 from cogs.trigger import Trigger
13 cog = Trigger(bot)
14 return cog
15
16
17@pytest.fixture
18def mock_db_connection():
19 with patch('cogs.trigger.get_connection') as mock_get_conn:
20 mock_conn = MagicMock()
21 mock_cursor = MagicMock()
22 mock_conn.cursor.return_value = mock_cursor
23 mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
24 mock_get_conn.return_value.__exit__ = MagicMock(return_value=False)
25 yield mock_conn, mock_cursor
26
27
28@pytest.mark.asyncio
29async def test_trigger_create_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection):
30 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
31
32 mock_conn, mock_cursor = mock_db_connection
33 mock_cursor.fetchone.return_value = None
34
35 with patch('cogs.trigger.get_connection') as mock_get_conn:
36 mock_conn = MagicMock()
37 mock_cursor = MagicMock()
38 mock_conn.cursor.return_value = mock_cursor
39 mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
40 mock_get_conn.return_value.__exit__ = MagicMock(return_value=False)
41
42 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
43 await trigger_cog.create.callback(trigger_cog, ctx, name="test_trigger")
44 mock_respond.assert_called_once_with("trigger added")
45
46
47@pytest.mark.asyncio
48async def test_trigger_add_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection):
49 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
50
51 with patch('cogs.trigger.get_connection') as mock_get_conn:
52 mock_conn = MagicMock()
53 mock_cursor = MagicMock()
54 mock_cursor.fetchone.return_value = (1,) # trigger exists
55 mock_conn.cursor.return_value = mock_cursor
56 mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
57 mock_get_conn.return_value.__exit__ = MagicMock(return_value=False)
58
59 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
60 await trigger_cog.add.callback(trigger_cog, ctx, name="test_trigger", trigger="hello", response="Hi there!")
61 mock_respond.assert_called_once_with("trigger updated")
62
63
64@pytest.mark.asyncio
65async def test_trigger_add_command_not_found(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection):
66 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
67
68 with patch('cogs.trigger.get_connection') as mock_get_conn:
69 mock_conn = MagicMock()
70 mock_cursor = MagicMock()
71 mock_cursor.fetchone.return_value = None # trigger not found
72 mock_conn.cursor.return_value = mock_cursor
73 mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
74 mock_get_conn.return_value.__exit__ = MagicMock(return_value=False)
75
76 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
77 await trigger_cog.add.callback(trigger_cog, ctx, name="nonexistent", trigger="hello", response="Hi")
78 mock_respond.assert_called_once_with("Trigger not found.")
79
80
81@pytest.mark.asyncio
82async def test_trigger_delete_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection):
83 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
84
85 with patch('cogs.trigger.get_connection') as mock_get_conn:
86 mock_conn = MagicMock()
87 mock_cursor = MagicMock()
88 mock_cursor.fetchone.return_value = (1,) # trigger exists
89 mock_conn.cursor.return_value = mock_cursor
90 mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
91 mock_get_conn.return_value.__exit__ = MagicMock(return_value=False)
92
93 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
94 await trigger_cog.delete.callback(trigger_cog, ctx, name="test_trigger")
95 mock_respond.assert_called_once_with("trigger deleted")
96
97
98@pytest.mark.asyncio
99async def test_trigger_help_command(trigger_cog, mock_channel, mock_guild, bot):
100 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
101
102 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
103 await trigger_cog.help.callback(trigger_cog, ctx)
104 mock_respond.assert_called_once()
105 args = mock_respond.call_args
106 assert "Trigger create" in str(args)
diff --git a/tests/cogs/test_trigger_integration.py b/tests/cogs/test_trigger_integration.py
deleted file mode 100644
index 4828387..0000000
--- a/tests/cogs/test_trigger_integration.py
+++ /dev/null
@@ -1,54 +0,0 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch
3from cogs.trigger import Trigger
4
5
6@pytest.fixture
7def trigger_cog(bot):
8 from cogs.utils.database import init_database
9 init_database()
10 from cogs.trigger import Trigger
11 cog = Trigger(bot)
12 return cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_trigger_response_flow(trigger_cog):
18 """Test trigger creation and response in a simulated message flow."""
19 cog = trigger_cog
20
21 ctx = MagicMock()
22 ctx.author = MagicMock()
23 ctx.author.id = 123
24 ctx.guild = MagicMock()
25 ctx.guild.id = 456
26 ctx.respond = AsyncMock()
27
28 # Create a trigger using the create command
29 await cog.create.callback(cog, ctx, name="test_trigger")
30
31 # Add a keyword and response
32 await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world")
33
34 # Verify trigger was created - check DB
35 from cogs.utils.database import get_connection
36 with get_connection() as conn:
37 c = conn.cursor()
38 c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",))
39 result = c.fetchone()
40 assert result is not None
41
42 # Simulate a message that should trigger
43 message = MagicMock()
44 message.author = ctx.author
45 message.author.bot = False
46 message.guild = ctx.guild
47 message.content = "say hello to me"
48 message.channel.send = AsyncMock() # Make send awaitable
49
50 # Check if trigger matches using the static method
51 await Trigger.trigger(message)
52
53 # Verify message.channel.send was called with the response
54 message.channel.send.assert_called_once_with("world")
diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py
deleted file mode 100644
index c6563e6..0000000
--- a/tests/cogs/test_tts.py
+++ /dev/null
@@ -1,58 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def tts_cog(bot):
9 from cogs.tts import TTS
10 cog = TTS(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async def test_tts_file_output(tts_cog, mock_channel, mock_guild, bot, mock_user):
16 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
17 ctx.user = mock_user
18 ctx.author = mock_user
19
20 "/tmp/test_tts.mp3"
21
22 with patch('cogs.tts.gTTS') as _:
23 with patch('os.path.exists', return_value=True):
24 with patch('os.unlink'):
25 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
26 with patch('cogs.tts.get_volume', return_value=1.0):
27 await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file")
28 mock_respond.assert_called_once()
29
30
31@pytest.mark.asyncio
32async def test_tts_voice_output_not_in_channel(tts_cog, mock_channel, mock_guild, bot, mock_user):
33 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
34 ctx.user = mock_user
35 ctx.author = mock_user
36 ctx.author.voice = None
37
38 with patch('cogs.tts.gTTS'):
39 with patch('os.path.exists', return_value=True):
40 with patch('os.unlink'):
41 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
42 with patch('cogs.tts.get_volume', return_value=1.0):
43 await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="vc")
44
45
46@pytest.mark.asyncio
47async def test_tts_error_handling(tts_cog, mock_channel, mock_guild, bot, mock_user):
48 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
49 ctx.user = mock_user
50 ctx.author = mock_user
51
52 with patch('cogs.tts.gTTS', side_effect=Exception("TTS Error")):
53 with patch('os.path.exists', return_value=False):
54 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
55 await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file")
56 mock_respond.assert_called_once()
57 args = mock_respond.call_args
58 assert "Error" in str(args) or "eror" in str(args)
diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py
deleted file mode 100644
index 0777126..0000000
--- a/tests/cogs/test_uploader.py
+++ /dev/null
@@ -1,226 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4import discord
5from conftest import MockApplicationContext, MockMessage, MockUser
6
7
8@pytest.fixture
9def uploader_cog(bot):
10 with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'):
11 from cogs.uploader import Uploader
12 cog = Uploader(bot)
13 return cog
14
15
16@pytest.mark.asyncio
17async def test_file_autocomplete(uploader_cog):
18 mock_autocomplete_ctx = MagicMock()
19 mock_autocomplete_ctx.value = "test"
20 mock_interaction = MagicMock()
21 mock_user = MockUser(id=12345)
22 mock_interaction.user = mock_user
23 mock_autocomplete_ctx.interaction = mock_interaction
24
25 with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search:
26 mock_search.return_value = [
27 "https://copyparty.local/testfile1.mp3",
28 "https://copyparty.local/testfile2.mp3"
29 ]
30 result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx)
31 assert len(result) == 2
32 assert result[0].name == "testfile1.mp3"
33
34
35@pytest.mark.asyncio
36async def test_file_autocomplete_empty_query(uploader_cog):
37 mock_autocomplete_ctx = MagicMock()
38 mock_autocomplete_ctx.value = ""
39 mock_interaction = MagicMock()
40 mock_interaction.user = MockUser(id=12345)
41 mock_autocomplete_ctx.interaction = mock_interaction
42
43 with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search:
44 result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx)
45 mock_search.assert_called_once_with("", max_results=25)
46
47
48@pytest.mark.asyncio
49async def test_download_from_url_success(uploader_cog):
50 with patch('requests.get') as mock_get:
51 mock_response = MagicMock()
52 mock_response.status_code = 200
53 mock_response.content = b"test file content"
54 mock_get.return_value = mock_response
55
56 mock_file = MagicMock()
57 mock_file.write = AsyncMock()
58
59 mock_cm = AsyncMock()
60 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
61 mock_cm.__aexit__ = AsyncMock(return_value=None)
62
63 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
64 result = await uploader_cog._download_from_url("https://copyparty.local/file.txt")
65
66 assert result is not None
67 assert "file.txt" in result
68 mock_file.write.assert_called_once_with(b"test file content")
69
70
71@pytest.mark.asyncio
72async def test_download_from_url_failure(uploader_cog):
73 with patch('requests.get') as mock_get:
74 mock_response = MagicMock()
75 mock_response.status_code = 404
76 mock_get.return_value = mock_response
77
78 result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt")
79
80 assert result is None
81
82
83@pytest.mark.asyncio
84async def test_upload_to_copyparty_success(uploader_cog):
85 mock_file = MagicMock()
86 mock_file.read = AsyncMock(return_value=b"test content")
87
88 mock_cm = AsyncMock()
89 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
90 mock_cm.__aexit__ = AsyncMock(return_value=None)
91
92 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
93 with patch('requests.post') as mock_post:
94 mock_response = MagicMock()
95 mock_response.status_code = 200
96 mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"}
97 mock_post.return_value = mock_response
98
99 with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}):
100 result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt")
101
102 assert result == "https://copyparty.local/uploaded.txt"
103
104
105@pytest.mark.asyncio
106async def test_upload_to_copyparty_failure(uploader_cog):
107 mock_file = MagicMock()
108 mock_file.read = AsyncMock(return_value=b"test content")
109
110 mock_cm = AsyncMock()
111 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
112 mock_cm.__aexit__ = AsyncMock(return_value=None)
113
114 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
115 with patch('requests.post') as mock_post:
116 mock_response = MagicMock()
117 mock_response.status_code = 500
118 mock_post.return_value = mock_response
119
120 result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt")
121
122 assert result is None
123
124
125@pytest.mark.asyncio
126async def test_upload_file_no_url(uploader_cog):
127 uploader_cog._uploadurl = None
128
129 result = await uploader_cog._upload_file("/tmp/test.txt")
130
131 assert result is None
132
133
134@pytest.mark.asyncio
135async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot):
136 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
137
138 mock_message = MagicMock()
139 mock_message.attachments = []
140
141 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
142 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
143 mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True)
144
145
146@pytest.mark.asyncio
147async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot):
148 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
149
150 mock_attachment = MagicMock()
151 mock_attachment.filename = "test.txt"
152 mock_attachment.save = AsyncMock()
153
154 mock_message = MagicMock()
155 mock_message.attachments = [mock_attachment]
156
157 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
158 mock_upload.return_value = "https://copyparty.local/uploaded.txt"
159
160 with patch('os.path.exists', return_value=True):
161 with patch('os.remove'):
162 with patch.object(ctx, 'defer', new_callable=AsyncMock):
163 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
164 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
165 mock_respond.assert_called_once()
166 assert "Wysłano" in str(mock_respond.call_args)
167
168
169@pytest.mark.asyncio
170async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot):
171 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
172
173 mock_attachment = MagicMock()
174 mock_attachment.filename = "test.txt"
175 mock_attachment.save = AsyncMock()
176
177 mock_message = MagicMock()
178 mock_message.attachments = [mock_attachment]
179
180 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
181 mock_upload.return_value = None
182
183 with patch('os.path.exists', return_value=True):
184 with patch('os.remove'):
185 with patch.object(ctx, 'defer', new_callable=AsyncMock):
186 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
187 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
188 mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True)
189
190
191@pytest.mark.asyncio
192async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot):
193 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
194
195 mock_attachment = MagicMock()
196 mock_attachment.filename = "test.txt"
197 mock_attachment.save = AsyncMock()
198
199 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
200 mock_upload.return_value = "https://copyparty.local/uploaded.txt"
201
202 with patch('os.path.exists', return_value=True):
203 with patch('os.remove'):
204 with patch.object(ctx, 'defer', new_callable=AsyncMock):
205 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
206 await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment)
207 mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt")
208
209
210@pytest.mark.asyncio
211async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot):
212 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
213
214 mock_attachment = MagicMock()
215 mock_attachment.filename = "test.txt"
216 mock_attachment.save = AsyncMock()
217
218 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
219 mock_upload.return_value = None
220
221 with patch('os.path.exists', return_value=True):
222 with patch('os.remove'):
223 with patch.object(ctx, 'defer', new_callable=AsyncMock):
224 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
225 await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment)
226 mock_respond.assert_called_once_with("Nie udało się wysłać pliku", ephemeral=True) \ No newline at end of file
diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py
deleted file mode 100644
index 17ff839..0000000
--- a/tests/cogs/test_vclogs.py
+++ /dev/null
@@ -1,88 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from conftest import MockApplicationContext
4
5
6def test_format_duration():
7 from cogs.vclogs import format_duration
8
9 assert format_duration(30) == "30s"
10 assert format_duration(90) == "1m 30s"
11 assert format_duration(3661) == "1h 1m 1s"
12
13
14def test_load_activity_data():
15 with patch("cogs.vclogs.get_connection") as mock_conn:
16 mock_cursor = MagicMock()
17 mock_conn.return_value.cursor.return_value = mock_cursor
18 mock_conn.return_value.close.return_value = None
19 mock_cursor.fetchall.return_value = []
20
21 from cogs.vclogs import load_activity_data
22 data = load_activity_data()
23 assert isinstance(data, dict)
24
25
26def test_save_activity_data():
27 with patch("cogs.vclogs.get_connection") as mock_conn:
28 mock_cursor = MagicMock()
29 mock_conn.return_value.cursor.return_value = mock_cursor
30 mock_conn.return_value.commit.return_value = None
31 mock_conn.return_value.close.return_value = None
32
33 from cogs.vclogs import save_activity_data
34 save_activity_data({"123": {"active": True}})
35
36
37@pytest.fixture
38def vclogs_cog(bot):
39 with patch('cogs.vclogs.get_voice_manager'):
40 with patch('cogs.vclogs.get_volume', return_value=1.0):
41 with patch('cogs.vclogs.save_settings'):
42 from cogs.vclogs import VcLogs
43 cog = VcLogs(bot)
44 return cog
45
46
47@pytest.mark.asyncio
48async def test_handle_state_change_deaf_true(vclogs_cog):
49 mock_member = MagicMock()
50 mock_member.display_name = "TestUser"
51 mock_channel = MagicMock()
52
53 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', True, None)
54
55 assert "jest bity przez ojca" in tts_text
56
57
58@pytest.mark.asyncio
59async def test_handle_state_change_deaf_false(vclogs_cog):
60 mock_member = MagicMock()
61 mock_member.display_name = "TestUser"
62 mock_channel = MagicMock()
63
64 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', False, None)
65
66 assert "uciek od ojca" in tts_text
67
68
69@pytest.mark.asyncio
70async def test_handle_state_change_mute_true(vclogs_cog):
71 mock_member = MagicMock()
72 mock_member.display_name = "TestUser"
73 mock_channel = MagicMock()
74
75 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', True, None)
76
77 assert "idzie robić jajecznice" in tts_text
78
79
80@pytest.mark.asyncio
81async def test_handle_state_change_mute_false(vclogs_cog):
82 mock_member = MagicMock()
83 mock_member.display_name = "TestUser"
84 mock_channel = MagicMock()
85
86 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None)
87
88 assert "zrobił jajecznice" in tts_text \ No newline at end of file
diff --git a/tests/cogs/test_vclogs_integration.py b/tests/cogs/test_vclogs_integration.py
deleted file mode 100644
index 9391876..0000000
--- a/tests/cogs/test_vclogs_integration.py
+++ /dev/null
@@ -1,86 +0,0 @@
1import pytest
2import asyncio
3from unittest.mock import MagicMock, AsyncMock, patch
4
5
6@pytest.fixture
7def vclogs_cog(bot):
8 from cogs.vclogs import VcLogs
9 cog = VcLogs(bot)
10 return cog
11
12
13@pytest.mark.slow
14@pytest.mark.asyncio
15async def test_full_voice_session_tracking(vclogs_cog, monkeypatch):
16 """Test complete voice session with AFK transitions using real time sleeps."""
17 cog = vclogs_cog
18
19 # Set up environment
20 monkeypatch.setenv("STATUS_CHANNEL_ID", "123456")
21
22 member = MagicMock()
23 member.id = 123
24 member.guild.id = 456
25 member.bot = False
26 member.display_name = "TestUser"
27 member.guild.get_member.return_value = member
28
29 # Create a mock log channel
30 log_channel = MagicMock()
31 log_channel.send = AsyncMock(return_value=MagicMock())
32
33 # Mock bot.get_channel to return our log channel
34 with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel):
35 # Join voice
36 before = MagicMock()
37 before.channel = None
38 after_join = MagicMock()
39 after_join.channel = MagicMock()
40 after_join.channel.id = 789
41 after_join.channel.name = "Test Voice"
42 after_join.self_deaf = False
43 after_join.self_mute = False
44 after_join.deaf = False
45 after_join.mute = False
46 await cog.on_voice_state_update(member, before, after_join)
47
48 await asyncio.sleep(2) # Simulate 2s active
49
50 # Go AFK (self_deaf)
51 before_afk = after_join
52 after_afk = MagicMock()
53 after_afk.channel = before_afk.channel
54 after_afk.self_deaf = True
55 after_afk.self_mute = False
56 after_afk.deaf = True
57 after_afk.mute = False
58 await cog.on_voice_state_update(member, before_afk, after_afk)
59
60 await asyncio.sleep(3) # Simulate 3s AFK
61
62 # Return from AFK
63 before_back = after_afk
64 after_back = MagicMock()
65 after_back.channel = before_back.channel
66 after_back.self_deaf = False
67 after_back.self_mute = False
68 after_back.deaf = False
69 after_back.mute = False
70 await cog.on_voice_state_update(member, before_back, after_back)
71
72 await asyncio.sleep(1) # Simulate 1s active
73
74 # Leave voice
75 before_leave = after_back
76 after_leave = MagicMock()
77 after_leave.channel = None
78 after_leave.self_deaf = False
79 after_leave.self_mute = False
80 await cog.on_voice_state_update(member, before_leave, after_leave)
81
82 # Verify activity was saved (check DB)
83 from cogs.vclogs import load_activity_data
84 activity_data = load_activity_data()
85 # After leaving, data should be deleted, so check the log channel was called
86 assert log_channel.send.called
diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py
deleted file mode 100644
index 6eb226b..0000000
--- a/tests/cogs/test_voice.py
+++ /dev/null
@@ -1,79 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import asyncio
4from conftest import MockApplicationContext
5
6
7@pytest.mark.asyncio
8async def test_get_voice_manager():
9 from cogs.voice import get_voice_manager, VoiceManager
10
11 mock_bot = MagicMock()
12 mock_bot.user = MagicMock()
13 mock_bot.user.id = 12345
14
15 manager1 = get_voice_manager(mock_bot)
16 manager2 = get_voice_manager(mock_bot)
17
18 assert isinstance(manager1, VoiceManager)
19 assert manager1 is manager2
20
21
22@pytest.mark.asyncio
23async def test_voice_manager_join_channel_not_found():
24 from cogs.voice import VoiceManager
25
26 mock_bot = MagicMock()
27 mock_bot.get_channel.return_value = None
28
29 manager = VoiceManager(mock_bot)
30 result = await manager.join_by_channel_id(999)
31
32 assert result is None
33
34
35@pytest.mark.asyncio
36async def test_voice_manager_join_invalid_channel():
37 from cogs.voice import VoiceManager
38
39 mock_bot = MagicMock()
40 mock_channel = MagicMock()
41 mock_bot.get_channel.return_value = mock_channel
42
43 manager = VoiceManager(mock_bot)
44 result = await manager.join_by_channel_id(999)
45
46 assert result is None
47
48
49@pytest.mark.asyncio
50async def test_voice_manager_get_voice_status_not_connected():
51 from cogs.voice import VoiceManager
52
53 mock_bot = MagicMock()
54 mock_guild = MagicMock()
55
56 with patch('cogs.voice.VoiceManager.get_client', return_value=None):
57 manager = VoiceManager(mock_bot)
58 voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild)
59
60 assert voice_client is None
61 assert is_connected == False
62 assert is_playing == False
63
64
65@pytest.mark.asyncio
66async def test_voice_manager_stop():
67 from cogs.voice import VoiceManager
68
69 mock_bot = MagicMock()
70 mock_guild = MagicMock()
71 mock_voice_client = MagicMock()
72 mock_voice_client.disconnect = AsyncMock()
73
74 with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client):
75 manager = VoiceManager(mock_bot)
76 await manager.stop(mock_guild)
77
78 mock_voice_client.stop.assert_called_once()
79 mock_voice_client.disconnect.assert_called_once()
diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py
deleted file mode 100644
index 4de2e8b..0000000
--- a/tests/cogs/test_web.py
+++ /dev/null
@@ -1,57 +0,0 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from cogs.web import Web
4from conftest import MockApplicationContext
5
6
7@pytest.fixture
8def web_cog(bot):
9 cog = Web(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async def test_web_command_success(web_cog, mock_channel, mock_guild, bot):
15 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
16
17 mock_response = MagicMock()
18 mock_response.status_code = 200
19 mock_response.text = '''
20 <html>
21 <a class="result__a" href="https://example.com">Test Result</a>
22 <a class="result__a" href="https://example.com/2">Test Result 2</a>
23 </html>
24 '''
25
26 with patch('cogs.web.requests.get', return_value=mock_response) as mock_get:
27 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
28 await web_cog.web.callback(web_cog, ctx, query="test query")
29 mock_get.assert_called_once()
30 mock_respond.assert_called_once()
31
32
33@pytest.mark.asyncio
34async def test_web_command_no_results(web_cog, mock_channel, mock_guild, bot):
35 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
36
37 mock_response = MagicMock()
38 mock_response.status_code = 200
39 mock_response.text = '<html><body></body></html>'
40
41 with patch('cogs.web.requests.get', return_value=mock_response):
42 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
43 await web_cog.web.callback(web_cog, ctx, query="test")
44 mock_respond.assert_called_once_with(content="No results found.", ephemeral=True)
45
46
47@pytest.mark.asyncio
48async def test_web_command_http_error(web_cog, mock_channel, mock_guild, bot):
49 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
50
51 mock_response = MagicMock()
52 mock_response.status_code = 500
53
54 with patch('cogs.web.requests.get', return_value=mock_response):
55 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
56 await web_cog.web.callback(web_cog, ctx, query="test")
57 mock_respond.assert_called_once_with(content="Error: 500", ephemeral=True)
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 6b7c3bc..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,228 +0,0 @@
1import asyncio
2import os
3import sys
4import tempfile
5from unittest.mock import AsyncMock, MagicMock
6import pytest
7
8import discord
9from discord.ext import commands
10
11sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12
13
14@pytest.fixture(autouse=True, scope="function")
15def setup_test_env(monkeypatch):
16 test_db = os.path.join(tempfile.gettempdir(), "test_senator.db")
17 if os.path.exists(test_db):
18 os.remove(test_db)
19 monkeypatch.setenv("SENATOR_DB_PATH", test_db)
20 reload_database()
21 yield
22 if os.path.exists(test_db):
23 os.remove(test_db)
24
25
26def reload_database():
27 import cogs.utils.database as db_module
28 db_module._initialized = False
29
30
31class MockUser:
32 def __init__(self, id=12345, name="testuser", discriminator="0001"):
33 self.id = id
34 self.name = name
35 self.discriminator = discriminator
36 self.mention = f"@{name}"
37 self.display_name = name
38 self.avatar = None
39 self.bot = False
40 self.guild = None
41 self._state = MagicMock()
42
43 def __str__(self):
44 return f"{self.name}#{self.discriminator}"
45
46
47class MockChannel:
48 def __init__(self, id=11111, name="test-channel", type=discord.ChannelType.text):
49 self.id = id
50 self.name = name
51 self.type = type
52 self.mention = f"#{name}"
53 self.guild = None
54 self.category = None
55 self._state = MagicMock()
56
57 def send(self, *args, **kwargs):
58 return MagicMock()
59
60
61class MockTextChannel(MockChannel):
62 def __init__(self, id=11111, name="test-channel"):
63 super().__init__(id, name, discord.ChannelType.text)
64
65
66class MockVoiceChannel(MockChannel):
67 def __init__(self, id=11112, name="test-voice"):
68 super().__init__(id, name, discord.ChannelType.voice)
69 self.voice_states = {}
70
71
72class MockGuild:
73 def __init__(self, id=99999, name="Test Server"):
74 self.id = id
75 self.name = name
76 self.channels = []
77 self.roles = []
78 self.emojis = []
79 self.me = MockUser(id=111111, name="Sen Bot")
80 self._state = MagicMock()
81 self._default_role = None
82
83 def get_channel(self, channel_id):
84 for ch in self.channels:
85 if ch.id == channel_id:
86 return ch
87 return None
88
89 def get_role(self, role_id):
90 for role in self.roles:
91 if role.id == role_id:
92 return role
93 return None
94
95 def get_member(self, member_id):
96 return None
97
98
99class MockMessage:
100 def __init__(self, id=22222, content="test message", author=None, channel=None, guild=None):
101 self.id = id
102 self.content = content
103 self.author = author or MockUser()
104 self.channel = channel or MockChannel()
105 self.guild = guild
106 self.attachments = []
107 self.embeds = []
108 self.jump_url = "https://discord.com/channels/..."
109 self.created_at = None
110
111 async def reply(self, *args, **kwargs):
112 return MagicMock()
113 # Stub for reply - kept async to match Discord interface signature
114
115
116class MockInteraction:
117 def __init__(self, user=None, channel=None, guild=None):
118 self.user = user or MockUser()
119 self.channel = channel or MockChannel()
120 self.guild = guild or MockGuild()
121 self.message = None
122 self.response = AsyncMock()
123 self.followup = AsyncMock()
124 self.client = None
125 self.id = 33333
126 self.token = "test_token"
127 self._state = MagicMock()
128
129 async def defer(self, *args, **kwargs):
130 pass
131
132 async def respond(self, *args, **kwargs):
133 pass
134
135 async def delete_original_response(self, *args, **kwargs):
136 pass
137
138 async def send(self, *args, **kwargs):
139 return MagicMock()
140
141
142# Stub for Discord interaction - test implementation only
143class MockApplicationContext:
144 def __init__(self, bot=None, interaction=None, user=None, channel=None, guild=None):
145 self.bot = bot
146 self.interaction = interaction or MockInteraction(user=user, channel=channel, guild=guild)
147 self.user = user or MockUser()
148 self.channel = channel or MockChannel()
149 self.guild = guild or MockGuild()
150 self.message = None
151 self.response = AsyncMock()
152 self.followup = AsyncMock()
153 self.client = self.bot
154 self.id = 33333
155 self.token = "test_token"
156 self._state = self.interaction._state
157 self.author = self.user
158
159 async def defer(self, *args, **kwargs):
160 pass
161 # Stub for defer - tests don't verify interaction state
162
163 async def respond(self, *args, **kwargs):
164 pass
165 # Stub for respond - tests mock the response
166
167 async def delete_original_response(self, *args, **kwargs):
168 pass
169 # Stub for delete_original_response - not used in tests
170
171 async def send(self, *args, **kwargs):
172 return MagicMock()
173 # Stub for send - not used in tests
174
175 async def reply(self, *args, **kwargs):
176 return MagicMock()
177 # Stub for reply - not used in tests
178
179 async def send_followup(self, *args, **kwargs):
180 return MagicMock()
181 # Stub for send_followup - not used in tests
182
183
184@pytest.fixture
185def mock_guild():
186 return MockGuild()
187
188
189@pytest.fixture
190def mock_channel(mock_guild):
191 channel = MockTextChannel()
192 channel.guild = mock_guild
193 return channel
194
195
196@pytest.fixture
197def mock_voice_channel(mock_guild):
198 channel = MockVoiceChannel()
199 channel.guild = mock_guild
200 return channel
201
202
203@pytest.fixture
204def mock_user():
205 return MockUser()
206
207
208@pytest.fixture
209def mock_member(mock_guild):
210 member = MockUser()
211 member.guild = mock_guild
212 return member
213
214
215@pytest.fixture
216def mock_message(mock_channel, mock_user, mock_guild):
217 return MockMessage(channel=mock_channel, author=mock_user, guild=mock_guild)
218
219
220@pytest.fixture
221def mock_interaction(mock_channel, mock_user, mock_guild):
222 return MockInteraction(user=mock_user, channel=mock_channel, guild=mock_guild)
223
224
225@pytest.fixture
226def bot():
227 intents = discord.Intents.all()
228 return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop())