From 3e69185296521108216a96a668fb1e99e36a3d7e Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Fri, 10 Apr 2026 02:13:04 +0200 Subject: [PATCH] feat(events): add message update, role, and voice state update handlers - Implemented `handle_message_update` to log message edits. - Created role event handlers for role creation, update, and deletion. - Added voice state update handling to log channel changes. - Introduced a new `ready_event` handler to restore bot presence and enforce blacklist. - Updated `mod.rs` to include new event modules. - Enhanced `main.rs` for database connection and initialization. - Added comprehensive permission management in `permissions.rs`. --- .dockerignore | 5 + .env.example | 11 + .gitignore | 4 + Cargo.lock | 3333 ++++++++++++++++++++++++ Cargo.toml | 17 + Dockerfile | 23 + docker-compose.yml | 36 + src/activity.rs | 133 + src/commands/addrole.rs | 26 + src/commands/admin_common.rs | 53 + src/commands/admin_service.rs | 56 + src/commands/advanced_tools.rs | 2783 ++++++++++++++++++++ src/commands/alias.rs | 137 + src/commands/alladmins.rs | 150 ++ src/commands/allbots.rs | 133 + src/commands/allperms.rs | 27 + src/commands/autobackup.rs | 27 + src/commands/autoconfiglog.rs | 26 + src/commands/autopublish.rs | 112 + src/commands/autoreact.rs | 27 + src/commands/backup.rs | 31 + src/commands/ban.rs | 23 + src/commands/banlist.rs | 23 + src/commands/banner.rs | 78 + src/commands/bl.rs | 92 + src/commands/blinfo.rs | 97 + src/commands/boostembed.rs | 26 + src/commands/boosters.rs | 145 ++ src/commands/boostlog.rs | 26 + src/commands/botadmins.rs | 149 ++ src/commands/botconfig_common.rs | 166 ++ src/commands/botconfig_service.rs | 46 + src/commands/bringall.rs | 27 + src/commands/button.rs | 30 + src/commands/calc.rs | 108 + src/commands/change.rs | 27 + src/commands/changeall.rs | 27 + src/commands/channel.rs | 112 + src/commands/choose.rs | 27 + src/commands/claim.rs | 69 + src/commands/cleanup.rs | 27 + src/commands/clear_all_sanctions.rs | 27 + src/commands/clear_bl.rs | 53 + src/commands/clear_messages.rs | 27 + src/commands/clear_owners.rs | 53 + src/commands/clear_perms.rs | 27 + src/commands/clear_sanctions.rs | 27 + src/commands/close.rs | 76 + src/commands/cmute.rs | 23 + src/commands/command_contract.rs | 16 + src/commands/common.rs | 141 + src/commands/compet.rs | 27 + src/commands/create.rs | 30 + src/commands/del.rs | 27 + src/commands/del_sanction.rs | 27 + src/commands/delrole.rs | 26 + src/commands/derank.rs | 26 + src/commands/discussion.rs | 77 + src/commands/dnd.rs | 27 + src/commands/embed.rs | 27 + src/commands/emoji.rs | 97 + src/commands/end.rs | 27 + src/commands/giveaway.rs | 27 + src/commands/help.rs | 799 ++++++ src/commands/helpalias.rs | 81 + src/commands/helpsetting.rs | 180 ++ src/commands/helptype.rs | 78 + src/commands/hide.rs | 23 + src/commands/hideall.rs | 26 + src/commands/idle.rs | 27 + src/commands/invisible.rs | 27 + src/commands/invite.rs | 84 + src/commands/join.rs | 29 + src/commands/kick.rs | 23 + src/commands/leave.rs | 48 + src/commands/leave_settings.rs | 30 + src/commands/listen.rs | 27 + src/commands/loading.rs | 27 + src/commands/lock.rs | 23 + src/commands/lockall.rs | 23 + src/commands/logs_service.rs | 1293 +++++++++ src/commands/mainprefix.rs | 27 + src/commands/massiverole.rs | 27 + src/commands/member.rs | 91 + src/commands/messagelog.rs | 26 + src/commands/mod.rs | 291 +++ src/commands/moderation_tools.rs | 1303 +++++++++ src/commands/modlog.rs | 26 + src/commands/mp.rs | 474 ++++ src/commands/mute.rs | 23 + src/commands/mutelist.rs | 23 + src/commands/newsticker.rs | 27 + src/commands/nolog.rs | 26 + src/commands/online.rs | 27 + src/commands/owner.rs | 59 + src/commands/perms.rs | 27 + src/commands/perms_service.rs | 667 +++++ src/commands/pic.rs | 78 + src/commands/ping.rs | 64 + src/commands/playto.rs | 27 + src/commands/prefix.rs | 27 + src/commands/raidlog.rs | 26 + src/commands/remove_activity.rs | 27 + src/commands/rename.rs | 121 + src/commands/renew.rs | 27 + src/commands/reroll.rs | 27 + src/commands/role.rs | 106 + src/commands/rolelog.rs | 26 + src/commands/rolemembers.rs | 100 + src/commands/sanctions.rs | 27 + src/commands/say.rs | 41 + src/commands/server.rs | 171 ++ src/commands/serverinfo.rs | 72 + src/commands/set.rs | 259 ++ src/commands/set_boostembed.rs | 30 + src/commands/set_modlogs.rs | 27 + src/commands/shadowbot.rs | 36 + src/commands/showpics.rs | 50 + src/commands/slash_commands_manager.rs | 32 + src/commands/snipe.rs | 83 + src/commands/stream.rs | 27 + src/commands/suggestion.rs | 472 ++++ src/commands/sync.rs | 27 + src/commands/tempban.rs | 23 + src/commands/tempcmute.rs | 23 + src/commands/tempmute.rs | 23 + src/commands/temprole.rs | 27 + src/commands/tempvoc.rs | 428 +++ src/commands/tempvoc_cmd.rs | 17 + src/commands/theme.rs | 64 + src/commands/ticket.rs | 433 +++ src/commands/ticket_member.rs | 147 ++ src/commands/tickets.rs | 74 + src/commands/unban.rs | 23 + src/commands/unbanall.rs | 27 + src/commands/unbl.rs | 79 + src/commands/uncmute.rs | 23 + src/commands/unhide.rs | 23 + src/commands/unhideall.rs | 26 + src/commands/unlock.rs | 23 + src/commands/unlockall.rs | 23 + src/commands/unmassiverole.rs | 27 + src/commands/unmute.rs | 23 + src/commands/unmuteall.rs | 23 + src/commands/unowner.rs | 88 + src/commands/untemprole.rs | 27 + src/commands/user.rs | 78 + src/commands/viewlogs.rs | 264 ++ src/commands/vocinfo.rs | 82 + src/commands/voicekick.rs | 27 + src/commands/voicelog.rs | 26 + src/commands/voicemove.rs | 27 + src/commands/warn.rs | 23 + src/commands/watch.rs | 27 + src/db.rs | 2744 +++++++++++++++++++ src/events/channel_event.rs | 20 + src/events/guild_create_event.rs | 8 + src/events/guild_member_event.rs | 49 + src/events/handler.rs | 125 + src/events/interaction_create_event.rs | 59 + src/events/message_delete_event.rs | 50 + src/events/message_event.rs | 421 +++ src/events/message_update_event.rs | 31 + src/events/mod.rs | 11 + src/events/ready_event.rs | 14 + src/events/role_event.rs | 27 + src/events/voice_state_update_event.rs | 21 + src/main.rs | 70 + src/permissions.rs | 619 +++++ 169 files changed, 23909 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/activity.rs create mode 100644 src/commands/addrole.rs create mode 100644 src/commands/admin_common.rs create mode 100644 src/commands/admin_service.rs create mode 100644 src/commands/advanced_tools.rs create mode 100644 src/commands/alias.rs create mode 100644 src/commands/alladmins.rs create mode 100644 src/commands/allbots.rs create mode 100644 src/commands/allperms.rs create mode 100644 src/commands/autobackup.rs create mode 100644 src/commands/autoconfiglog.rs create mode 100644 src/commands/autopublish.rs create mode 100644 src/commands/autoreact.rs create mode 100644 src/commands/backup.rs create mode 100644 src/commands/ban.rs create mode 100644 src/commands/banlist.rs create mode 100644 src/commands/banner.rs create mode 100644 src/commands/bl.rs create mode 100644 src/commands/blinfo.rs create mode 100644 src/commands/boostembed.rs create mode 100644 src/commands/boosters.rs create mode 100644 src/commands/boostlog.rs create mode 100644 src/commands/botadmins.rs create mode 100644 src/commands/botconfig_common.rs create mode 100644 src/commands/botconfig_service.rs create mode 100644 src/commands/bringall.rs create mode 100644 src/commands/button.rs create mode 100644 src/commands/calc.rs create mode 100644 src/commands/change.rs create mode 100644 src/commands/changeall.rs create mode 100644 src/commands/channel.rs create mode 100644 src/commands/choose.rs create mode 100644 src/commands/claim.rs create mode 100644 src/commands/cleanup.rs create mode 100644 src/commands/clear_all_sanctions.rs create mode 100644 src/commands/clear_bl.rs create mode 100644 src/commands/clear_messages.rs create mode 100644 src/commands/clear_owners.rs create mode 100644 src/commands/clear_perms.rs create mode 100644 src/commands/clear_sanctions.rs create mode 100644 src/commands/close.rs create mode 100644 src/commands/cmute.rs create mode 100644 src/commands/command_contract.rs create mode 100644 src/commands/common.rs create mode 100644 src/commands/compet.rs create mode 100644 src/commands/create.rs create mode 100644 src/commands/del.rs create mode 100644 src/commands/del_sanction.rs create mode 100644 src/commands/delrole.rs create mode 100644 src/commands/derank.rs create mode 100644 src/commands/discussion.rs create mode 100644 src/commands/dnd.rs create mode 100644 src/commands/embed.rs create mode 100644 src/commands/emoji.rs create mode 100644 src/commands/end.rs create mode 100644 src/commands/giveaway.rs create mode 100644 src/commands/help.rs create mode 100644 src/commands/helpalias.rs create mode 100644 src/commands/helpsetting.rs create mode 100644 src/commands/helptype.rs create mode 100644 src/commands/hide.rs create mode 100644 src/commands/hideall.rs create mode 100644 src/commands/idle.rs create mode 100644 src/commands/invisible.rs create mode 100644 src/commands/invite.rs create mode 100644 src/commands/join.rs create mode 100644 src/commands/kick.rs create mode 100644 src/commands/leave.rs create mode 100644 src/commands/leave_settings.rs create mode 100644 src/commands/listen.rs create mode 100644 src/commands/loading.rs create mode 100644 src/commands/lock.rs create mode 100644 src/commands/lockall.rs create mode 100644 src/commands/logs_service.rs create mode 100644 src/commands/mainprefix.rs create mode 100644 src/commands/massiverole.rs create mode 100644 src/commands/member.rs create mode 100644 src/commands/messagelog.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/moderation_tools.rs create mode 100644 src/commands/modlog.rs create mode 100644 src/commands/mp.rs create mode 100644 src/commands/mute.rs create mode 100644 src/commands/mutelist.rs create mode 100644 src/commands/newsticker.rs create mode 100644 src/commands/nolog.rs create mode 100644 src/commands/online.rs create mode 100644 src/commands/owner.rs create mode 100644 src/commands/perms.rs create mode 100644 src/commands/perms_service.rs create mode 100644 src/commands/pic.rs create mode 100644 src/commands/ping.rs create mode 100644 src/commands/playto.rs create mode 100644 src/commands/prefix.rs create mode 100644 src/commands/raidlog.rs create mode 100644 src/commands/remove_activity.rs create mode 100644 src/commands/rename.rs create mode 100644 src/commands/renew.rs create mode 100644 src/commands/reroll.rs create mode 100644 src/commands/role.rs create mode 100644 src/commands/rolelog.rs create mode 100644 src/commands/rolemembers.rs create mode 100644 src/commands/sanctions.rs create mode 100644 src/commands/say.rs create mode 100644 src/commands/server.rs create mode 100644 src/commands/serverinfo.rs create mode 100644 src/commands/set.rs create mode 100644 src/commands/set_boostembed.rs create mode 100644 src/commands/set_modlogs.rs create mode 100644 src/commands/shadowbot.rs create mode 100644 src/commands/showpics.rs create mode 100644 src/commands/slash_commands_manager.rs create mode 100644 src/commands/snipe.rs create mode 100644 src/commands/stream.rs create mode 100644 src/commands/suggestion.rs create mode 100644 src/commands/sync.rs create mode 100644 src/commands/tempban.rs create mode 100644 src/commands/tempcmute.rs create mode 100644 src/commands/tempmute.rs create mode 100644 src/commands/temprole.rs create mode 100644 src/commands/tempvoc.rs create mode 100644 src/commands/tempvoc_cmd.rs create mode 100644 src/commands/theme.rs create mode 100644 src/commands/ticket.rs create mode 100644 src/commands/ticket_member.rs create mode 100644 src/commands/tickets.rs create mode 100644 src/commands/unban.rs create mode 100644 src/commands/unbanall.rs create mode 100644 src/commands/unbl.rs create mode 100644 src/commands/uncmute.rs create mode 100644 src/commands/unhide.rs create mode 100644 src/commands/unhideall.rs create mode 100644 src/commands/unlock.rs create mode 100644 src/commands/unlockall.rs create mode 100644 src/commands/unmassiverole.rs create mode 100644 src/commands/unmute.rs create mode 100644 src/commands/unmuteall.rs create mode 100644 src/commands/unowner.rs create mode 100644 src/commands/untemprole.rs create mode 100644 src/commands/user.rs create mode 100644 src/commands/viewlogs.rs create mode 100644 src/commands/vocinfo.rs create mode 100644 src/commands/voicekick.rs create mode 100644 src/commands/voicelog.rs create mode 100644 src/commands/voicemove.rs create mode 100644 src/commands/warn.rs create mode 100644 src/commands/watch.rs create mode 100644 src/db.rs create mode 100644 src/events/channel_event.rs create mode 100644 src/events/guild_create_event.rs create mode 100644 src/events/guild_member_event.rs create mode 100644 src/events/handler.rs create mode 100644 src/events/interaction_create_event.rs create mode 100644 src/events/message_delete_event.rs create mode 100644 src/events/message_event.rs create mode 100644 src/events/message_update_event.rs create mode 100644 src/events/mod.rs create mode 100644 src/events/ready_event.rs create mode 100644 src/events/role_event.rs create mode 100644 src/events/voice_state_update_event.rs create mode 100644 src/main.rs create mode 100644 src/permissions.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7c1b123 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target +.git +.gitignore +.env +.env.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46ca8b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Discord +BOT_TOKEN=change_me +FORCE_OWNER_IDS=671763971803447298 + +# PostgreSQL +POSTGRES_DB=shadowbot +POSTGRES_USER=shadowbot +POSTGRES_PASSWORD=change_me + +# App database URL +DATABASE_URL=postgres://shadowbot:change_me@postgres:5432/shadowbot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0be940c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.env +.env.* +!.env.example diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3d41687 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "command_attr" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8208103c5e25a091226dfa8d61d08d0561cc14f31b25691811ba37d4ec9b157b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.10", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_cow" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serenity" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" +dependencies = [ + "arrayvec", + "async-trait", + "base64", + "bitflags", + "bytes", + "chrono", + "command_attr", + "dashmap", + "flate2", + "futures", + "levenshtein", + "mime_guess", + "parking_lot", + "percent-encoding", + "reqwest", + "rustc-hash", + "secrecy", + "serde", + "serde_cow", + "serde_json", + "static_assertions", + "time", + "tokio", + "tokio-tungstenite", + "tracing", + "typemap_rev", + "typesize", + "url", + "uwl", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shadowbot" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "dotenv", + "meval", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serenity", + "sqlx", + "tokio", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "rustls 0.22.4", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls 0.22.4", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typemap_rev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typesize" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da66c62c5b7017a2787e77373c03e6a5aafde77a73bff1ff96e91cd2e128179" +dependencies = [ + "chrono", + "dashmap", + "hashbrown 0.14.5", + "mini-moka", + "parking_lot", + "secrecy", + "serde_json", + "time", + "typesize-derive", + "url", +] + +[[package]] +name = "typesize-derive" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uwl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..39c2928 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "shadowbot" +version = "0.1.0" +edition = "2024" + +[dependencies] +serenity = { version = "0.12", features = ["client", "gateway", "rustls_backend", "model"] } +tokio = { version = "1", features = ["full"] } +dotenv = "0.15" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono"] } +chrono = { version = "0.4", features = ["clock"] } +meval = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +base64 = "0.22" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..415b1aa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1.7 + +FROM rust:1-slim-bookworm AS builder +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +RUN cargo build --release + +FROM debian:bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 appuser + +COPY --from=builder /app/target/release/shadowbot /usr/local/bin/shadowbot + +USER appuser +WORKDIR /home/appuser +ENV RUST_LOG=info + +CMD ["shadowbot"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..387a91f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + image: shadowbot:latest + container_name: shadowbot-bot + restart: unless-stopped + environment: + BOT_TOKEN: ${BOT_TOKEN} + DATABASE_URL: postgres://${POSTGRES_USER:-shadowbot}:${POSTGRES_PASSWORD:-shadowbot_dev_password}@postgres:5432/${POSTGRES_DB:-shadowbot} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + container_name: shadowbot-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-shadowbot} + POSTGRES_USER: ${POSTGRES_USER:-shadowbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shadowbot_dev_password} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shadowbot} -d ${POSTGRES_DB:-shadowbot}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: diff --git a/src/activity.rs b/src/activity.rs new file mode 100644 index 0000000..289e705 --- /dev/null +++ b/src/activity.rs @@ -0,0 +1,133 @@ +use serenity::gateway::ActivityData; +use serenity::model::prelude::OnlineStatus; +use serenity::prelude::{Context, TypeMapKey}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::{Duration, sleep}; + +pub struct ActivityTaskKey; + +impl TypeMapKey for ActivityTaskKey { + type Value = Arc>>>; +} + +#[derive(Clone, Copy, Debug)] +pub enum RotatingActivityKind { + Playing, + Listening, + Watching, + Competing, + Streaming, +} + +impl RotatingActivityKind { + pub fn from_command(command: &str) -> Option { + match command { + "+playto" => Some(Self::Playing), + "+listen" => Some(Self::Listening), + "+watch" => Some(Self::Watching), + "+compet" => Some(Self::Competing), + "+stream" => Some(Self::Streaming), + _ => None, + } + } + + pub fn from_db(value: &str) -> Option { + match value { + "playing" => Some(Self::Playing), + "listening" => Some(Self::Listening), + "watching" => Some(Self::Watching), + "competing" => Some(Self::Competing), + "streaming" => Some(Self::Streaming), + _ => None, + } + } + + pub fn as_db(&self) -> &'static str { + match self { + Self::Playing => "playing", + Self::Listening => "listening", + Self::Watching => "watching", + Self::Competing => "competing", + Self::Streaming => "streaming", + } + } + + fn to_activity(self, message: &str) -> ActivityData { + match self { + Self::Playing => ActivityData::playing(message), + Self::Listening => ActivityData::listening(message), + Self::Watching => ActivityData::watching(message), + Self::Competing => ActivityData::competing(message), + Self::Streaming => ActivityData::streaming(message, "https://twitch.tv/discord") + .unwrap_or_else(|_| ActivityData::playing(message)), + } + } +} + +pub fn parse_status(value: &str) -> OnlineStatus { + match value { + "idle" => OnlineStatus::Idle, + "dnd" => OnlineStatus::DoNotDisturb, + "invisible" => OnlineStatus::Invisible, + _ => OnlineStatus::Online, + } +} + +pub async fn stop_rotation(ctx: &Context) { + let task_slot = { + let mut data = ctx.data.write().await; + if !data.contains_key::() { + data.insert::(Arc::new(Mutex::new(None))); + } + data.get::().cloned() + }; + + if let Some(slot) = task_slot { + let mut guard = slot.lock().await; + if let Some(handle) = guard.take() { + handle.abort(); + } + } +} + +pub async fn start_rotation( + ctx: &Context, + kind: RotatingActivityKind, + messages: Vec, + status: OnlineStatus, +) { + if messages.is_empty() { + return; + } + + stop_rotation(ctx).await; + + let task_slot = { + let mut data = ctx.data.write().await; + if !data.contains_key::() { + data.insert::(Arc::new(Mutex::new(None))); + } + data.get::().cloned() + }; + + let Some(slot) = task_slot else { + return; + }; + + let cloned_ctx = ctx.clone(); + let handle = tokio::spawn(async move { + let mut index = 0usize; + loop { + let msg = &messages[index % messages.len()]; + let activity = kind.to_activity(msg); + cloned_ctx.set_presence(Some(activity), status); + index = (index + 1) % messages.len(); + sleep(Duration::from_secs(30)).await; + } + }); + + let mut guard = slot.lock().await; + *guard = Some(handle); +} diff --git a/src/commands/addrole.rs b/src/commands/addrole.rs new file mode 100644 index 0000000..c4b73ac --- /dev/null +++ b/src/commands/addrole.rs @@ -0,0 +1,26 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_addrole(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_add_del_role(ctx, msg, args, true).await; +} + +pub struct AddroleCommand; +pub static COMMAND_DESCRIPTOR: AddroleCommand = AddroleCommand; + +impl crate::commands::command_contract::CommandSpec for AddroleCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "addrole", + command: "addrole", + category: "admin", + params: "<@membre/ID[,..]> <@role/ID>", + summary: "Ajoute un role", + description: "Ajoute un role a un ou plusieurs membres.", + examples: &["+addrole @User @Membre"], + alias_source_key: "addrole", + default_aliases: &["ar"], + } + } +} diff --git a/src/commands/admin_common.rs b/src/commands/admin_common.rs new file mode 100644 index 0000000..1b90a45 --- /dev/null +++ b/src/commands/admin_common.rs @@ -0,0 +1,53 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::permissions::is_owner_user; + +pub fn parse_user_id(input: &str) -> Option { + let cleaned = input + .trim() + .trim_start_matches('<') + .trim_end_matches('>') + .trim_start_matches('@') + .trim_start_matches('!'); + + cleaned.parse::().ok().map(UserId::new) +} + +pub async fn app_owner_id(ctx: &Context) -> Option { + let info = ctx.http.get_current_application_info().await.ok()?; + info.owner.map(|u| u.id) +} + +pub async fn ensure_owner(ctx: &Context, msg: &Message) -> Result<(), ()> { + if is_owner_user(ctx, msg.author.id).await { + Ok(()) + } else { + let embed = CreateEmbed::new() + .title("Accès refusé") + .description("Cette commande est réservée aux owners du bot.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + Err(()) + } +} + +pub async fn ban_user_everywhere(ctx: &Context, user_id: UserId, reason: &str) -> (usize, usize) { + let guilds = ctx.cache.guilds(); + let mut ok = 0usize; + let mut ko = 0usize; + + for guild_id in guilds { + match guild_id + .ban_with_reason(&ctx.http, user_id, 0, reason) + .await + { + Ok(_) => ok += 1, + Err(_) => ko += 1, + } + } + + (ok, ko) +} diff --git a/src/commands/admin_service.rs b/src/commands/admin_service.rs new file mode 100644 index 0000000..4a9edc6 --- /dev/null +++ b/src/commands/admin_service.rs @@ -0,0 +1,56 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::db::{DbPoolKey, is_blacklisted, list_blacklisted_ids}; + +pub async fn enforce_blacklist_on_message(ctx: &Context, msg: &Message) -> bool { + if msg.author.bot { + return false; + } + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + return false; + }; + + let blacklisted = is_blacklisted(&pool, bot_id, msg.author.id) + .await + .unwrap_or(false); + if !blacklisted { + return false; + } + + if let Some(guild_id) = msg.guild_id { + let _ = guild_id + .ban_with_reason(&ctx.http, msg.author.id, 0, "Blacklist globale du bot") + .await; + } + + true +} + +pub async fn enforce_blacklist_on_guild(ctx: &Context, guild_id: GuildId) { + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + return; + }; + + let users = list_blacklisted_ids(&pool, bot_id) + .await + .unwrap_or_default(); + for uid in users { + let _ = guild_id + .ban_with_reason(&ctx.http, uid, 0, "Blacklist globale du bot") + .await; + } +} diff --git a/src/commands/advanced_tools.rs b/src/commands/advanced_tools.rs new file mode 100644 index 0000000..ff7ad05 --- /dev/null +++ b/src/commands/advanced_tools.rs @@ -0,0 +1,2783 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateChannel, CreateEmbed, CreateEmbedFooter, CreateInputText, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateModal, + EditChannel, EditMember, EditMessage, EditRole, +}; +use serenity::model::application::{ActionRowComponent, InputTextStyle}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::admin_common::parse_user_id; +use crate::commands::common::{parse_channel_id, parse_role, send_embed, theme_color}; +use crate::db::DbPoolKey; + +static MAINTENANCE_TICK: OnceLock> = OnceLock::new(); + +const ADV_GIVEAWAY_OPEN_MODAL: &str = "adv:giveaway:open_modal"; +const ADV_GIVEAWAY_END_MODAL: &str = "adv:giveaway:end_modal"; +const ADV_BACKUP_CREATE_MODAL: &str = "adv:backup:create_modal"; +const ADV_BACKUP_LIST_MODAL: &str = "adv:backup:list_modal"; +const ADV_BACKUP_LOAD_MODAL: &str = "adv:backup:load_modal"; +const ADV_BACKUP_DELETE_MODAL: &str = "adv:backup:delete_modal"; +const ADV_AUTOREACT_ADD_MODAL: &str = "adv:autoreact:add_modal"; +const ADV_AUTOREACT_DEL_MODAL: &str = "adv:autoreact:del_modal"; +const ADV_AUTOREACT_LIST: &str = "adv:autoreact:list"; +const ADV_CHOOSE_MODAL: &str = "adv:choose:modal"; +const ADV_EMBED_MODAL: &str = "adv:embed:modal"; +const ADV_LOADING_MODAL: &str = "adv:loading:modal"; + +fn adv_component_id(action: &str, owner_id: UserId) -> String { + format!("{}:{}", action, owner_id.get()) +} + +fn parse_owner_component_id(custom_id: &str) -> Option<(&str, u64)> { + let mut parts = custom_id.rsplitn(2, ':'); + let owner = parts.next()?.parse::().ok()?; + let action = parts.next()?; + Some((action, owner)) +} + +fn modal_value(modal: &ModalInteraction, wanted_id: &str) -> Option { + for row in &modal.data.components { + for component in &row.components { + if let ActionRowComponent::InputText(input) = component { + if input.custom_id == wanted_id { + return input.value.clone(); + } + } + } + } + None +} + +async fn respond_ephemeral( + ctx: &Context, + component: &ComponentInteraction, + content: impl Into, +) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content.into()) + .ephemeral(true), + ), + ) + .await; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupRole { + id: u64, + name: String, + color: u32, + hoist: bool, + mentionable: bool, + permissions: u64, + position: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupOverwrite { + kind: String, + target_id: u64, + allow: u64, + deny: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupChannel { + id: u64, + name: String, + kind: String, + position: i64, + parent_id: Option, + topic: Option, + nsfw: bool, + bitrate: Option, + user_limit: Option, + slowmode: Option, + overwrites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupEmoji { + id: u64, + name: String, + animated: bool, + image_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupMemberRoles { + user_id: u64, + role_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ServerBackupPayload { + guild_id: u64, + guild_name: String, + roles: Vec, + channels: Vec, + emojis: Vec, + members: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmojiBackupPayload { + guild_id: u64, + guild_name: String, + emojis: Vec, +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +fn duration_from_input(input: &str) -> Option { + let raw = input.trim().to_lowercase(); + if raw.is_empty() { + return None; + } + + let mut number = String::new(); + let mut suffix = String::new(); + for ch in raw.chars() { + if ch.is_ascii_digit() { + if !suffix.is_empty() { + return None; + } + number.push(ch); + } else if !ch.is_whitespace() { + suffix.push(ch); + } + } + + let value = number.parse::().ok()?; + let secs = match suffix.as_str() { + "s" | "sec" | "secs" | "seconde" | "secondes" => value, + "m" | "min" | "mins" | "minute" | "minutes" => value * 60, + "h" | "heure" | "heures" => value * 3600, + "j" | "d" | "jour" | "jours" => value * 86400, + "" => value, + _ => return None, + }; + + Some(Duration::from_secs(secs.max(1))) +} + +fn parse_backup_kind(input: &str) -> Option<&'static str> { + match input.to_lowercase().as_str() { + "serveur" | "server" | "srv" => Some("server"), + "emoji" | "emojis" => Some("emoji"), + _ => None, + } +} + +fn channel_kind_to_str(kind: ChannelType) -> String { + match kind { + ChannelType::Text => "text", + ChannelType::Voice => "voice", + ChannelType::Category => "category", + ChannelType::News => "news", + ChannelType::Stage => "stage", + ChannelType::Forum => "forum", + _ => "other", + } + .to_string() +} + +fn channel_kind_from_str(kind: &str) -> ChannelType { + match kind { + "voice" => ChannelType::Voice, + "category" => ChannelType::Category, + "news" => ChannelType::News, + "stage" => ChannelType::Stage, + "forum" => ChannelType::Forum, + _ => ChannelType::Text, + } +} + +fn serialize_overwrites(source: &[PermissionOverwrite]) -> Vec { + source + .iter() + .map(|ow| { + let (kind, target_id) = match ow.kind { + PermissionOverwriteType::Role(role_id) => ("role".to_string(), role_id.get()), + PermissionOverwriteType::Member(user_id) => ("member".to_string(), user_id.get()), + _ => ("other".to_string(), 0), + }; + + BackupOverwrite { + kind, + target_id, + allow: ow.allow.bits(), + deny: ow.deny.bits(), + } + }) + .collect() +} + +fn deserialize_overwrites( + source: &[BackupOverwrite], + role_map: &HashMap, +) -> Vec { + let mut out = Vec::new(); + + for ow in source { + let kind = match ow.kind.as_str() { + "role" => { + let Some(mapped) = role_map.get(&ow.target_id) else { + continue; + }; + PermissionOverwriteType::Role(*mapped) + } + "member" => PermissionOverwriteType::Member(UserId::new(ow.target_id)), + _ => continue, + }; + + out.push(PermissionOverwrite { + allow: Permissions::from_bits_truncate(ow.allow), + deny: Permissions::from_bits_truncate(ow.deny), + kind, + }); + } + + out +} + +pub async fn apply_autoreacts(ctx: &Context, msg: &Message) { + if msg.author.bot { + return; + } + + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let rows = sqlx::query_as::<_, (String,)>( + r#" + SELECT emoji + FROM bot_autoreacts + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 + ORDER BY emoji ASC; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(msg.channel_id.get() as i64) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + for (emoji_text,) in rows { + if let Ok(reaction_type) = ReactionType::try_from(emoji_text.as_str()) { + let _ = msg.react(&ctx.http, reaction_type).await; + } + } +} + +pub async fn maybe_run_maintenance(ctx: &Context, guild_id: Option) { + let Some(guild_id) = guild_id else { + return; + }; + + let now = Instant::now(); + let lock = + MAINTENANCE_TICK.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(60))); + { + let mut last = lock.lock().expect("maintenance tick lock poisoned"); + if now.duration_since(*last) < Duration::from_secs(30) { + return; + } + *last = now; + } + + run_temprole_cleanup(ctx, guild_id).await; + run_autobackup_tick(ctx, guild_id).await; +} + +async fn run_temprole_cleanup(ctx: &Context, guild_id: GuildId) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let now = Utc::now(); + + let rows = sqlx::query_as::<_, (i64, i64)>( + r#" + SELECT user_id, role_id + FROM bot_temproles + WHERE bot_id = $1 AND guild_id = $2 AND active = TRUE AND expires_at <= $3; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(now) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + for (user_id, role_id) in &rows { + if let Ok(member) = guild_id + .member(&ctx.http, UserId::new(*user_id as u64)) + .await + { + let _ = member + .remove_role(&ctx.http, RoleId::new(*role_id as u64)) + .await; + } + } + + if !rows.is_empty() { + let _ = sqlx::query( + r#" + UPDATE bot_temproles + SET active = FALSE + WHERE bot_id = $1 AND guild_id = $2 AND active = TRUE AND expires_at <= $3; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(now) + .execute(&pool) + .await; + } +} + +async fn run_autobackup_tick(ctx: &Context, guild_id: GuildId) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let rows = sqlx::query_as::<_, (String, i32)>( + r#" + SELECT kind, interval_days + FROM bot_autobackups + WHERE bot_id = $1 AND guild_id = $2 AND next_run_at <= NOW(); + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + for (kind, days) in rows { + let auto_name = format!("auto_{}_{}", kind, Utc::now().format("%Y%m%d_%H%M%S")); + let _ = create_backup_internal(ctx, guild_id, &kind, &auto_name).await; + + let _ = sqlx::query( + r#" + UPDATE bot_autobackups + SET last_run_at = NOW(), + next_run_at = NOW() + make_interval(days => $4) + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(&kind) + .bind(days) + .execute(&pool) + .await; + } +} + +async fn serialize_server_backup( + ctx: &Context, + guild_id: GuildId, +) -> Result { + let guild = guild_id + .to_partial_guild(&ctx.http) + .await + .map_err(|e| format!("Impossible de lire le serveur: {e}"))?; + + let channels = guild_id + .channels(&ctx.http) + .await + .map_err(|e| format!("Impossible de lire les salons: {e}"))?; + + let members = guild_id + .members(&ctx.http, None, None) + .await + .unwrap_or_default(); + + let mut roles = guild + .roles + .values() + .map(|role| BackupRole { + id: role.id.get(), + name: role.name.clone(), + color: role.colour.0, + hoist: role.hoist, + mentionable: role.mentionable, + permissions: role.permissions.bits(), + position: role.position as i64, + }) + .collect::>(); + roles.sort_by_key(|r| r.position); + + let mut channels_list = channels + .values() + .map(|ch| BackupChannel { + id: ch.id.get(), + name: ch.name.clone(), + kind: channel_kind_to_str(ch.kind), + position: ch.position as i64, + parent_id: ch.parent_id.map(|id| id.get()), + topic: ch.topic.clone(), + nsfw: ch.nsfw, + bitrate: ch.bitrate.map(|v| v as u32), + user_limit: ch.user_limit.map(|v| v as u32), + slowmode: ch.rate_limit_per_user, + overwrites: serialize_overwrites(&ch.permission_overwrites), + }) + .collect::>(); + channels_list.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); + + let emojis = guild + .emojis + .values() + .map(|emoji| BackupEmoji { + id: emoji.id.get(), + name: emoji.name.clone(), + animated: emoji.animated, + image_url: emoji.url(), + }) + .collect::>(); + + let members = members + .into_iter() + .map(|m| BackupMemberRoles { + user_id: m.user.id.get(), + role_ids: m.roles.iter().map(|rid| rid.get()).collect(), + }) + .collect::>(); + + Ok(ServerBackupPayload { + guild_id: guild_id.get(), + guild_name: guild.name, + roles, + channels: channels_list, + emojis, + members, + }) +} + +async fn serialize_emoji_backup( + ctx: &Context, + guild_id: GuildId, +) -> Result { + let guild = guild_id + .to_partial_guild(&ctx.http) + .await + .map_err(|e| format!("Impossible de lire le serveur: {e}"))?; + + let emojis = guild + .emojis + .values() + .map(|emoji| BackupEmoji { + id: emoji.id.get(), + name: emoji.name.clone(), + animated: emoji.animated, + image_url: emoji.url(), + }) + .collect::>(); + + Ok(EmojiBackupPayload { + guild_id: guild_id.get(), + guild_name: guild.name, + emojis, + }) +} + +async fn create_backup_internal( + ctx: &Context, + guild_id: GuildId, + kind: &str, + name: &str, +) -> Result<(), String> { + let Some(pool) = pool(ctx).await else { + return Err("Base de données indisponible".to_string()); + }; + + let bot_id = ctx.cache.current_user().id; + + let payload_value = if kind == "server" { + serde_json::to_value(serialize_server_backup(ctx, guild_id).await?) + .map_err(|e| format!("Erreur serialisation backup: {e}"))? + } else { + serde_json::to_value(serialize_emoji_backup(ctx, guild_id).await?) + .map_err(|e| format!("Erreur serialisation backup: {e}"))? + }; + + sqlx::query( + r#" + INSERT INTO bot_backups (bot_id, guild_id, kind, backup_name, payload) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (bot_id, guild_id, kind, backup_name) + DO UPDATE SET payload = EXCLUDED.payload, created_at = NOW(); + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(name) + .bind(payload_value) + .execute(&pool) + .await + .map_err(|e| format!("Erreur insertion backup: {e}"))?; + + Ok(()) +} + +async fn restore_emoji_backup( + ctx: &Context, + guild_id: GuildId, + payload: EmojiBackupPayload, +) -> Result { + let mut created = 0usize; + + for emoji in payload.emojis { + let response = reqwest::get(&emoji.image_url) + .await + .map_err(|e| format!("Erreur téléchargement emoji {}: {e}", emoji.name))?; + let bytes = response + .bytes() + .await + .map_err(|e| format!("Erreur lecture emoji {}: {e}", emoji.name))?; + + let data_uri = format!("data:image/png;base64,{}", { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + }); + + if guild_id + .create_emoji(&ctx.http, &emoji.name, &data_uri) + .await + .is_ok() + { + created += 1; + } + } + + Ok(created) +} + +async fn restore_server_backup( + ctx: &Context, + guild_id: GuildId, + payload: ServerBackupPayload, +) -> Result<(usize, usize, usize), String> { + let partial = guild_id + .to_partial_guild(&ctx.http) + .await + .map_err(|e| format!("Impossible de lire le serveur: {e}"))?; + + let mut role_map = HashMap::::new(); + let mut category_map = HashMap::::new(); + + for role in partial.roles.values() { + role_map.insert(role.id.get(), role.id); + } + + let mut created_roles = 0usize; + let mut created_channels = 0usize; + let mut restored_members = 0usize; + + for role in payload.roles.iter().filter(|r| r.name != "@everyone") { + let existing = partial + .roles + .values() + .find(|r| r.name == role.name) + .map(|r| r.id); + let role_id = if let Some(existing_id) = existing { + existing_id + } else { + let created = guild_id + .create_role( + &ctx.http, + EditRole::new() + .name(&role.name) + .hoist(role.hoist) + .mentionable(role.mentionable) + .permissions(Permissions::from_bits_truncate(role.permissions)) + .colour(role.color), + ) + .await + .map_err(|e| format!("Creation role {} impossible: {e}", role.name))?; + created_roles += 1; + created.id + }; + + role_map.insert(role.id, role_id); + } + + for channel in payload.channels.iter().filter(|ch| ch.kind == "category") { + let created = guild_id + .create_channel( + &ctx.http, + CreateChannel::new(&channel.name) + .kind(ChannelType::Category) + .position(channel.position as u16), + ) + .await; + + if let Ok(new_channel) = created { + category_map.insert(channel.id, new_channel.id); + created_channels += 1; + } + } + + for channel in payload.channels.iter().filter(|ch| ch.kind != "category") { + let mut builder = CreateChannel::new(&channel.name) + .kind(channel_kind_from_str(&channel.kind)) + .position(channel.position as u16) + .nsfw(channel.nsfw); + + if let Some(parent_id) = channel.parent_id { + if let Some(mapped_parent) = category_map.get(&parent_id) { + builder = builder.category(*mapped_parent); + } + } + if let Some(topic) = &channel.topic { + builder = builder.topic(topic); + } + if let Some(bitrate) = channel.bitrate { + builder = builder.bitrate(bitrate); + } + if let Some(user_limit) = channel.user_limit { + builder = builder.user_limit(user_limit); + } + if let Some(slowmode) = channel.slowmode { + builder = builder.rate_limit_per_user(slowmode); + } + + let overwrites = deserialize_overwrites(&channel.overwrites, &role_map); + if !overwrites.is_empty() { + builder = builder.permissions(overwrites); + } + + if guild_id.create_channel(&ctx.http, builder).await.is_ok() { + created_channels += 1; + } + } + + for member in &payload.members { + if let Ok(mut target) = guild_id + .member(&ctx.http, UserId::new(member.user_id)) + .await + { + let mapped_roles = member + .role_ids + .iter() + .filter_map(|old_id| role_map.get(old_id).copied()) + .collect::>(); + + if target + .edit(&ctx.http, EditMember::new().roles(mapped_roles)) + .await + .is_ok() + { + restored_members += 1; + } + } + } + + Ok((created_roles, created_channels, restored_members)) +} + +pub async fn handle_giveaway(ctx: &Context, msg: &Message, _args: &[&str]) { + let embed = CreateEmbed::new() + .title("Giveaway") + .description("Utilise les boutons pour créer ou terminer un giveaway via modal.") + .color(theme_color(ctx).await) + .footer(CreateEmbedFooter::new("UI avancée: Components + Modal")); + + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_GIVEAWAY_OPEN_MODAL, msg.author.id)) + .label("Créer") + .emoji('🎉') + .style(ButtonStyle::Success), + CreateButton::new(adv_component_id(ADV_GIVEAWAY_END_MODAL, msg.author.id)) + .label("Terminer") + .emoji('🛑') + .style(ButtonStyle::Danger), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; +} + +async fn handle_end_giveaway(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(raw_id) = args.get(1) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("Usage: +end giveaway ") + .color(0xED4245), + ) + .await; + return; + }; + + let Ok(message_id) = raw_id.parse::() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("ID de message invalide.") + .color(0xED4245), + ) + .await; + return; + }; + + let _ = msg + .channel_id + .edit_message( + &ctx.http, + MessageId::new(message_id), + EditMessage::new().content("🎉 Giveaway terminé manuellement."), + ) + .await; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Giveaway") + .description(format!("Giveaway `{}` terminé.", message_id)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_reroll(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(referenced) = msg.referenced_message.as_ref() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Reroll") + .description("Réponds à un message giveaway pour reroll.") + .color(0xED4245), + ) + .await; + return; + }; + + let mut candidates = referenced.mentions.iter().map(|u| u.id).collect::>(); + candidates.sort_by_key(|u| u.get()); + candidates.dedup(); + + if candidates.is_empty() { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Reroll") + .description("Aucun participant détecté.") + .color(0xED4245), + ) + .await; + return; + } + + let winner = candidates + .choose(&mut rand::thread_rng()) + .copied() + .unwrap_or(candidates[0]); + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Reroll") + .description(format!("Nouveau gagnant: <@{}>", winner.get())) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_choose(ctx: &Context, msg: &Message, args: &[&str]) { + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Choose") + .description("Ouvre un modal pour saisir les options (séparées par `|`).") + .color(theme_color(ctx).await); + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_CHOOSE_MODAL, msg.author.id)) + .label("Saisir les options") + .style(ButtonStyle::Primary), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; + return; + } + + let merged = args.join(" "); + let mut options = merged + .split('|') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>(); + + if options.len() < 2 { + options = args.iter().map(|s| (*s).to_string()).collect(); + } + + if options.len() < 2 { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Choose") + .description("Donne au moins 2 options.") + .color(0xED4245), + ) + .await; + return; + } + + let pick = options + .choose(&mut rand::thread_rng()) + .cloned() + .unwrap_or_else(|| options[0].clone()); + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Tirage") + .description(format!("Résultat: **{}**", pick)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_embed_builder(ctx: &Context, msg: &Message, _args: &[&str]) { + let embed = CreateEmbed::new() + .title("Générateur d'embed") + .description("Utilise le bouton pour construire un embed via modal.") + .color(theme_color(ctx).await); + + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_EMBED_MODAL, msg.author.id)) + .label("Créer un embed") + .style(ButtonStyle::Primary), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; +} + +pub async fn handle_backup(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Backup") + .description("Choisis une action. Les boutons ouvrent un modal.") + .color(theme_color(ctx).await); + + let components = vec![ + CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_BACKUP_CREATE_MODAL, msg.author.id)) + .label("Créer") + .style(ButtonStyle::Success), + CreateButton::new(adv_component_id(ADV_BACKUP_LIST_MODAL, msg.author.id)) + .label("Lister") + .style(ButtonStyle::Primary), + ]), + CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_BACKUP_LOAD_MODAL, msg.author.id)) + .label("Charger") + .style(ButtonStyle::Primary), + CreateButton::new(adv_component_id(ADV_BACKUP_DELETE_MODAL, msg.author.id)) + .label("Supprimer") + .style(ButtonStyle::Danger), + ]), + ]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; + return; + } + + let action_or_kind = args[0].to_lowercase(); + + if action_or_kind == "list" { + let Some(kind_raw) = args.get(1) else { + return; + }; + let Some(kind) = parse_backup_kind(kind_raw) else { + return; + }; + handle_backup_list(ctx, msg, guild_id, kind).await; + return; + } + + if action_or_kind == "delete" { + let (Some(kind_raw), Some(name)) = (args.get(1), args.get(2)) else { + return; + }; + let Some(kind) = parse_backup_kind(kind_raw) else { + return; + }; + handle_backup_delete(ctx, msg, guild_id, kind, name).await; + return; + } + + if action_or_kind == "load" { + let (Some(kind_raw), Some(name)) = (args.get(1), args.get(2)) else { + return; + }; + let Some(kind) = parse_backup_kind(kind_raw) else { + return; + }; + handle_backup_load(ctx, msg, guild_id, kind, name).await; + return; + } + + let Some(kind) = parse_backup_kind(&action_or_kind) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description("Type invalide: utilise serveur ou emoji.") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(name) = args.get(1) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description("Tu dois préciser un nom de backup.") + .color(0xED4245), + ) + .await; + return; + }; + + match create_backup_internal(ctx, guild_id, kind, name).await { + Ok(()) => { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description(format!("Backup `{}` de type `{}` créée.", name, kind)) + .color(theme_color(ctx).await), + ) + .await; + } + Err(err) => { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description(format!("Erreur: {}", err)) + .color(0xED4245), + ) + .await; + } + } +} + +async fn handle_backup_list(ctx: &Context, msg: &Message, guild_id: GuildId, kind: &str) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let rows = sqlx::query_as::<_, (String, DateTime)>( + r#" + SELECT backup_name, created_at + FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 + ORDER BY created_at DESC; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + let desc = if rows.is_empty() { + "Aucune backup enregistrée.".to_string() + } else { + rows.into_iter() + .map(|(name, ts)| format!("- `{}` · ", name, ts.timestamp())) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title(format!("Backups {}", kind)) + .description(desc) + .color(theme_color(ctx).await), + ) + .await; +} + +async fn handle_backup_delete( + ctx: &Context, + msg: &Message, + guild_id: GuildId, + kind: &str, + name: &str, +) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let deleted = sqlx::query( + r#" + DELETE FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 AND backup_name = $4; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(name) + .execute(&pool) + .await + .ok() + .map(|res| res.rows_affected()) + .unwrap_or(0); + + let desc = if deleted > 0 { + format!("Backup `{}` supprimée.", name) + } else { + format!("Aucune backup `{}` trouvée.", name) + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description(desc) + .color(theme_color(ctx).await), + ) + .await; +} + +async fn handle_backup_load( + ctx: &Context, + msg: &Message, + guild_id: GuildId, + kind: &str, + name: &str, +) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + + let row = sqlx::query_as::<_, (serde_json::Value,)>( + r#" + SELECT payload + FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 AND backup_name = $4 + LIMIT 1; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(name) + .fetch_optional(&pool) + .await + .ok() + .flatten(); + + let Some((payload_value,)) = row else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description("Backup introuvable.") + .color(0xED4245), + ) + .await; + return; + }; + + let result_text = if kind == "emoji" { + match serde_json::from_value::(payload_value) { + Ok(payload) => match restore_emoji_backup(ctx, guild_id, payload).await { + Ok(count) => format!("Load emoji terminé: {} emojis créés.", count), + Err(err) => format!("Erreur load emoji: {}", err), + }, + Err(err) => format!("Payload invalide: {err}"), + } + } else { + match serde_json::from_value::(payload_value) { + Ok(payload) => match restore_server_backup(ctx, guild_id, payload).await { + Ok((roles, channels, members)) => format!( + "Load serveur terminé: {} rôles, {} salons, {} membres synchronisés.", + roles, channels, members + ), + Err(err) => format!("Erreur load serveur: {}", err), + }, + Err(err) => format!("Payload invalide: {err}"), + } + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Backup") + .description(result_text) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_autobackup(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + let Some(kind_raw) = args.first() else { + return; + }; + let Some(days_raw) = args.get(1) else { + return; + }; + + let Some(kind) = parse_backup_kind(kind_raw) else { + return; + }; + + let Ok(days) = days_raw.parse::() else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + let _ = sqlx::query( + r#" + INSERT INTO bot_autobackups (bot_id, guild_id, kind, interval_days, next_run_at) + VALUES ($1, $2, $3, $4, NOW() + make_interval(days => $4)) + ON CONFLICT (bot_id, guild_id, kind) + DO UPDATE SET interval_days = EXCLUDED.interval_days, + next_run_at = NOW() + make_interval(days => EXCLUDED.interval_days); + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(days.max(1)) + .execute(&pool) + .await; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AutoBackup") + .description(format!( + "Auto-backup `{}` configurée toutes les {} jours.", + kind, + days.max(1) + )) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_loading(ctx: &Context, msg: &Message, args: &[&str]) { + if args.len() < 2 { + let embed = CreateEmbed::new() + .title("Loading") + .description("Ouvre un modal pour saisir la durée et le message.") + .color(theme_color(ctx).await); + + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_LOADING_MODAL, msg.author.id)) + .label("Configurer") + .style(ButtonStyle::Primary), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; + return; + } + + let Some(duration) = duration_from_input(args[0]) else { + return; + }; + + let total_secs = duration.as_secs().clamp(1, 120); + let text = args[1..].join(" "); + + let mut sent = match msg + .channel_id + .send_message(&ctx.http, CreateMessage::new().content("[----------] 0%")) + .await + { + Ok(m) => m, + Err(_) => return, + }; + + for i in 0..=10_u64 { + let done = "#".repeat(i as usize); + let todo = "-".repeat((10 - i) as usize); + let percent = i * 10; + + let _ = sent + .edit( + &ctx.http, + EditMessage::new().content(format!("{} [{}{}] {}%", text, done, todo, percent)), + ) + .await; + + if i < 10 { + tokio::time::sleep(Duration::from_secs((total_secs / 10).max(1))).await; + } + } +} + +fn emoji_url_from_source(msg: &Message, source: &str) -> String { + if source.starts_with("http://") || source.starts_with("https://") { + return source.to_string(); + } + + if source.starts_with("<:") || source.starts_with("'); + let parts = cleaned.split(':').collect::>(); + if parts.len() == 3 { + let animated = parts[0] == "a"; + return format!( + "https://cdn.discordapp.com/emojis/{}.{}", + parts[2], + if animated { "gif" } else { "png" } + ); + } + } + + if let Some(att) = msg.attachments.first() { + return att.url.clone(); + } + + String::new() +} + +pub async fn handle_create_emoji(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.len() < 2 { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Create Emoji") + .description("Usage: +create ") + .color(0xED4245), + ) + .await; + return; + } + + let image_url = emoji_url_from_source(msg, args[0]); + if image_url.is_empty() { + return; + } + + let response = match reqwest::get(&image_url).await { + Ok(r) => r, + Err(_) => return, + }; + let bytes = match response.bytes().await { + Ok(b) => b, + Err(_) => return, + }; + + let data_uri = format!("data:image/png;base64,{}", { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + }); + + let result = guild_id.create_emoji(&ctx.http, args[1], &data_uri).await; + + let embed = if let Ok(emoji) = result { + CreateEmbed::new() + .title("Emoji") + .description(format!("Emoji créé: {}", emoji)) + .color(theme_color(ctx).await) + } else { + CreateEmbed::new() + .title("Emoji") + .description("Impossible de créer l'emoji.") + .color(0xED4245) + }; + + send_embed(ctx, msg, embed).await; +} + +pub async fn handle_new_sticker(ctx: &Context, msg: &Message, _args: &[&str]) { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("NewSticker") + .description("Création de sticker disponible prochainement (API sticker V2).") + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_massive_role(ctx: &Context, msg: &Message, args: &[&str], add: bool) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.is_empty() { + return; + } + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + + let Some(target_role) = parse_role(&guild, args[0]) else { + return; + }; + + let filter_role = args.get(1).and_then(|raw| parse_role(&guild, raw)); + let members = guild_id + .members(&ctx.http, None, None) + .await + .unwrap_or_default(); + + let mut affected = 0usize; + for member in members { + if let Some(filter) = &filter_role { + if !member.roles.contains(&filter.id) { + continue; + } + } + + let result = if add { + member.add_role(&ctx.http, target_role.id).await + } else { + member.remove_role(&ctx.http, target_role.id).await + }; + + if result.is_ok() { + affected += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title(if add { "MassiveRole" } else { "UnMassiveRole" }) + .description(format!( + "{} membres traités pour le rôle <@&{}>.", + affected, + target_role.id.get() + )) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_voicemove(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + if args.len() < 2 { + return; + } + + let Some(from_channel) = parse_channel_id(args[0]) else { + return; + }; + let Some(to_channel) = parse_channel_id(args[1]) else { + return; + }; + + let user_ids = { + let Some(guild) = guild_id.to_guild_cached(&ctx.cache) else { + return; + }; + + guild + .voice_states + .iter() + .filter_map(|(uid, state)| { + if state.channel_id == Some(from_channel) { + Some(*uid) + } else { + None + } + }) + .collect::>() + }; + + let mut moved = 0usize; + for user_id in user_ids { + if guild_id + .move_member(&ctx.http, user_id, to_channel) + .await + .is_ok() + { + moved += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("VoiceMove") + .description(format!("{} membres déplacés.", moved)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_voicekick(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.is_empty() { + return; + } + + let mut kicked = 0usize; + for raw in args { + if let Some(user_id) = parse_user_id(raw) { + if guild_id.disconnect_member(&ctx.http, user_id).await.is_ok() { + kicked += 1; + } + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("VoiceKick") + .description(format!("{} membres déconnectés.", kicked)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_cleanup_voice(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(channel_raw) = args.first() else { + return; + }; + let Some(channel_id) = parse_channel_id(channel_raw) else { + return; + }; + + let user_ids = { + let Some(guild) = guild_id.to_guild_cached(&ctx.cache) else { + return; + }; + + guild + .voice_states + .iter() + .filter_map(|(uid, state)| { + if state.channel_id == Some(channel_id) { + Some(*uid) + } else { + None + } + }) + .collect::>() + }; + + let mut kicked = 0usize; + for user_id in user_ids { + if guild_id.disconnect_member(&ctx.http, user_id).await.is_ok() { + kicked += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Cleanup") + .description(format!("{} utilisateurs déconnectés.", kicked)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_bringall(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let target_channel = if let Some(raw) = args.first() { + parse_channel_id(raw) + } else { + let Some(guild) = guild_id.to_guild_cached(&ctx.cache) else { + return; + }; + guild + .voice_states + .get(&msg.author.id) + .and_then(|v| v.channel_id) + }; + + let Some(target_channel) = target_channel else { + return; + }; + + let user_ids = { + let Some(guild) = guild_id.to_guild_cached(&ctx.cache) else { + return; + }; + + guild + .voice_states + .iter() + .filter_map(|(uid, state)| state.channel_id.map(|_| *uid)) + .collect::>() + }; + + let mut moved = 0usize; + for user_id in user_ids { + if guild_id + .move_member(&ctx.http, user_id, target_channel) + .await + .is_ok() + { + moved += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BringAll") + .description(format!("{} membres déplacés.", moved)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_renew(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let channel_id = args + .first() + .and_then(|raw| parse_channel_id(raw)) + .unwrap_or(msg.channel_id); + + let Ok(channel) = channel_id.to_channel(&ctx.http).await else { + return; + }; + let Channel::Guild(text_channel) = channel else { + return; + }; + + if text_channel.kind != ChannelType::Text && text_channel.kind != ChannelType::News { + return; + } + + let parent_id = text_channel.parent_id; + let topic = text_channel.topic.clone(); + let nsfw = text_channel.nsfw; + let slowmode = text_channel.rate_limit_per_user; + let name = text_channel.name.clone(); + + let _ = text_channel.delete(&ctx.http).await; + + let mut builder = CreateChannel::new(name) + .kind(ChannelType::Text) + .nsfw(nsfw) + .rate_limit_per_user(slowmode.unwrap_or(0)); + + if let Some(parent) = parent_id { + builder = builder.category(parent); + } + if let Some(topic) = topic { + builder = builder.topic(topic); + } + + let _ = guild_id.create_channel(&ctx.http, builder).await; +} + +pub async fn handle_unbanall(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let bans = guild_id + .bans(&ctx.http, None, None) + .await + .unwrap_or_default(); + + let mut unbanned = 0usize; + for ban in bans { + if guild_id.unban(&ctx.http, ban.user.id).await.is_ok() { + unbanned += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("UnbanAll") + .description(format!("{} bannissements retirés.", unbanned)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_temprole(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.len() < 3 { + return; + } + + let Some(user_id) = parse_user_id(args[0]) else { + return; + }; + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + + let Some(role) = parse_role(&guild, args[1]) else { + return; + }; + + let Some(duration) = duration_from_input(args[2]) else { + return; + }; + + let expires_at = Utc::now() + chrono::Duration::seconds(duration.as_secs() as i64); + + if let Ok(member) = guild_id.member(&ctx.http, user_id).await { + let _ = member.add_role(&ctx.http, role.id).await; + } + + if let Some(pool) = pool(ctx).await { + let bot_id = ctx.cache.current_user().id; + let _ = sqlx::query( + r#" + INSERT INTO bot_temproles (bot_id, guild_id, user_id, role_id, expires_at, active, added_by) + VALUES ($1, $2, $3, $4, $5, TRUE, $6) + ON CONFLICT (bot_id, guild_id, user_id, role_id) + DO UPDATE SET expires_at = EXCLUDED.expires_at, active = TRUE, added_by = EXCLUDED.added_by; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(user_id.get() as i64) + .bind(role.id.get() as i64) + .bind(expires_at) + .bind(msg.author.id.get() as i64) + .execute(&pool) + .await; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("TempRole") + .description(format!( + "Rôle <@&{}> ajouté à <@{}> jusqu'à .", + role.id.get(), + user_id.get(), + expires_at.timestamp() + )) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_untemprole(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.len() < 2 { + return; + } + + let Some(user_id) = parse_user_id(args[0]) else { + return; + }; + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + + let Some(role) = parse_role(&guild, args[1]) else { + return; + }; + + if let Ok(member) = guild_id.member(&ctx.http, user_id).await { + let _ = member.remove_role(&ctx.http, role.id).await; + } + + if let Some(pool) = pool(ctx).await { + let bot_id = ctx.cache.current_user().id; + let _ = sqlx::query( + r#" + UPDATE bot_temproles + SET active = FALSE + WHERE bot_id = $1 AND guild_id = $2 AND user_id = $3 AND role_id = $4; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(user_id.get() as i64) + .bind(role.id.get() as i64) + .execute(&pool) + .await; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("UnTempRole") + .description(format!( + "Rôle <@&{}> retiré à <@{}>.", + role.id.get(), + user_id.get() + )) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_sync(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(scope) = args.first() else { + return; + }; + + let Ok(channels) = guild_id.channels(&ctx.http).await else { + return; + }; + + let ids_to_sync = if scope.eq_ignore_ascii_case("all") { + channels.keys().copied().collect::>() + } else if let Some(channel_id) = parse_channel_id(scope) { + if let Some(target) = channels.get(&channel_id) { + if target.kind == ChannelType::Category { + channels + .values() + .filter(|ch| ch.parent_id == Some(channel_id)) + .map(|ch| ch.id) + .collect::>() + } else { + vec![channel_id] + } + } else { + vec![channel_id] + } + } else { + Vec::new() + }; + + let mut synced = 0usize; + for channel_id in ids_to_sync { + let Some(channel) = channels.get(&channel_id) else { + continue; + }; + let Some(parent_id) = channel.parent_id else { + continue; + }; + let Some(parent) = channels.get(&parent_id) else { + continue; + }; + + if channel_id + .edit( + &ctx.http, + EditChannel::new().permissions(parent.permission_overwrites.clone()), + ) + .await + .is_ok() + { + synced += 1; + } + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Sync") + .description(format!("{} salons synchronisés.", synced)) + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_button(ctx: &Context, msg: &Message, args: &[&str]) { + if args.len() < 2 { + return; + } + + let action = args[0].to_lowercase(); + let link = args[1]; + + if action == "add" { + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .content("Bouton personnalisé") + .components(vec![CreateActionRow::Buttons(vec![ + CreateButton::new_link(link).label("Ouvrir"), + ])]), + ) + .await; + } else if action == "del" { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Button") + .description("Suppression: supprime simplement le message contenant le bouton.") + .color(theme_color(ctx).await), + ) + .await; + } +} + +pub async fn handle_autoreact(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(action) = args.first().map(|s| s.to_lowercase()) else { + let embed = CreateEmbed::new() + .title("AutoReact") + .description("Utilise les boutons pour ajouter/supprimer/lister via UI.") + .color(theme_color(ctx).await); + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_AUTOREACT_ADD_MODAL, msg.author.id)) + .label("Ajouter") + .style(ButtonStyle::Success), + CreateButton::new(adv_component_id(ADV_AUTOREACT_DEL_MODAL, msg.author.id)) + .label("Supprimer") + .style(ButtonStyle::Danger), + CreateButton::new(adv_component_id(ADV_AUTOREACT_LIST, msg.author.id)) + .label("Lister") + .style(ButtonStyle::Primary), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id; + + if action == "list" { + let rows = sqlx::query_as::<_, (i64, String)>( + r#" + SELECT channel_id, emoji + FROM bot_autoreacts + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY channel_id ASC, emoji ASC; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + let desc = if rows.is_empty() { + "Aucun autoreact configuré.".to_string() + } else { + rows.into_iter() + .map(|(channel_id, emoji)| format!("- <#{}> -> {}", channel_id, emoji)) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AutoReact") + .description(desc) + .color(theme_color(ctx).await), + ) + .await; + return; + } + + if args.len() < 3 { + return; + } + + let Some(channel_id) = parse_channel_id(args[1]) else { + return; + }; + let emoji = args[2]; + + if action == "add" { + let _ = sqlx::query( + r#" + INSERT INTO bot_autoreacts (bot_id, guild_id, channel_id, emoji) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bot_id, guild_id, channel_id, emoji) DO NOTHING; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(channel_id.get() as i64) + .bind(emoji) + .execute(&pool) + .await; + } else if action == "del" { + let _ = sqlx::query( + r#" + DELETE FROM bot_autoreacts + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 AND emoji = $4; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(channel_id.get() as i64) + .bind(emoji) + .execute(&pool) + .await; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AutoReact") + .description("Configuration mise à jour.") + .color(theme_color(ctx).await), + ) + .await; +} + +pub async fn handle_end(ctx: &Context, msg: &Message, args: &[&str]) { + if args.is_empty() { + let embed = CreateEmbed::new() + .title("End") + .description("Utilise le bouton pour terminer un giveaway via modal.") + .color(theme_color(ctx).await); + let components = vec![CreateActionRow::Buttons(vec![ + CreateButton::new(adv_component_id(ADV_GIVEAWAY_END_MODAL, msg.author.id)) + .label("Terminer un giveaway") + .style(ButtonStyle::Danger), + ])]; + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; + return; + } + + if args + .first() + .map(|v| v.eq_ignore_ascii_case("giveaway")) + .unwrap_or(false) + { + handle_end_giveaway(ctx, msg, args).await; + } +} + +pub async fn handle_create(ctx: &Context, msg: &Message, args: &[&str]) { + handle_create_emoji(ctx, msg, args).await; +} + +pub async fn handle_cleanup(ctx: &Context, msg: &Message, args: &[&str]) { + handle_cleanup_voice(ctx, msg, args).await; +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + let Some((action, owner_id)) = parse_owner_component_id(&component.data.custom_id) else { + if component.data.custom_id == "adv:giveaway:join" { + respond_ephemeral(ctx, component, "Participation enregistrée. Bonne chance !").await; + return true; + } + return false; + }; + + if component.user.id.get() != owner_id { + respond_ephemeral( + ctx, + component, + "Seul l'auteur du menu peut utiliser ce bouton.", + ) + .await; + return true; + } + + let open_modal = |custom_id: String, title: &str, rows: Vec| { + CreateInteractionResponse::Modal(CreateModal::new(custom_id, title).components(rows)) + }; + + let response = match action { + ADV_GIVEAWAY_OPEN_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Créer un Giveaway", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Titre", "title") + .required(true) + .max_length(100), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Durée (ex: 10m)", "duration") + .required(true) + .max_length(20), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Nombre de gagnants", "winners") + .required(true) + .max_length(3), + ), + ], + )), + ADV_GIVEAWAY_END_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Terminer un Giveaway", + vec![CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "ID du message", "message_id") + .required(true) + .max_length(30), + )], + )), + ADV_CHOOSE_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Choose", + vec![CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Paragraph, + "Options (séparées par |)", + "options", + ) + .required(true) + .max_length(1500), + )], + )), + ADV_EMBED_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Créer un Embed", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Titre", "title") + .required(true) + .max_length(256), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Paragraph, "Description", "description") + .required(true) + .max_length(4000), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Couleur hex (optionnel)", "color") + .required(false) + .max_length(8), + ), + ], + )), + ADV_LOADING_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Créer un Loading", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Durée (ex: 20s)", "duration") + .required(true) + .max_length(20), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Message", "message") + .required(true) + .max_length(120), + ), + ], + )), + ADV_BACKUP_CREATE_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Créer une Backup", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Type (serveur/emoji)", "kind") + .required(true) + .max_length(20), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Nom", "name") + .required(true) + .max_length(80), + ), + ], + )), + ADV_BACKUP_LIST_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Lister les Backups", + vec![CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Type (serveur/emoji)", "kind") + .required(true) + .max_length(20), + )], + )), + ADV_BACKUP_LOAD_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Charger une Backup", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Type (serveur/emoji)", "kind") + .required(true) + .max_length(20), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Nom", "name") + .required(true) + .max_length(80), + ), + ], + )), + ADV_BACKUP_DELETE_MODAL => Some(open_modal( + component.data.custom_id.clone(), + "Supprimer une Backup", + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Type (serveur/emoji)", "kind") + .required(true) + .max_length(20), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Nom", "name") + .required(true) + .max_length(80), + ), + ], + )), + ADV_AUTOREACT_ADD_MODAL | ADV_AUTOREACT_DEL_MODAL => Some(open_modal( + component.data.custom_id.clone(), + if action == ADV_AUTOREACT_ADD_MODAL { + "Ajouter AutoReact" + } else { + "Supprimer AutoReact" + }, + vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Salon (#id)", "channel") + .required(true) + .max_length(50), + ), + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Emoji", "emoji") + .required(true) + .max_length(80), + ), + ], + )), + ADV_AUTOREACT_LIST => { + let Some(guild_id) = component.guild_id else { + respond_ephemeral(ctx, component, "Commande disponible uniquement en serveur.") + .await; + return true; + }; + + let Some(pool) = pool(ctx).await else { + respond_ephemeral(ctx, component, "Base de données indisponible.").await; + return true; + }; + + let bot_id = ctx.cache.current_user().id; + let rows = sqlx::query_as::<_, (i64, String)>( + r#" + SELECT channel_id, emoji + FROM bot_autoreacts + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY channel_id ASC, emoji ASC; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + let text = if rows.is_empty() { + "Aucun autoreact configuré.".to_string() + } else { + rows.into_iter() + .map(|(channel_id, emoji)| format!("- <#{}> -> {}", channel_id, emoji)) + .collect::>() + .join("\n") + }; + + respond_ephemeral(ctx, component, text).await; + None + } + _ => None, + }; + + if let Some(response) = response { + let _ = component.create_response(&ctx.http, response).await; + return true; + } + + false +} + +pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { + let Some((action, owner_id)) = parse_owner_component_id(&modal.data.custom_id) else { + return false; + }; + + if modal.user.id.get() != owner_id { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Seul l'auteur du menu peut soumettre ce formulaire.") + .ephemeral(true), + ), + ) + .await; + return true; + } + + let Some(guild_id) = modal.guild_id else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Cette action nécessite un serveur.") + .ephemeral(true), + ), + ) + .await; + return true; + }; + + match action { + ADV_GIVEAWAY_OPEN_MODAL => { + let title = modal_value(modal, "title").unwrap_or_else(|| "Giveaway".to_string()); + let duration = modal_value(modal, "duration").unwrap_or_else(|| "10m".to_string()); + let winners = modal_value(modal, "winners").unwrap_or_else(|| "1".to_string()); + + let embed = CreateEmbed::new() + .title(format!("🎉 {}", title)) + .description(format!( + "Clique sur le bouton pour participer.\nDurée: **{}**\nGagnants: **{}**", + duration, winners + )) + .color(theme_color(ctx).await) + .footer(CreateEmbedFooter::new( + "Utilise +end giveaway pour terminer", + )); + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .embed(embed) + .components(vec![CreateActionRow::Buttons(vec![ + CreateButton::new("adv:giveaway:join") + .label("Participer") + .emoji('🎉') + .style(ButtonStyle::Success), + ])]), + ), + ) + .await; + return true; + } + ADV_GIVEAWAY_END_MODAL => { + let message_id = modal_value(modal, "message_id") + .and_then(|v| v.trim().parse::().ok()) + .map(MessageId::new); + + if let Some(message_id) = message_id { + let _ = modal + .channel_id + .edit_message( + &ctx.http, + message_id, + EditMessage::new().content("🎉 Giveaway terminé manuellement."), + ) + .await; + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Giveaway terminé.") + .ephemeral(true), + ), + ) + .await; + } else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("ID invalide.") + .ephemeral(true), + ), + ) + .await; + } + return true; + } + ADV_CHOOSE_MODAL => { + let content = modal_value(modal, "options").unwrap_or_default(); + let options = content + .split('|') + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect::>(); + + let text = if options.len() >= 2 { + let pick = options + .choose(&mut rand::thread_rng()) + .cloned() + .unwrap_or_else(|| options[0].clone()); + format!("Résultat: **{}**", pick) + } else { + "Donne au moins 2 options séparées par `|`.".to_string() + }; + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(text) + .ephemeral(true), + ), + ) + .await; + return true; + } + ADV_EMBED_MODAL => { + let title = modal_value(modal, "title").unwrap_or_else(|| "Embed".to_string()); + let description = modal_value(modal, "description").unwrap_or_default(); + let color_raw = modal_value(modal, "color").unwrap_or_default(); + let color = u32::from_str_radix( + color_raw + .trim() + .trim_start_matches('#') + .trim_start_matches("0x"), + 16, + ) + .unwrap_or(theme_color(ctx).await); + + let embed = CreateEmbed::new() + .title(title) + .description(description) + .color(color); + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().embed(embed), + ), + ) + .await; + return true; + } + ADV_LOADING_MODAL => { + let duration_raw = modal_value(modal, "duration").unwrap_or_else(|| "10s".to_string()); + let message = modal_value(modal, "message").unwrap_or_else(|| "Chargement".to_string()); + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Animation lancée.") + .ephemeral(true), + ), + ) + .await; + + let Some(duration) = duration_from_input(&duration_raw) else { + return true; + }; + let total_secs = duration.as_secs().clamp(1, 120); + let ctx_cloned = ctx.clone(); + let channel_id = modal.channel_id; + tokio::spawn(async move { + let mut sent = match channel_id + .send_message( + &ctx_cloned.http, + CreateMessage::new().content("[----------] 0%"), + ) + .await + { + Ok(m) => m, + Err(_) => return, + }; + + for i in 0..=10_u64 { + let done = "#".repeat(i as usize); + let todo = "-".repeat((10 - i) as usize); + let percent = i * 10; + + let _ = sent + .edit( + &ctx_cloned.http, + EditMessage::new() + .content(format!("{} [{}{}] {}%", message, done, todo, percent)), + ) + .await; + + if i < 10 { + tokio::time::sleep(Duration::from_secs((total_secs / 10).max(1))).await; + } + } + }); + + return true; + } + ADV_BACKUP_CREATE_MODAL + | ADV_BACKUP_LIST_MODAL + | ADV_BACKUP_LOAD_MODAL + | ADV_BACKUP_DELETE_MODAL => { + let Some(pool) = pool(ctx).await else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Base de données indisponible.") + .ephemeral(true), + ), + ) + .await; + return true; + }; + let bot_id = ctx.cache.current_user().id; + let kind_raw = modal_value(modal, "kind").unwrap_or_default(); + let Some(kind) = parse_backup_kind(&kind_raw) else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Type invalide: utilise serveur ou emoji.") + .ephemeral(true), + ), + ) + .await; + return true; + }; + + let text = if action == ADV_BACKUP_CREATE_MODAL { + let name = modal_value(modal, "name").unwrap_or_else(|| "backup".to_string()); + match create_backup_internal(ctx, guild_id, kind, name.trim()).await { + Ok(()) => format!("Backup `{}` ({}) créée.", name.trim(), kind), + Err(err) => format!("Erreur: {}", err), + } + } else if action == ADV_BACKUP_LIST_MODAL { + let rows = sqlx::query_as::<_, (String, DateTime)>( + r#" + SELECT backup_name, created_at + FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 + ORDER BY created_at DESC; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + if rows.is_empty() { + "Aucune backup enregistrée.".to_string() + } else { + rows.into_iter() + .map(|(name, ts)| format!("- `{}` · ", name, ts.timestamp())) + .collect::>() + .join("\n") + } + } else if action == ADV_BACKUP_LOAD_MODAL { + let name = modal_value(modal, "name").unwrap_or_default(); + let row = sqlx::query_as::<_, (serde_json::Value,)>( + r#" + SELECT payload + FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 AND backup_name = $4 + LIMIT 1; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(name.trim()) + .fetch_optional(&pool) + .await + .ok() + .flatten(); + + if let Some((payload_value,)) = row { + if kind == "emoji" { + match serde_json::from_value::(payload_value) { + Ok(payload) => match restore_emoji_backup(ctx, guild_id, payload).await + { + Ok(count) => format!("Load emoji terminé: {} emojis créés.", count), + Err(err) => format!("Erreur load emoji: {}", err), + }, + Err(err) => format!("Payload invalide: {err}"), + } + } else { + match serde_json::from_value::(payload_value) { + Ok(payload) => { + match restore_server_backup(ctx, guild_id, payload).await { + Ok((roles, channels, members)) => format!( + "Load serveur terminé: {} rôles, {} salons, {} membres.", + roles, channels, members + ), + Err(err) => format!("Erreur load serveur: {}", err), + } + } + Err(err) => format!("Payload invalide: {err}"), + } + } + } else { + "Backup introuvable.".to_string() + } + } else { + let name = modal_value(modal, "name").unwrap_or_default(); + let deleted = sqlx::query( + r#" + DELETE FROM bot_backups + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 AND backup_name = $4; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(kind) + .bind(name.trim()) + .execute(&pool) + .await + .ok() + .map(|res| res.rows_affected()) + .unwrap_or(0); + + if deleted > 0 { + format!("Backup `{}` supprimée.", name.trim()) + } else { + format!("Aucune backup `{}` trouvée.", name.trim()) + } + }; + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(text) + .ephemeral(true), + ), + ) + .await; + + return true; + } + ADV_AUTOREACT_ADD_MODAL | ADV_AUTOREACT_DEL_MODAL => { + let Some(pool) = pool(ctx).await else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Base de données indisponible.") + .ephemeral(true), + ), + ) + .await; + return true; + }; + let bot_id = ctx.cache.current_user().id; + + let channel_raw = modal_value(modal, "channel").unwrap_or_default(); + let emoji = modal_value(modal, "emoji").unwrap_or_default(); + let Some(channel_id) = parse_channel_id(channel_raw.trim()) else { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Salon invalide.") + .ephemeral(true), + ), + ) + .await; + return true; + }; + + if action == ADV_AUTOREACT_ADD_MODAL { + let _ = sqlx::query( + r#" + INSERT INTO bot_autoreacts (bot_id, guild_id, channel_id, emoji) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bot_id, guild_id, channel_id, emoji) DO NOTHING; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(channel_id.get() as i64) + .bind(emoji.trim()) + .execute(&pool) + .await; + } else { + let _ = sqlx::query( + r#" + DELETE FROM bot_autoreacts + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 AND emoji = $4; + "#, + ) + .bind(bot_id.get() as i64) + .bind(guild_id.get() as i64) + .bind(channel_id.get() as i64) + .bind(emoji.trim()) + .execute(&pool) + .await; + } + + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Configuration AutoReact mise à jour.") + .ephemeral(true), + ), + ) + .await; + return true; + } + _ => {} + } + + false +} diff --git a/src/commands/alias.rs b/src/commands/alias.rs new file mode 100644 index 0000000..e50baaa --- /dev/null +++ b/src/commands/alias.rs @@ -0,0 +1,137 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{add_list_fields, send_embed}; +use crate::db::{ + DbPoolKey, get_command_alias, list_command_aliases, remove_command_alias, set_command_alias, +}; +use crate::permissions::all_command_keys; + +pub async fn handle_alias(ctx: &Context, msg: &Message, args: &[&str]) { + let bot_id = ctx.cache.current_user().id; + let Some(pool) = pool(ctx).await else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + if args.len() == 1 { + let aliases = list_command_aliases(&pool, bot_id) + .await + .unwrap_or_default(); + let lines = aliases + .into_iter() + .map(|(alias, command)| format!("`{}` -> `{}`", alias, command)) + .collect::>(); + + let mut embed = serenity::builder::CreateEmbed::new() + .title("Aliases") + .color(0x5865F2); + embed = add_list_fields(embed, &lines, "Aliases enregistrés"); + send_embed(ctx, msg, embed).await; + return; + } + + if args.len() < 2 { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+alias `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + if args[0].eq_ignore_ascii_case("remove") || args[0].eq_ignore_ascii_case("delete") { + let alias_name = args[1].trim_start_matches('+').to_lowercase(); + if alias_name.is_empty() { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Alias invalide.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let removed = remove_command_alias(&pool, bot_id, &alias_name) + .await + .unwrap_or(0); + let embed = serenity::builder::CreateEmbed::new() + .title("Alias supprimé") + .description(format!("`{}` : {} suppression(s).", alias_name, removed)) + .color(0x57F287); + send_embed(ctx, msg, embed).await; + return; + } + + let command = args[0].trim_start_matches('+').to_lowercase(); + if !all_command_keys() + .iter() + .any(|candidate| candidate == &command) + { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Commande cible inconnue.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let alias_name = args[1].trim_start_matches('+').to_lowercase(); + if alias_name.is_empty() { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Alias invalide.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let _ = set_command_alias(&pool, bot_id, &alias_name, &command).await; + let embed = serenity::builder::CreateEmbed::new() + .title("Alias créé") + .description(format!( + "`{}` devient un alias de `{}`", + alias_name, command + )) + .color(0x57F287); + send_embed(ctx, msg, embed).await; +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +pub async fn resolve_alias(ctx: &Context, command: &str) -> Option { + let bot_id = ctx.cache.current_user().id; + let pool = pool(ctx).await?; + get_command_alias(&pool, bot_id, command) + .await + .ok() + .flatten() +} + +pub async fn resolve_command_alias_name(ctx: &Context, command: &str) -> Option { + resolve_alias(ctx, command).await +} +pub struct AliasCommand; +pub static COMMAND_DESCRIPTOR: AliasCommand = AliasCommand; + +impl crate::commands::command_contract::CommandSpec for AliasCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "alias", + command: "alias", + category: "permissions", + params: " | remove | list", + summary: "Gere les aliases personnalises", + description: "Liste, ajoute ou supprime des aliases de commandes stockes en base.", + examples: &["+alias", "+as", "+help alias"], + alias_source_key: "alias", + default_aliases: &["als"], + } + } +} diff --git a/src/commands/alladmins.rs b/src/commands/alladmins.rs new file mode 100644 index 0000000..b160173 --- /dev/null +++ b/src/commands/alladmins.rs @@ -0,0 +1,150 @@ +use serenity::builder::CreateEmbed; +use serenity::model::guild::Role; +use serenity::model::prelude::*; +use serenity::prelude::*; +use std::collections::HashMap; + +use crate::commands::common::{add_list_fields, has_flag, mention_user, parse_limit, send_embed}; + +pub async fn handle_alladmins(ctx: &Context, msg: &Message, args: &[&str]) { + let limit = parse_limit(args, 25, 100); + let detailed = has_flag(args, &["--details", "-d", "full"]); + + let Some(guild_id) = msg.guild_id else { + let embed = CreateEmbed::new() + .title("Commande invalide") + .description("Cette commande doit être utilisée dans un serveur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let partial_guild = match guild_id.to_partial_guild(&ctx.http).await { + Ok(guild) => guild, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer le serveur: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let members = match guild_id.members(&ctx.http, None, None).await { + Ok(members) => members, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer les membres: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let mut admin_members = members + .iter() + .filter(|member| { + !member.user.bot + && has_admin_permission(member, partial_guild.owner_id, &partial_guild.roles) + }) + .collect::>(); + + admin_members.sort_by_key(|member| member.user.name.to_lowercase()); + + if admin_members.is_empty() { + let embed = CreateEmbed::new() + .title("Admins (hors bots)") + .description("Aucun administrateur (hors bots) trouvé.") + .color(0xFEE75C); + send_embed(ctx, msg, embed).await; + return; + } + + let visible = admin_members.iter().take(limit).collect::>(); + let lines = visible + .iter() + .map(|member| { + if detailed { + let top_role = member + .roles + .iter() + .filter_map(|role_id| partial_guild.roles.get(role_id)) + .max_by_key(|role| role.position) + .map(|role| role.name.clone()) + .unwrap_or_else(|| "Aucun".to_string()); + + format!( + "- {} | ID: {} | Roles: {} | Top: {}", + mention_user(member.user.id), + member.user.id, + member.roles.len(), + top_role + ) + } else { + format!( + "- {} (ID: {})", + mention_user(member.user.id), + member.user.id + ) + } + }) + .collect::>(); + + let ratio = (admin_members.len() as f64 / members.len() as f64) * 100.0; + let mut embed = CreateEmbed::new() + .title("Admins (hors bots)") + .description(format!( + "Serveur: **{}**\nAdmins humains: **{}** / Membres: **{}** ({:.1}%)", + partial_guild.name, + admin_members.len(), + members.len(), + ratio + )) + .color(0x5865F2) + .field("Owner", partial_guild.owner_id.mention().to_string(), true); + + embed = add_list_fields( + embed, + &lines, + &format!( + "Liste ({} affichés / {} total)", + visible.len(), + admin_members.len() + ), + ); + + send_embed(ctx, msg, embed).await; +} + +fn has_admin_permission(member: &Member, owner_id: UserId, roles: &HashMap) -> bool { + if member.user.id == owner_id { + return true; + } + + member.roles.iter().any(|role_id| { + roles + .get(role_id) + .is_some_and(|role| role.permissions.contains(Permissions::ADMINISTRATOR)) + }) +} + +pub struct AlladminsCommand; +pub static COMMAND_DESCRIPTOR: AlladminsCommand = AlladminsCommand; + +impl crate::commands::command_contract::CommandSpec for AlladminsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "alladmins", + command: "alladmins", + category: "general", + params: "aucun", + summary: "Liste les administrateurs du serveur", + description: "Affiche les membres qui possedent des droits administrateur sur le serveur.", + examples: &["+alladmins", "+as", "+help alladmins"], + alias_source_key: "alladmins", + default_aliases: &["aad"], + } + } +} diff --git a/src/commands/allbots.rs b/src/commands/allbots.rs new file mode 100644 index 0000000..341446b --- /dev/null +++ b/src/commands/allbots.rs @@ -0,0 +1,133 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{ + add_list_fields, discord_ts, has_flag, mention_user, parse_limit, send_embed, +}; + +pub async fn handle_allbots(ctx: &Context, msg: &Message, args: &[&str]) { + let limit = parse_limit(args, 25, 100); + let detailed = has_flag(args, &["--details", "-d", "full"]); + + let Some(guild_id) = msg.guild_id else { + let embed = CreateEmbed::new() + .title("Commande invalide") + .description("Cette commande doit être utilisée dans un serveur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let guild = match guild_id.to_partial_guild(&ctx.http).await { + Ok(guild) => guild, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer le serveur: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let members = match guild_id.members(&ctx.http, None, None).await { + Ok(members) => members, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer les membres: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let mut bots = members + .iter() + .filter(|member| member.user.bot) + .collect::>(); + + bots.sort_by_key(|member| member.user.name.to_lowercase()); + + if bots.is_empty() { + let embed = CreateEmbed::new() + .title("Bots du serveur") + .description("Aucun bot trouvé sur ce serveur.") + .color(0xFEE75C); + send_embed(ctx, msg, embed).await; + return; + } + + let visible = bots.iter().take(limit).collect::>(); + let lines = visible + .iter() + .map(|member| { + if detailed { + format!( + "- {} | ID: {} | Créé: {}", + mention_user(member.user.id), + member.user.id, + discord_ts(member.user.created_at(), "F") + ) + } else { + format!( + "- {} (ID: {})", + mention_user(member.user.id), + member.user.id + ) + } + }) + .collect::>(); + + let bot_ratio = (bots.len() as f64 / members.len() as f64) * 100.0; + let mut embed = CreateEmbed::new() + .title("Bots présents sur le serveur") + .description(format!( + "Serveur: **{}**\nBots: **{}** / Membres: **{}** ({:.1}%)", + guild.name, + bots.len(), + members.len(), + bot_ratio + )) + .color(0x5865F2); + + if let Some(newest_bot) = bots.iter().max_by_key(|member| member.user.created_at()) { + embed = embed.field( + "Bot le plus récent", + format!( + "{} ({})", + mention_user(newest_bot.user.id), + discord_ts(newest_bot.user.created_at(), "F") + ), + false, + ); + } + + embed = add_list_fields( + embed, + &lines, + &format!("Liste ({} affichés / {} total)", visible.len(), bots.len()), + ); + + send_embed(ctx, msg, embed).await; +} + +pub struct AllbotsCommand; +pub static COMMAND_DESCRIPTOR: AllbotsCommand = AllbotsCommand; + +impl crate::commands::command_contract::CommandSpec for AllbotsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "allbots", + command: "allbots", + category: "general", + params: "aucun", + summary: "Liste tous les bots du serveur", + description: "Affiche la liste des membres bots presents sur le serveur courant.", + examples: &["+allbots", "+as", "+help allbots"], + alias_source_key: "allbots", + default_aliases: &["abt"], + } + } +} diff --git a/src/commands/allperms.rs b/src/commands/allperms.rs new file mode 100644 index 0000000..799d316 --- /dev/null +++ b/src/commands/allperms.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::perms_service; + +pub async fn handle_allperms(ctx: &Context, msg: &Message, args: &[&str]) { + perms_service::handle_allperms(ctx, msg, args).await; +} + +pub struct AllpermsCommand; +pub static COMMAND_DESCRIPTOR: AllpermsCommand = AllpermsCommand; + +impl crate::commands::command_contract::CommandSpec for AllpermsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "allperms", + command: "allperms", + category: "permissions", + params: "[page]", + summary: "Liste les ACL de toutes commandes", + description: "Affiche le niveau ACL requis pour chaque commande avec pagination.", + examples: &["+allperms", "+as", "+help allperms"], + alias_source_key: "allperms", + default_aliases: &["apm"], + } + } +} diff --git a/src/commands/autobackup.rs b/src/commands/autobackup.rs new file mode 100644 index 0000000..cea0170 --- /dev/null +++ b/src/commands/autobackup.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_autobackup(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_autobackup(ctx, msg, args).await; +} + +pub struct AutoBackupCommand; +pub static COMMAND_DESCRIPTOR: AutoBackupCommand = AutoBackupCommand; + +impl crate::commands::command_contract::CommandSpec for AutoBackupCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "autobackup", + command: "autobackup", + category: "admin", + params: " ", + summary: "Configure les backups automatiques", + description: "Definit l'intervalle en jours des backups automatiques.", + examples: &["+autobackup serveur 3", "+autobackup emoji 7"], + alias_source_key: "autobackup", + default_aliases: &["abkp"], + } + } +} diff --git a/src/commands/autoconfiglog.rs b/src/commands/autoconfiglog.rs new file mode 100644 index 0000000..28581f7 --- /dev/null +++ b/src/commands/autoconfiglog.rs @@ -0,0 +1,26 @@ +use crate::commands::logs_service; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_autoconfiglog(ctx: &Context, msg: &Message) { + logs_service::handle_autoconfiglog(ctx, msg).await; +} + +pub struct AutoconfiglogCommand; +pub static COMMAND_DESCRIPTOR: AutoconfiglogCommand = AutoconfiglogCommand; + +impl crate::commands::command_contract::CommandSpec for AutoconfiglogCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "autoconfiglog", + command: "autoconfiglog", + category: "admin", + params: "aucun", + summary: "Cree tous les salons de logs", + description: "Cree automatiquement les salons de logs et les configure.", + examples: &["+autoconfiglog"], + alias_source_key: "autoconfiglog", + default_aliases: &["acl"], + } + } +} diff --git a/src/commands/autopublish.rs b/src/commands/autopublish.rs new file mode 100644 index 0000000..38041fd --- /dev/null +++ b/src/commands/autopublish.rs @@ -0,0 +1,112 @@ +use chrono::Utc; +use serenity::builder::CreateEmbed; +use serenity::model::Colour; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{parse_channel_id, send_embed}; +use crate::db; + +pub async fn handle_autopublish(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + if args.is_empty() { + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let channels = db::get_autopublish_channels(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or_default(); + let description = if channels.is_empty() { + "Aucun salon d'annonces configuré.".to_string() + } else { + channels + .into_iter() + .map(|channel| format!("<#{}>", channel.channel_id)) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Autopublish") + .description(description) + .colour(Colour::from_rgb(100, 150, 255)), + ) + .await; + return; + } + + let enabled = args[0].eq_ignore_ascii_case("on") || args[0].eq_ignore_ascii_case("enable"); + let disabled = args[0].eq_ignore_ascii_case("off") || args[0].eq_ignore_ascii_case("disable"); + if !enabled && !disabled { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Autopublish") + .description("Utilisation: +autopublish on|off [#canal]") + .color(0xED4245), + ) + .await; + return; + } + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_i64 = guild_id.get() as i64; + let channel_id = args + .get(1) + .and_then(|value| parse_channel_id(value)) + .unwrap_or(msg.channel_id); + + let result = if enabled { + db::add_autopublish_channel(&pool, bot_id, guild_id_i64, channel_id.get() as i64).await + } else { + db::remove_autopublish_channel(&pool, bot_id, guild_id_i64, channel_id.get() as i64).await + }; + + if result.is_err() { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Autopublish") + .description("Impossible de mettre à jour le salon d'annonces.") + .color(0xED4245), + ) + .await; + return; + } + + let embed = if enabled { + CreateEmbed::new() + .title("Autopublish activé") + .description(format!("Salon: <#{}>", channel_id.get())) + .colour(Colour::from_rgb(0, 200, 120)) + .timestamp(Utc::now()) + } else { + CreateEmbed::new() + .title("Autopublish désactivé") + .description(format!("Salon: <#{}>", channel_id.get())) + .colour(Colour::from_rgb(255, 120, 0)) + .timestamp(Utc::now()) + }; + + send_embed(ctx, msg, embed).await; +} diff --git a/src/commands/autoreact.rs b/src/commands/autoreact.rs new file mode 100644 index 0000000..ddc05c1 --- /dev/null +++ b/src/commands/autoreact.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_autoreact(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_autoreact(ctx, msg, args).await; +} + +pub struct AutoReactCommand; +pub static COMMAND_DESCRIPTOR: AutoReactCommand = AutoReactCommand; + +impl crate::commands::command_contract::CommandSpec for AutoReactCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "autoreact", + command: "autoreact", + category: "admin", + params: " | list", + summary: "Configure les reactions automatiques", + description: "Ajoute, retire et liste les reactions automatiquement appliquees aux messages d'un salon.", + examples: &["+autoreact add #general 😀", "+autoreact list"], + alias_source_key: "autoreact", + default_aliases: &["ar", "reactauto"], + } + } +} diff --git a/src/commands/backup.rs b/src/commands/backup.rs new file mode 100644 index 0000000..cf50d7a --- /dev/null +++ b/src/commands/backup.rs @@ -0,0 +1,31 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_backup(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_backup(ctx, msg, args).await; +} + +pub struct BackupCommand; +pub static COMMAND_DESCRIPTOR: BackupCommand = BackupCommand; + +impl crate::commands::command_contract::CommandSpec for BackupCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "backup", + command: "backup", + category: "admin", + params: " | list/delete/load", + summary: "Gere les backups serveur et emojis", + description: "Cree, liste, supprime et recharge des backups serveur ou emojis.", + examples: &[ + "+backup serveur prod_1", + "+backup list serveur", + "+backup load emoji nightly", + ], + alias_source_key: "backup", + default_aliases: &["bkp"], + } + } +} diff --git a/src/commands/ban.rs b/src/commands/ban.rs new file mode 100644 index 0000000..32710d3 --- /dev/null +++ b/src/commands/ban.rs @@ -0,0 +1,23 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; +pub async fn handle_ban(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_ban(ctx, msg, args, false).await; +} +pub struct BanCommand; +pub static COMMAND_DESCRIPTOR: BanCommand = BanCommand; +impl crate::commands::command_contract::CommandSpec for BanCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "ban", + command: "ban", + category: "admin", + params: "<@membre/ID[,..]> [raison]", + summary: "Bannit un membre", + description: "Ban un ou plusieurs membres.", + examples: &["+ban @User"], + alias_source_key: "ban", + default_aliases: &["b"], + } + } +} diff --git a/src/commands/banlist.rs b/src/commands/banlist.rs new file mode 100644 index 0000000..2f644cf --- /dev/null +++ b/src/commands/banlist.rs @@ -0,0 +1,23 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; +pub async fn handle_banlist(ctx: &Context, msg: &Message) { + moderation_tools::handle_banlist(ctx, msg).await; +} +pub struct BanlistCommand; +pub static COMMAND_DESCRIPTOR: BanlistCommand = BanlistCommand; +impl crate::commands::command_contract::CommandSpec for BanlistCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "banlist", + command: "banlist", + category: "admin", + params: "aucun", + summary: "Liste les bans", + description: "Affiche la liste des bannissements en cours.", + examples: &["+banlist"], + alias_source_key: "banlist", + default_aliases: &["bls"], + } + } +} diff --git a/src/commands/banner.rs b/src/commands/banner.rs new file mode 100644 index 0000000..c007b04 --- /dev/null +++ b/src/commands/banner.rs @@ -0,0 +1,78 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; + +pub async fn handle_banner(ctx: &Context, msg: &Message, args: &[&str]) { + let user = if args.is_empty() { + msg.author.clone() + } else { + let user_id = args[0] + .trim_start_matches('<') + .trim_end_matches('>') + .trim_start_matches('@') + .trim_start_matches('!') + .parse::() + .ok() + .map(UserId::new); + + if let Some(uid) = user_id { + match ctx.http.get_user(uid).await { + Ok(u) => u, + Err(_) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Utilisateur non trouvé.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + } + } else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Impossible de parser l'utilisateur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let banner_url = user.banner_url().unwrap_or_default(); + + if banner_url.is_empty() { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("{} n'a pas de bannière.", user.name)) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let embed = CreateEmbed::new() + .title(format!("Bannière de {}", user.name)) + .image(banner_url) + .color(0x5865F2); + + send_embed(ctx, msg, embed).await; +} + +pub struct BannerCommand; +pub static COMMAND_DESCRIPTOR: BannerCommand = BannerCommand; + +impl crate::commands::command_contract::CommandSpec for BannerCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "banner", + command: "banner", + category: "general", + params: "<@membre/ID>", + summary: "Affiche la banniere utilisateur", + description: "Affiche la banniere de profil dun utilisateur cible ou de lauteur.", + examples: &["+banner", "+br", "+help banner"], + alias_source_key: "banner", + default_aliases: &["bnr"], + } + } +} diff --git a/src/commands/bl.rs b/src/commands/bl.rs new file mode 100644 index 0000000..629c51a --- /dev/null +++ b/src/commands/bl.rs @@ -0,0 +1,92 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::admin_common::{ban_user_everywhere, ensure_owner, parse_user_id}; +use crate::commands::common::{add_list_fields, send_embed, theme_color, truncate_text}; +use crate::db::{DbPoolKey, add_to_blacklist, list_blacklist}; + +pub async fn handle_bl(ctx: &Context, msg: &Message, args: &[&str]) { + if ensure_owner(ctx, msg).await.is_err() { + return; + } + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + if args.is_empty() { + let rows = list_blacklist(&pool, bot_id).await.unwrap_or_default(); + let lines = rows + .iter() + .map(|r| format!("<@{}> · {}", r.user_id, truncate_text(&r.reason, 80))) + .collect::>(); + + let color = theme_color(ctx).await; + let mut embed = serenity::builder::CreateEmbed::new() + .title("Blacklist") + .color(color); + embed = add_list_fields(embed, &lines, "Membres blacklistés"); + send_embed(ctx, msg, embed).await; + return; + } + + let Some(target) = parse_user_id(args[0]) else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Membre invalide.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let reason = if args.len() > 1 { + args[1..].join(" ") + } else { + "Aucune raison fournie".to_string() + }; + + let _ = add_to_blacklist(&pool, bot_id, target, &reason, Some(msg.author.id)).await; + let (ok, ko) = ban_user_everywhere(ctx, target, &format!("Blacklist: {}", reason)).await; + + let embed = serenity::builder::CreateEmbed::new() + .title("Blacklist mise à jour") + .description(format!("<@{}> a été blacklisté.", target.get())) + .field("Raison", truncate_text(&reason, 1024), false) + .field( + "Bans appliqués", + format!("{} réussis · {} échecs", ok, ko), + false, + ) + .color(0x57F287); + send_embed(ctx, msg, embed).await; +} + +pub struct BlCommand; +pub static COMMAND_DESCRIPTOR: BlCommand = BlCommand; + +impl crate::commands::command_contract::CommandSpec for BlCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "bl", + command: "bl", + category: "admin", + params: "[<@membre/ID> [raison...]]", + summary: "Gere la blacklist globale", + description: "Affiche la blacklist ou ajoute un utilisateur a la blacklist globale du bot.", + examples: &["+bl", "+help bl"], + alias_source_key: "bl", + default_aliases: &["bls"], + } + } +} diff --git a/src/commands/blinfo.rs b/src/commands/blinfo.rs new file mode 100644 index 0000000..baad3b5 --- /dev/null +++ b/src/commands/blinfo.rs @@ -0,0 +1,97 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::admin_common::{ensure_owner, parse_user_id}; +use crate::commands::common::{send_embed, truncate_text}; +use crate::db::{DbPoolKey, get_blacklist_info}; + +pub async fn handle_blinfo(ctx: &Context, msg: &Message, args: &[&str]) { + if ensure_owner(ctx, msg).await.is_err() { + return; + } + + if args.is_empty() { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+blinfo <@membre/ID>`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let Some(target) = parse_user_id(args[0]) else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Membre invalide.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let info = get_blacklist_info(&pool, bot_id, target) + .await + .ok() + .flatten(); + let Some(info) = info else { + let embed = serenity::builder::CreateEmbed::new() + .title("Blacklist") + .description("Ce membre n'est pas blacklisté.") + .color(0xFF0000); + send_embed(ctx, msg, embed).await; + return; + }; + + let added_at = crate::commands::common::discord_ts( + Timestamp::from_unix_timestamp(info.added_at.timestamp()) + .unwrap_or_else(|_| Timestamp::now()), + "F", + ); + + let by = info + .added_by + .map(|id| format!("<@{}>", id)) + .unwrap_or_else(|| "Inconnu".to_string()); + + let embed = serenity::builder::CreateEmbed::new() + .title("Informations blacklist") + .field("Membre", format!("<@{}>", info.user_id), true) + .field("Ajouté par", by, true) + .field("Ajouté le", added_at, true) + .field("Raison", truncate_text(&info.reason, 1024), false) + .color(0xFF0000); + send_embed(ctx, msg, embed).await; +} + +pub struct BlinfoCommand; +pub static COMMAND_DESCRIPTOR: BlinfoCommand = BlinfoCommand; + +impl crate::commands::command_contract::CommandSpec for BlinfoCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "blinfo", + command: "blinfo", + category: "admin", + params: "<@membre/ID>", + summary: "Affiche les details blacklist", + description: "Affiche les details de blacklist pour un utilisateur donne.", + examples: &["+blinfo", "+bo", "+help blinfo"], + alias_source_key: "blinfo", + default_aliases: &["bli"], + } + } +} diff --git a/src/commands/boostembed.rs b/src/commands/boostembed.rs new file mode 100644 index 0000000..8119c82 --- /dev/null +++ b/src/commands/boostembed.rs @@ -0,0 +1,26 @@ +use crate::commands::logs_service; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_boostembed(ctx: &Context, msg: &Message, args: &[&str]) { + logs_service::handle_boostembed(ctx, msg, args).await; +} + +pub struct BoostembedCommand; +pub static COMMAND_DESCRIPTOR: BoostembedCommand = BoostembedCommand; + +impl crate::commands::command_contract::CommandSpec for BoostembedCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "boostembed", + command: "boostembed", + category: "admin", + params: "", + summary: "Active, coupe ou teste l embed boost", + description: "Controle l embed de boost et permet un test rapide.", + examples: &["+boostembed on", "+boostembed test"], + alias_source_key: "boostembed", + default_aliases: &["bembed"], + } + } +} diff --git a/src/commands/boosters.rs b/src/commands/boosters.rs new file mode 100644 index 0000000..83083bf --- /dev/null +++ b/src/commands/boosters.rs @@ -0,0 +1,145 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{ + add_list_fields, discord_ts, has_flag, mention_user, parse_limit, send_embed, +}; + +pub async fn handle_boosters(ctx: &Context, msg: &Message, args: &[&str]) { + let limit = parse_limit(args, 25, 100); + let detailed = has_flag(args, &["--details", "-d", "full"]); + let recent_first = has_flag(args, &["--recent", "recent", "-r"]); + + let Some(guild_id) = msg.guild_id else { + let embed = CreateEmbed::new() + .title("Commande invalide") + .description("Cette commande doit être utilisée dans un serveur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let guild = match guild_id.to_partial_guild(&ctx.http).await { + Ok(guild) => guild, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer le serveur: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let members = match guild_id.members(&ctx.http, None, None).await { + Ok(members) => members, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer les membres: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let mut boosters = members + .iter() + .filter(|member| member.premium_since.is_some()) + .collect::>(); + + if recent_first { + boosters.sort_by(|left, right| right.premium_since.cmp(&left.premium_since)); + } else { + boosters.sort_by_key(|member| member.user.name.to_lowercase()); + } + + if boosters.is_empty() { + let embed = CreateEmbed::new() + .title("Boosters du serveur") + .description("Aucun booster trouvé sur ce serveur.") + .color(0xFEE75C); + send_embed(ctx, msg, embed).await; + return; + } + + let visible = boosters.iter().take(limit).collect::>(); + let lines = visible + .iter() + .map(|member| { + if detailed { + format!( + "- {} | ID: {} | Boost depuis: {}", + mention_user(member.user.id), + member.user.id, + member + .premium_since + .map(|value| discord_ts(value, "F")) + .unwrap_or_else(|| "Inconnu".to_string()) + ) + } else { + format!("- {}", mention_user(member.user.id)) + } + }) + .collect::>(); + + let ratio = (boosters.len() as f64 / members.len() as f64) * 100.0; + let mut embed = CreateEmbed::new() + .title("Membres boostant le serveur") + .description(format!( + "Serveur: **{}**\nBoosters: **{}** / Membres: **{}** ({:.1}%)", + guild.name, + boosters.len(), + members.len(), + ratio + )) + .color(0x5865F2); + + if let Some(last_boost) = boosters + .iter() + .filter_map(|member| { + member + .premium_since + .map(|since| (mention_user(member.user.id), since)) + }) + .max_by_key(|(_, since)| *since) + { + embed = embed.field( + "Dernier boost", + format!("{} ({})", last_boost.0, discord_ts(last_boost.1, "F")), + false, + ); + } + + embed = add_list_fields( + embed, + &lines, + &format!( + "Liste ({} affichés / {} total)", + visible.len(), + boosters.len() + ), + ); + + send_embed(ctx, msg, embed).await; +} + +pub struct BoostersCommand; +pub static COMMAND_DESCRIPTOR: BoostersCommand = BoostersCommand; + +impl crate::commands::command_contract::CommandSpec for BoostersCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "boosters", + command: "boosters", + category: "general", + params: "aucun", + summary: "Liste les boosters du serveur", + description: "Affiche les membres qui boostent actuellement le serveur.", + examples: &["+boosters", "+bs", "+help boosters"], + alias_source_key: "boosters", + default_aliases: &["bst"], + } + } +} diff --git a/src/commands/boostlog.rs b/src/commands/boostlog.rs new file mode 100644 index 0000000..e94124b --- /dev/null +++ b/src/commands/boostlog.rs @@ -0,0 +1,26 @@ +use crate::commands::logs_service; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_boostlog(ctx: &Context, msg: &Message, args: &[&str]) { + logs_service::handle_log_toggle(ctx, msg, args, "boost", "BoostLog").await; +} + +pub struct BoostlogCommand; +pub static COMMAND_DESCRIPTOR: BoostlogCommand = BoostlogCommand; + +impl crate::commands::command_contract::CommandSpec for BoostlogCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "boostlog", + command: "boostlog", + category: "admin", + params: "", + summary: "Active les logs de boosts", + description: "Active ou desactive les logs de boosts.", + examples: &["+boostlog on #logs", "+boostlog off"], + alias_source_key: "boostlog", + default_aliases: &["blog"], + } + } +} diff --git a/src/commands/botadmins.rs b/src/commands/botadmins.rs new file mode 100644 index 0000000..8c1db44 --- /dev/null +++ b/src/commands/botadmins.rs @@ -0,0 +1,149 @@ +use serenity::builder::CreateEmbed; +use serenity::model::guild::Role; +use serenity::model::prelude::*; +use serenity::prelude::*; +use std::collections::HashMap; + +use crate::commands::common::{ + add_list_fields, discord_ts, has_flag, mention_user, parse_limit, send_embed, +}; + +pub async fn handle_botadmins(ctx: &Context, msg: &Message, args: &[&str]) { + let limit = parse_limit(args, 25, 100); + let detailed = has_flag(args, &["--details", "-d", "full"]); + + let Some(guild_id) = msg.guild_id else { + let embed = CreateEmbed::new() + .title("Commande invalide") + .description("Cette commande doit être utilisée dans un serveur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let partial_guild = match guild_id.to_partial_guild(&ctx.http).await { + Ok(guild) => guild, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer le serveur: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let members = match guild_id.members(&ctx.http, None, None).await { + Ok(members) => members, + Err(why) => { + let embed = CreateEmbed::new() + .title("Erreur") + .description(format!("Impossible de récupérer les membres: {why}")) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let mut admin_bots = members + .iter() + .filter(|member| { + member.user.bot + && has_admin_permission(member, partial_guild.owner_id, &partial_guild.roles) + }) + .collect::>(); + + admin_bots.sort_by_key(|member| member.user.name.to_lowercase()); + + if admin_bots.is_empty() { + let embed = CreateEmbed::new() + .title("Bots administrateurs") + .description("Aucun bot administrateur trouvé.") + .color(0xFEE75C); + send_embed(ctx, msg, embed).await; + return; + } + + let visible = admin_bots.iter().take(limit).collect::>(); + let lines = visible + .iter() + .map(|member| { + if detailed { + format!( + "- {} | ID: {} | Roles: {} | Créé: {}", + mention_user(member.user.id), + member.user.id, + member.roles.len(), + discord_ts(member.user.created_at(), "F") + ) + } else { + format!( + "- {} (ID: {})", + mention_user(member.user.id), + member.user.id + ) + } + }) + .collect::>(); + + let total_bots = members.iter().filter(|member| member.user.bot).count(); + let ratio = if total_bots == 0 { + 0.0 + } else { + (admin_bots.len() as f64 / total_bots as f64) * 100.0 + }; + + let mut embed = CreateEmbed::new() + .title("Bots administrateurs") + .description(format!( + "Serveur: **{}**\nBots admin: **{}** / Bots totaux: **{}** ({:.1}%)", + partial_guild.name, + admin_bots.len(), + total_bots, + ratio + )) + .color(0x5865F2); + + embed = add_list_fields( + embed, + &lines, + &format!( + "Liste ({} affichés / {} total)", + visible.len(), + admin_bots.len() + ), + ); + + send_embed(ctx, msg, embed).await; +} + +fn has_admin_permission(member: &Member, owner_id: UserId, roles: &HashMap) -> bool { + if member.user.id == owner_id { + return true; + } + + member.roles.iter().any(|role_id| { + roles + .get(role_id) + .is_some_and(|role| role.permissions.contains(Permissions::ADMINISTRATOR)) + }) +} + +pub struct BotadminsCommand; +pub static COMMAND_DESCRIPTOR: BotadminsCommand = BotadminsCommand; + +impl crate::commands::command_contract::CommandSpec for BotadminsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "botadmins", + command: "botadmins", + category: "general", + params: "aucun", + summary: "Liste les admins du bot", + description: "Affiche les utilisateurs ayant des droits admin sur le bot.", + examples: &["+botadmins", "+bs", "+help botadmins"], + alias_source_key: "botadmins", + default_aliases: &["bad"], + } + } +} diff --git a/src/commands/botconfig_common.rs b/src/commands/botconfig_common.rs new file mode 100644 index 0000000..f1efd52 --- /dev/null +++ b/src/commands/botconfig_common.rs @@ -0,0 +1,166 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::activity::{RotatingActivityKind, parse_status, start_rotation, stop_rotation}; +use crate::commands::common::send_embed; +use crate::db::{DbPoolKey, set_bot_status}; + +pub fn parse_color(value: &str) -> Option { + let v = value.trim().to_lowercase(); + match v.as_str() { + "red" | "rouge" => Some(0xED4245), + "green" | "vert" => Some(0x57F287), + "blue" | "bleu" => Some(0x5865F2), + "yellow" | "jaune" => Some(0xFEE75C), + "orange" => Some(0xFAA61A), + "purple" | "violet" => Some(0x9B59B6), + "pink" | "rose" => Some(0xEB459E), + "white" | "blanc" => Some(0xFFFFFF), + "black" | "noir" => Some(0x000000), + _ => { + let hex = v.trim_start_matches('#').trim_start_matches("0x"); + u32::from_str_radix(hex, 16).ok() + } + } +} + +pub async fn save_status_if_db(ctx: &Context, status: &str) { + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + let _ = set_bot_status(&pool, bot_id, status).await; + } +} + +pub async fn handle_activity(ctx: &Context, msg: &Message, command: &str, args: &[&str]) { + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Usage: `+playto|+listen|+watch|+compet|+stream `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let Some(kind) = RotatingActivityKind::from_command(command) else { + return; + }; + + let joined = args.join(" "); + let messages: Vec = joined + .split(",,") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if messages.is_empty() { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Aucun message d'activité valide.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let bot_id = ctx.cache.current_user().id; + + let status = { + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + if let Ok(Some(saved)) = crate::db::get_bot_status(&pool, bot_id).await { + parse_status(&saved) + } else { + OnlineStatus::Online + } + } else { + OnlineStatus::Online + } + }; + + start_rotation(ctx, kind, messages.clone(), status).await; + + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + let _ = + crate::db::set_bot_activity(&pool, bot_id, kind.as_db(), &messages.join("\n")).await; + } + + let embed = CreateEmbed::new() + .title("Activité mise à jour") + .description(format!("{} message(s) configuré(s).", messages.len())) + .field( + "Rotation", + "Les textes alternent toutes les 30 secondes.", + false, + ) + .color(0x57F287); + + send_embed(ctx, msg, embed).await; +} + +pub async fn handle_remove_activity(ctx: &Context, msg: &Message) { + stop_rotation(ctx).await; + ctx.set_activity(None); + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + let _ = crate::db::clear_bot_activity(&pool, bot_id).await; + } + + let embed = CreateEmbed::new() + .title("Activité supprimée") + .description("L'activité du bot a été retirée.") + .color(0x57F287); + + send_embed(ctx, msg, embed).await; +} + +pub async fn handle_status(ctx: &Context, msg: &Message, command: &str) { + let status_name = match command { + "+online" => { + ctx.online(); + "online" + } + "+idle" => { + ctx.idle(); + "idle" + } + "+dnd" => { + ctx.dnd(); + "dnd" + } + "+invisible" => { + ctx.invisible(); + "invisible" + } + _ => return, + }; + + save_status_if_db(ctx, status_name).await; + + let embed = CreateEmbed::new() + .title("Statut mis à jour") + .description(format!("Nouveau statut: {}", status_name)) + .color(0x57F287); + + send_embed(ctx, msg, embed).await; +} diff --git a/src/commands/botconfig_service.rs b/src/commands/botconfig_service.rs new file mode 100644 index 0000000..ba6e333 --- /dev/null +++ b/src/commands/botconfig_service.rs @@ -0,0 +1,46 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::activity::{RotatingActivityKind, parse_status, start_rotation}; +use crate::db::DbPoolKey; + +pub async fn restore_presence_from_db(ctx: &Context) { + let bot_id = ctx.cache.current_user().id; + + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + return; + }; + + let status = match crate::db::get_bot_status(&pool, bot_id).await { + Ok(Some(saved)) => parse_status(&saved), + _ => OnlineStatus::Online, + }; + + ctx.set_presence(None, status); + + let activity_row = crate::db::get_bot_activity(&pool, bot_id) + .await + .ok() + .flatten(); + if let Some((kind_raw, messages_raw)) = activity_row { + let Some(kind) = RotatingActivityKind::from_db(&kind_raw) else { + return; + }; + + let messages: Vec = messages_raw + .split('\n') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if !messages.is_empty() { + start_rotation(ctx, kind, messages, status).await; + } + } +} diff --git a/src/commands/bringall.rs b/src/commands/bringall.rs new file mode 100644 index 0000000..2866f95 --- /dev/null +++ b/src/commands/bringall.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_bringall(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_bringall(ctx, msg, args).await; +} + +pub struct BringAllCommand; +pub static COMMAND_DESCRIPTOR: BringAllCommand = BringAllCommand; + +impl crate::commands::command_contract::CommandSpec for BringAllCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "bringall", + command: "bringall", + category: "admin", + params: "[salon_vocal_destination]", + summary: "Rassemble tous les vocaux", + description: "Deplace tous les membres actuellement en vocal vers un salon cible.", + examples: &["+bringall #Event", "+bringall"], + alias_source_key: "bringall", + default_aliases: &["ball", "vbring"], + } + } +} diff --git a/src/commands/button.rs b/src/commands/button.rs new file mode 100644 index 0000000..51c3640 --- /dev/null +++ b/src/commands/button.rs @@ -0,0 +1,30 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_button(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_button(ctx, msg, args).await; +} + +pub struct ButtonCommand; +pub static COMMAND_DESCRIPTOR: ButtonCommand = ButtonCommand; + +impl crate::commands::command_contract::CommandSpec for ButtonCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "button", + command: "button", + category: "admin", + params: " ", + summary: "Gere des boutons decoratifs", + description: "Ajoute ou supprime un bouton de decoration personnalise sur un message du bot.", + examples: &[ + "+button add https://example.com", + "+button del https://example.com", + ], + alias_source_key: "button", + default_aliases: &["btn"], + } + } +} diff --git a/src/commands/calc.rs b/src/commands/calc.rs new file mode 100644 index 0000000..fdde5f3 --- /dev/null +++ b/src/commands/calc.rs @@ -0,0 +1,108 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +fn parse_linear_expression(expr: &str) -> Option<(f64, f64)> { + let normalized = expr.replace(' ', "").replace('-', "+-"); + let mut a = 0.0f64; + let mut b = 0.0f64; + + for raw in normalized.split('+') { + let term = raw.trim(); + if term.is_empty() { + continue; + } + + if term.contains('x') { + let coeff = term.replace('x', ""); + let c = if coeff.is_empty() || coeff == "+" { + 1.0 + } else if coeff == "-" { + -1.0 + } else { + coeff.parse::().ok()? + }; + a += c; + } else { + b += term.parse::().ok()?; + } + } + + Some((a, b)) +} + +fn solve_linear_equation(input: &str) -> Option { + let parts: Vec<&str> = input.split('=').collect(); + if parts.len() != 2 { + return None; + } + + let (a1, b1) = parse_linear_expression(parts[0])?; + let (a2, b2) = parse_linear_expression(parts[1])?; + + let a = a1 - a2; + let b = b2 - b1; + + if a.abs() < f64::EPSILON { + if b.abs() < f64::EPSILON { + return Some("Équation indéterminée (infinité de solutions).".to_string()); + } + return Some("Équation impossible (aucune solution).".to_string()); + } + + let x = b / a; + Some(format!("x = {}", x)) +} + +pub async fn handle_calc(ctx: &Context, msg: &Message, args: &[&str]) { + let color = theme_color(ctx).await; + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Usage: `+calc `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let query = args.join(" "); + + let result = if query.contains('=') && query.contains('x') { + solve_linear_equation(&query) + .unwrap_or_else(|| "Impossible de résoudre cette équation.".to_string()) + } else { + match meval::eval_str(&query) { + Ok(value) => value.to_string(), + Err(_) => "Expression invalide.".to_string(), + } + }; + + let embed = CreateEmbed::new() + .title("Calcul") + .field("Entrée", query, false) + .field("Résultat", result, false) + .color(color); + + send_embed(ctx, msg, embed).await; +} + +pub struct CalcCommand; +pub static COMMAND_DESCRIPTOR: CalcCommand = CalcCommand; + +impl crate::commands::command_contract::CommandSpec for CalcCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "calc", + command: "calc", + category: "general", + params: "", + summary: "Calcule une expression", + description: "Evalue une expression numerique simple et renvoie le resultat.", + examples: &["+calc", "+cc", "+help calc"], + alias_source_key: "calc", + default_aliases: &["clc"], + } + } +} diff --git a/src/commands/change.rs b/src/commands/change.rs new file mode 100644 index 0000000..a79c3f8 --- /dev/null +++ b/src/commands/change.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::perms_service; + +pub async fn handle_change(ctx: &Context, msg: &Message, args: &[&str]) { + perms_service::handle_change(ctx, msg, args).await; +} + +pub struct ChangeCommand; +pub static COMMAND_DESCRIPTOR: ChangeCommand = ChangeCommand; + +impl crate::commands::command_contract::CommandSpec for ChangeCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "change", + command: "change", + category: "permissions", + params: " | reset", + summary: "Change un niveau de permission", + description: "Definit le niveau ACL requis pour une commande ou reinitialise les overrides.", + examples: &["+change", "+ce", "+help change"], + alias_source_key: "change", + default_aliases: &["chg"], + } + } +} diff --git a/src/commands/changeall.rs b/src/commands/changeall.rs new file mode 100644 index 0000000..96a0ae4 --- /dev/null +++ b/src/commands/changeall.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::perms_service; + +pub async fn handle_changeall(ctx: &Context, msg: &Message, args: &[&str]) { + perms_service::handle_changeall(ctx, msg, args).await; +} + +pub struct ChangeallCommand; +pub static COMMAND_DESCRIPTOR: ChangeallCommand = ChangeallCommand; + +impl crate::commands::command_contract::CommandSpec for ChangeallCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "changeall", + command: "changeall", + category: "permissions", + params: " ", + summary: "Change des permissions en masse", + description: "Remplace en masse un niveau ACL source par un niveau ACL cible.", + examples: &["+changeall", "+cl", "+help changeall"], + alias_source_key: "changeall", + default_aliases: &["cga"], + } + } +} diff --git a/src/commands/channel.rs b/src/commands/channel.rs new file mode 100644 index 0000000..39513e1 --- /dev/null +++ b/src/commands/channel.rs @@ -0,0 +1,112 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::parse_channel_id; +use crate::commands::common::{discord_ts, send_embed}; + +pub async fn handle_channel(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Ok(guild) = guild_id.to_partial_guild(&ctx).await else { + return; + }; + + let channels = guild.channels(ctx).await.unwrap_or_default(); + + let channel_id = if !args.is_empty() { + let search = args.join(" ").to_lowercase(); + parse_channel_id(args[0]).or_else(|| { + // Chercher par nom de canal + channels + .iter() + .find(|(_, c)| c.name.to_lowercase().contains(&search)) + .map(|(id, _)| *id) + }) + } else { + Some(msg.channel_id) + }; + + let Some(channel_id) = channel_id else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Impossible de parser le canal.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let Ok(channel) = channel_id.to_channel(&ctx).await else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Canal non trouvé.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + match channel { + Channel::Guild(gc) => { + let created_at = discord_ts(gc.id.created_at(), "F"); + let channel_type = match gc.kind { + ChannelType::Text => "Texte", + ChannelType::Voice => "Vocal", + ChannelType::Private => "Privé", + ChannelType::Category => "Catégorie", + ChannelType::News => "Annonces", + ChannelType::Stage => "Stage", + ChannelType::Directory => "Répertoire", + ChannelType::Forum => "Forum", + ChannelType::Unknown(_) => "Inconnu", + _ => "Autre", + }; + + let mut embed = CreateEmbed::new() + .title(&gc.name) + .description(format!("ID: `{}`", gc.id.get())) + .color(0x5865F2) + .field("Type", channel_type, true) + .field("Créé", created_at, true); + + if let Some(topic) = &gc.topic { + embed = embed.field("Sujet", topic, false); + } + + if let Some(bitrate) = gc.bitrate { + embed = embed.field("Bitrate", format!("{} kbps", bitrate / 1000), true); + } + + embed = embed.field("NSFW", if gc.nsfw { "Oui" } else { "Non" }, true); + + send_embed(ctx, msg, embed).await; + } + _ => { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Ce type de canal n'est pas supporté.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + } + } +} + +pub struct ChannelCommand; +pub static COMMAND_DESCRIPTOR: ChannelCommand = ChannelCommand; + +impl crate::commands::command_contract::CommandSpec for ChannelCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "channel", + command: "channel", + category: "general", + params: "<#salon/ID>", + summary: "Affiche les details dun salon", + description: "Affiche les informations utiles dun salon texte ou vocal cible.", + examples: &["+channel", "+cl", "+help channel"], + alias_source_key: "channel", + default_aliases: &["chl"], + } + } +} diff --git a/src/commands/choose.rs b/src/commands/choose.rs new file mode 100644 index 0000000..4bed721 --- /dev/null +++ b/src/commands/choose.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_choose(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_choose(ctx, msg, args).await; +} + +pub struct ChooseCommand; +pub static COMMAND_DESCRIPTOR: ChooseCommand = ChooseCommand; + +impl crate::commands::command_contract::CommandSpec for ChooseCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "choose", + command: "choose", + category: "general", + params: "", + summary: "Tire une option au hasard", + description: "Lance un tirage au sort instantane parmi les options donnees.", + examples: &["+choose rouge | bleu | vert"], + alias_source_key: "choose", + default_aliases: &["pick", "random"], + } + } +} diff --git a/src/commands/claim.rs b/src/commands/claim.rs new file mode 100644 index 0000000..2ff6599 --- /dev/null +++ b/src/commands/claim.rs @@ -0,0 +1,69 @@ +use chrono::Utc; +use serenity::builder::CreateEmbed; +use serenity::model::Colour; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_claim(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_i64 = guild_id.get() as i64; + let channel_id = msg.channel_id.get() as i64; + + let Some(ticket) = db::get_ticket_by_channel(&pool, bot_id, guild_id_i64, channel_id) + .await + .ok() + .flatten() + else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("Ce salon n'est pas reconnu comme un ticket.") + .color(0xED4245), + ) + .await; + return; + }; + + if db::claim_ticket(&pool, ticket.id, msg.author.id.get() as i64) + .await + .is_err() + { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("Impossible de revendiquer ce ticket.") + .color(0xED4245), + ) + .await; + return; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Ticket revendiqué") + .description(format!("Le ticket #{} a été revendiqué.", ticket.id)) + .colour(Colour::from_rgb(0, 200, 120)) + .timestamp(Utc::now()), + ) + .await; +} diff --git a/src/commands/cleanup.rs b/src/commands/cleanup.rs new file mode 100644 index 0000000..ca0c1c3 --- /dev/null +++ b/src/commands/cleanup.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_cleanup(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_cleanup(ctx, msg, args).await; +} + +pub struct CleanupCommand; +pub static COMMAND_DESCRIPTOR: CleanupCommand = CleanupCommand; + +impl crate::commands::command_contract::CommandSpec for CleanupCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "cleanup", + command: "cleanup", + category: "admin", + params: "", + summary: "Vide un salon vocal", + description: "Deconnecte tous les utilisateurs presents dans un salon vocal cible.", + examples: &["+cleanup #General"], + alias_source_key: "cleanup", + default_aliases: &["vclean", "vcleanup"], + } + } +} diff --git a/src/commands/clear_all_sanctions.rs b/src/commands/clear_all_sanctions.rs new file mode 100644 index 0000000..cb63ba1 --- /dev/null +++ b/src/commands/clear_all_sanctions.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::moderation_tools; + +pub async fn handle_clear_all_sanctions(ctx: &Context, msg: &Message) { + moderation_tools::handle_clear_all_sanctions(ctx, msg).await; +} + +pub struct ClearAllSanctionsCommand; +pub static COMMAND_DESCRIPTOR: ClearAllSanctionsCommand = ClearAllSanctionsCommand; + +impl crate::commands::command_contract::CommandSpec for ClearAllSanctionsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_all_sanctions", + command: "clear all sanctions", + category: "admin", + params: "aucun", + summary: "Supprime toutes les sanctions du serveur", + description: "Efface toutes les sanctions de tous les membres du serveur.", + examples: &["+clear all sanctions"], + alias_source_key: "clear_all_sanctions", + default_aliases: &["casanctions"], + } + } +} diff --git a/src/commands/clear_bl.rs b/src/commands/clear_bl.rs new file mode 100644 index 0000000..56f10d1 --- /dev/null +++ b/src/commands/clear_bl.rs @@ -0,0 +1,53 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::admin_common::ensure_owner; +use crate::commands::common::send_embed; +use crate::db::{DbPoolKey, clear_blacklist}; + +pub async fn handle_clear_bl(ctx: &Context, msg: &Message) { + if ensure_owner(ctx, msg).await.is_err() { + return; + } + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let count = clear_blacklist(&pool, bot_id).await.unwrap_or(0); + let embed = serenity::builder::CreateEmbed::new() + .title("Blacklist réinitialisée") + .description(format!("{} membre(s) retiré(s) de la blacklist.", count)) + .color(0x57F287); + send_embed(ctx, msg, embed).await; +} + +pub struct ClearBlCommand; +pub static COMMAND_DESCRIPTOR: ClearBlCommand = ClearBlCommand; + +impl crate::commands::command_contract::CommandSpec for ClearBlCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_bl", + command: "clear bl", + category: "admin", + params: "aucun", + summary: "Vide la blacklist globale", + description: "Supprime toutes les entrees de la blacklist globale.", + examples: &["+clear bl", "+cl", "+help clear bl"], + alias_source_key: "clear_bl", + default_aliases: &["cbl"], + } + } +} diff --git a/src/commands/clear_messages.rs b/src/commands/clear_messages.rs new file mode 100644 index 0000000..950a8a2 --- /dev/null +++ b/src/commands/clear_messages.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::moderation_tools; + +pub async fn handle_clear_messages(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_clear_messages(ctx, msg, args).await; +} + +pub struct ClearMessagesCommand; +pub static COMMAND_DESCRIPTOR: ClearMessagesCommand = ClearMessagesCommand; + +impl crate::commands::command_contract::CommandSpec for ClearMessagesCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_messages", + command: "clear", + category: "admin", + params: " [@membre/ID]", + summary: "Supprime des messages dans le salon", + description: "Supprime un nombre de messages, optionnellement filtres par membre.", + examples: &["+clear 20", "+clear 20 @User"], + alias_source_key: "clear_messages", + default_aliases: &["purge"], + } + } +} diff --git a/src/commands/clear_owners.rs b/src/commands/clear_owners.rs new file mode 100644 index 0000000..850aaa2 --- /dev/null +++ b/src/commands/clear_owners.rs @@ -0,0 +1,53 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::admin_common::ensure_owner; +use crate::commands::common::send_embed; +use crate::db::{DbPoolKey, clear_bot_owners}; + +pub async fn handle_clear_owners(ctx: &Context, msg: &Message) { + if ensure_owner(ctx, msg).await.is_err() { + return; + } + + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + let Some(pool) = pool else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let count = clear_bot_owners(&pool, bot_id).await.unwrap_or(0); + let embed = serenity::builder::CreateEmbed::new() + .title("Owners réinitialisés") + .description(format!("{} owner(s) supprimé(s).", count)) + .color(0x57F287); + send_embed(ctx, msg, embed).await; +} + +pub struct ClearOwnersCommand; +pub static COMMAND_DESCRIPTOR: ClearOwnersCommand = ClearOwnersCommand; + +impl crate::commands::command_contract::CommandSpec for ClearOwnersCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_owners", + command: "clear owners", + category: "admin", + params: "aucun", + summary: "Vide la liste des owners", + description: "Supprime tous les owners supplementaires en base de donnees.", + examples: &["+clear owners", "+cs", "+help clear owners"], + alias_source_key: "clear_owners", + default_aliases: &["cro"], + } + } +} diff --git a/src/commands/clear_perms.rs b/src/commands/clear_perms.rs new file mode 100644 index 0000000..023d1da --- /dev/null +++ b/src/commands/clear_perms.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::perms_service; + +pub async fn handle_clear_perms(ctx: &Context, msg: &Message) { + perms_service::handle_clear_perms(ctx, msg).await; +} + +pub struct ClearPermsCommand; +pub static COMMAND_DESCRIPTOR: ClearPermsCommand = ClearPermsCommand; + +impl crate::commands::command_contract::CommandSpec for ClearPermsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_perms", + command: "clear perms", + category: "permissions", + params: "aucun", + summary: "Vide toutes les permissions scope", + description: "Supprime toutes les permissions ACL configurees en base.", + examples: &["+clear perms", "+cs", "+help clear perms"], + alias_source_key: "clear_perms", + default_aliases: &["cpm"], + } + } +} diff --git a/src/commands/clear_sanctions.rs b/src/commands/clear_sanctions.rs new file mode 100644 index 0000000..5a3b689 --- /dev/null +++ b/src/commands/clear_sanctions.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::moderation_tools; + +pub async fn handle_clear_sanctions(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_clear_sanctions(ctx, msg, args).await; +} + +pub struct ClearSanctionsCommand; +pub static COMMAND_DESCRIPTOR: ClearSanctionsCommand = ClearSanctionsCommand; + +impl crate::commands::command_contract::CommandSpec for ClearSanctionsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "clear_sanctions", + command: "clear sanctions", + category: "admin", + params: "<@membre/ID>", + summary: "Supprime toutes les sanctions d un membre", + description: "Efface completement les sanctions d un membre cible.", + examples: &["+clear sanctions @User"], + alias_source_key: "clear_sanctions", + default_aliases: &["csanctions"], + } + } +} diff --git a/src/commands/close.rs b/src/commands/close.rs new file mode 100644 index 0000000..cebc6ba --- /dev/null +++ b/src/commands/close.rs @@ -0,0 +1,76 @@ +use chrono::Utc; +use serenity::builder::CreateEmbed; +use serenity::model::Colour; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_close(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let reason = if args.is_empty() { + None + } else { + Some(args.join(" ")) + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_i64 = guild_id.get() as i64; + let channel_id = msg.channel_id.get() as i64; + + let Some(ticket) = db::get_ticket_by_channel(&pool, bot_id, guild_id_i64, channel_id) + .await + .ok() + .flatten() + else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("Ce salon n'est pas reconnu comme un ticket.") + .color(0xED4245), + ) + .await; + return; + }; + + if db::close_ticket(&pool, ticket.id, reason.clone()) + .await + .is_err() + { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Erreur") + .description("Impossible de fermer ce ticket.") + .color(0xED4245), + ) + .await; + return; + } + + let mut embed = CreateEmbed::new() + .title("Ticket fermé") + .description(format!("Le ticket #{} a été fermé.", ticket.id)) + .colour(Colour::from_rgb(255, 120, 0)) + .timestamp(Utc::now()); + + if let Some(reason) = reason { + embed = embed.field("Raison", reason, false); + } + + send_embed(ctx, msg, embed).await; +} diff --git a/src/commands/cmute.rs b/src/commands/cmute.rs new file mode 100644 index 0000000..105bc80 --- /dev/null +++ b/src/commands/cmute.rs @@ -0,0 +1,23 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; +pub async fn handle_cmute(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_cmute(ctx, msg, args, false).await; +} +pub struct CmuteCommand; +pub static COMMAND_DESCRIPTOR: CmuteCommand = CmuteCommand; +impl crate::commands::command_contract::CommandSpec for CmuteCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "cmute", + command: "cmute", + category: "admin", + params: "<@membre/ID[,..]> [raison]", + summary: "Mute salon", + description: "Mute un membre sur le salon courant.", + examples: &["+cmute @User"], + alias_source_key: "cmute", + default_aliases: &["cm"], + } + } +} diff --git a/src/commands/command_contract.rs b/src/commands/command_contract.rs new file mode 100644 index 0000000..4bbcb76 --- /dev/null +++ b/src/commands/command_contract.rs @@ -0,0 +1,16 @@ +#[derive(Clone, Copy)] +pub struct CommandMetadata { + pub key: &'static str, + pub command: &'static str, + pub category: &'static str, + pub params: &'static str, + pub summary: &'static str, + pub description: &'static str, + pub examples: &'static [&'static str], + pub alias_source_key: &'static str, + pub default_aliases: &'static [&'static str], +} + +pub trait CommandSpec: Send + Sync { + fn metadata(&self) -> CommandMetadata; +} diff --git a/src/commands/common.rs b/src/commands/common.rs new file mode 100644 index 0000000..0117648 --- /dev/null +++ b/src/commands/common.rs @@ -0,0 +1,141 @@ +use serenity::builder::{CreateEmbed, CreateMessage}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::db::{DbPoolKey, get_bot_theme}; + +pub fn parse_limit(args: &[&str], default: usize, max: usize) -> usize { + args.iter() + .find_map(|arg| arg.parse::().ok()) + .map(|value| value.clamp(1, max)) + .unwrap_or(default) +} + +pub fn has_flag(args: &[&str], names: &[&str]) -> bool { + args.iter() + .any(|arg| names.iter().any(|name| arg.eq_ignore_ascii_case(name))) +} + +pub fn truncate_text(input: &str, max_len: usize) -> String { + if input.chars().count() <= max_len { + return input.to_string(); + } + + let mut out = input + .chars() + .take(max_len.saturating_sub(1)) + .collect::(); + out.push('…'); + out +} + +pub fn add_list_fields(mut embed: CreateEmbed, lines: &[String], base_name: &str) -> CreateEmbed { + if lines.is_empty() { + return embed.field(base_name, "Aucun résultat.", false); + } + + let max_fields = 3; + let chunk_size = 12; + + for (index, chunk) in lines.chunks(chunk_size).take(max_fields).enumerate() { + let field_name = if index == 0 { + base_name.to_string() + } else { + format!("{} (suite {})", base_name, index + 1) + }; + + let value = truncate_text(&chunk.join("\n"), 1024); + embed = embed.field(field_name, value, false); + } + + let shown = (chunk_size * max_fields).min(lines.len()); + if lines.len() > shown { + embed = embed.field( + "Affichage", + format!("{} éléments affichés sur {}.", shown, lines.len()), + false, + ); + } + + embed +} + +pub fn mention_user(user_id: UserId) -> String { + format!("<@{}>", user_id.get()) +} + +pub fn discord_ts(timestamp: Timestamp, style: &str) -> String { + format!("", timestamp.unix_timestamp(), style) +} + +pub async fn send_embed(ctx: &Context, msg: &Message, embed: CreateEmbed) { + let color = theme_color(ctx).await; + let embed = embed.color(color); + + let _ = msg + .channel_id + .send_message(&ctx.http, CreateMessage::new().embed(embed)) + .await; +} + +pub async fn theme_color(ctx: &Context) -> u32 { + let bot_id = ctx.cache.current_user().id; + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + if let Ok(Some(color)) = get_bot_theme(&pool, bot_id).await { + return color; + } + } + + 0xFF0000 +} + +pub fn parse_role(guild: &PartialGuild, input: &str) -> Option { + // Essayer de parser comme mention <@&id> + if let Ok(id) = input + .trim_start_matches("<@&") + .trim_end_matches('>') + .parse::() + { + if let Some(role) = guild.roles.get(&RoleId::new(id)) { + return Some(role.clone()); + } + } + + // Essayer de parser comme ID brut + if let Ok(id) = input.parse::() { + if let Some(role) = guild.roles.get(&RoleId::new(id)) { + return Some(role.clone()); + } + } + + // Chercher par nom (case-insensitive) + let search = input.to_lowercase(); + guild + .roles + .values() + .find(|r| r.name.to_lowercase().contains(&search)) + .cloned() +} + +pub fn parse_channel_id(input: &str) -> Option { + // Essayer de parser comme mention <#id> + if let Ok(id) = input + .trim_start_matches("<#") + .trim_end_matches('>') + .parse::() + { + return Some(ChannelId::new(id)); + } + + // Essayer de parser comme ID brut + if let Ok(id) = input.parse::() { + return Some(ChannelId::new(id)); + } + + None +} diff --git a/src/commands/compet.rs b/src/commands/compet.rs new file mode 100644 index 0000000..1bb23ee --- /dev/null +++ b/src/commands/compet.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::botconfig_common; + +pub async fn handle_compet(ctx: &Context, msg: &Message, args: &[&str]) { + botconfig_common::handle_activity(ctx, msg, "+compet", args).await; +} + +pub struct CompetCommand; +pub static COMMAND_DESCRIPTOR: CompetCommand = CompetCommand; + +impl crate::commands::command_contract::CommandSpec for CompetCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "compet", + command: "compet", + category: "profile", + params: "", + summary: "Definit une activite competing", + description: "Configure la rotation des messages d activite en mode competing.", + examples: &["+compet", "+ct", "+help compet"], + alias_source_key: "compet", + default_aliases: &["cpt"], + } + } +} diff --git a/src/commands/create.rs b/src/commands/create.rs new file mode 100644 index 0000000..624bc89 --- /dev/null +++ b/src/commands/create.rs @@ -0,0 +1,30 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_create(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_create(ctx, msg, args).await; +} + +pub struct CreateCommand; +pub static COMMAND_DESCRIPTOR: CreateCommand = CreateCommand; + +impl crate::commands::command_contract::CommandSpec for CreateCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "create", + command: "create", + category: "admin", + params: "[emoji/url] [nom]", + summary: "Cree un emoji custom", + description: "Cree un emoji custom a partir d'une image, d'un lien ou d'un emoji nitro.", + examples: &[ + "+create <:blob:123456789012345678> blobcopy", + "+create https://... logo", + ], + alias_source_key: "create", + default_aliases: &["mkemoji", "ce"], + } + } +} diff --git a/src/commands/del.rs b/src/commands/del.rs new file mode 100644 index 0000000..463a8ce --- /dev/null +++ b/src/commands/del.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::perms_service; + +pub async fn handle_del(ctx: &Context, msg: &Message, args: &[&str]) { + perms_service::handle_del_perm(ctx, msg, args).await; +} + +pub struct DelCommand; +pub static COMMAND_DESCRIPTOR: DelCommand = DelCommand; + +impl crate::commands::command_contract::CommandSpec for DelCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "del", + command: "del", + category: "permissions", + params: "perm <@&rôle/@membre/ID>", + summary: "Supprime des permissions scope", + description: "Supprime les permissions ACL associees a un role ou utilisateur.", + examples: &["+del", "+dl", "+help del"], + alias_source_key: "del", + default_aliases: &["dlp"], + } + } +} diff --git a/src/commands/del_sanction.rs b/src/commands/del_sanction.rs new file mode 100644 index 0000000..9786eff --- /dev/null +++ b/src/commands/del_sanction.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::moderation_tools; + +pub async fn handle_del_sanction(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_del_sanction(ctx, msg, args).await; +} + +pub struct DelSanctionCommand; +pub static COMMAND_DESCRIPTOR: DelSanctionCommand = DelSanctionCommand; + +impl crate::commands::command_contract::CommandSpec for DelSanctionCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "del_sanction", + command: "del sanction", + category: "admin", + params: "<@membre/ID> ", + summary: "Supprime une sanction d un membre", + description: "Supprime une sanction specifique dans l historique d un membre.", + examples: &["+del sanction @User 1"], + alias_source_key: "del_sanction", + default_aliases: &["delsanction"], + } + } +} diff --git a/src/commands/delrole.rs b/src/commands/delrole.rs new file mode 100644 index 0000000..4af6ab4 --- /dev/null +++ b/src/commands/delrole.rs @@ -0,0 +1,26 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_delrole(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_add_del_role(ctx, msg, args, false).await; +} + +pub struct DelroleCommand; +pub static COMMAND_DESCRIPTOR: DelroleCommand = DelroleCommand; + +impl crate::commands::command_contract::CommandSpec for DelroleCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "delrole", + command: "delrole", + category: "admin", + params: "<@membre/ID[,..]> <@role/ID>", + summary: "Retire un role", + description: "Retire un role a un ou plusieurs membres.", + examples: &["+delrole @User @Membre"], + alias_source_key: "delrole", + default_aliases: &["dr"], + } + } +} diff --git a/src/commands/derank.rs b/src/commands/derank.rs new file mode 100644 index 0000000..b3ddd2d --- /dev/null +++ b/src/commands/derank.rs @@ -0,0 +1,26 @@ +use crate::commands::moderation_tools; +use serenity::model::prelude::*; +use serenity::prelude::*; + +pub async fn handle_derank(ctx: &Context, msg: &Message, args: &[&str]) { + moderation_tools::handle_derank(ctx, msg, args).await; +} + +pub struct DerankCommand; +pub static COMMAND_DESCRIPTOR: DerankCommand = DerankCommand; + +impl crate::commands::command_contract::CommandSpec for DerankCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "derank", + command: "derank", + category: "admin", + params: "<@membre/ID[,..]>", + summary: "Retire tous les roles", + description: "Retire tous les roles gerables d un membre.", + examples: &["+derank @User"], + alias_source_key: "derank", + default_aliases: &["drk"], + } + } +} diff --git a/src/commands/discussion.rs b/src/commands/discussion.rs new file mode 100644 index 0000000..0f36262 --- /dev/null +++ b/src/commands/discussion.rs @@ -0,0 +1,77 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::commands::server::resolve_guild_target; + +pub async fn handle_discussion(ctx: &Context, msg: &Message, args: &[&str]) { + if args.len() < 2 { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Usage: `+discussion `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let Some(guild_id) = resolve_guild_target(ctx, args[0]).await else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Serveur introuvable.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let content = args[1..].join(" "); + let Ok(channels) = guild_id.channels(&ctx.http).await else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Impossible de lire les salons.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + for channel in channels.values() { + if matches!(channel.kind, ChannelType::Text | ChannelType::News) { + let _ = channel + .say( + &ctx.http, + format!("[Discussion via {}] {}", msg.author.tag(), content), + ) + .await; + let embed = CreateEmbed::new() + .title("Discussion envoyée") + .description(format!("Message transmis dans `{}`.", guild_id.get())) + .color(0x57F287); + send_embed(ctx, msg, embed).await; + return; + } + } + + let embed = CreateEmbed::new() + .title("Erreur") + .description("Aucun salon texte trouvable sur ce serveur.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; +} +pub struct DiscussionCommand; +pub static COMMAND_DESCRIPTOR: DiscussionCommand = DiscussionCommand; + +impl crate::commands::command_contract::CommandSpec for DiscussionCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "discussion", + command: "discussion", + category: "profile", + params: " ", + summary: "Diffuse un message serveur", + description: "Envoie un message de discussion sur un serveur cible.", + examples: &["+discussion", "+dn", "+help discussion"], + alias_source_key: "discussion", + default_aliases: &["dsc"], + } + } +} diff --git a/src/commands/dnd.rs b/src/commands/dnd.rs new file mode 100644 index 0000000..0bf800c --- /dev/null +++ b/src/commands/dnd.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::botconfig_common; + +pub async fn handle_dnd(ctx: &Context, msg: &Message) { + botconfig_common::handle_status(ctx, msg, "+dnd").await; +} + +pub struct DndCommand; +pub static COMMAND_DESCRIPTOR: DndCommand = DndCommand; + +impl crate::commands::command_contract::CommandSpec for DndCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "dnd", + command: "dnd", + category: "profile", + params: "aucun", + summary: "Passe le bot en dnd", + description: "Change le statut du bot en do not disturb et sauvegarde ce statut.", + examples: &["+dnd", "+dd", "+help dnd"], + alias_source_key: "dnd", + default_aliases: &["dnm"], + } + } +} diff --git a/src/commands/embed.rs b/src/commands/embed.rs new file mode 100644 index 0000000..83434ba --- /dev/null +++ b/src/commands/embed.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_embed(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_embed_builder(ctx, msg, args).await; +} + +pub struct EmbedCommand; +pub static COMMAND_DESCRIPTOR: EmbedCommand = EmbedCommand; + +impl crate::commands::command_contract::CommandSpec for EmbedCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "embed", + command: "embed", + category: "admin", + params: "title | description (v1)", + summary: "Ouvre le generateur d'embed", + description: "Affiche un generateur d'embed interactif version rapide.", + examples: &["+embed"], + alias_source_key: "embed", + default_aliases: &["emb"], + } + } +} diff --git a/src/commands/emoji.rs b/src/commands/emoji.rs new file mode 100644 index 0000000..3425650 --- /dev/null +++ b/src/commands/emoji.rs @@ -0,0 +1,97 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +fn parse_custom_emoji(input: &str) -> Option<(bool, String)> { + if !(input.starts_with("<:") || input.starts_with("') { + return None; + } + + let animated = input.starts_with("'); + let parts: Vec<&str> = inner.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let id = parts[2].to_string(); + Some((animated, id)) +} + +fn unicode_emoji_url(input: &str) -> Option { + if input.is_empty() { + return None; + } + + let codepoints = input + .chars() + .filter(|c| *c as u32 != 0xFE0F) + .map(|c| format!("{:x}", c as u32)) + .collect::>() + .join("-"); + + if codepoints.is_empty() { + return None; + } + + Some(format!( + "https://twemoji.maxcdn.com/v/latest/72x72/{}.png", + codepoints + )) +} + +pub async fn handle_emoji(ctx: &Context, msg: &Message, args: &[&str]) { + let color = theme_color(ctx).await; + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Usage: `+emoji <émoji>`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let input = args.join(" "); + + let url = if let Some((animated, id)) = parse_custom_emoji(&input) { + let ext = if animated { "gif" } else { "png" }; + format!("https://cdn.discordapp.com/emojis/{}.{}?size=1024", id, ext) + } else if let Some(url) = unicode_emoji_url(input.trim()) { + url + } else { + let embed = CreateEmbed::new() + .title("Erreur") + .description("Émoji invalide. Utilise un émoji Unicode ou un émoji custom Discord.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let embed = CreateEmbed::new() + .title("Image de l'émoji") + .image(url) + .color(color); + + send_embed(ctx, msg, embed).await; +} + +pub struct EmojiCommand; +pub static COMMAND_DESCRIPTOR: EmojiCommand = EmojiCommand; + +impl crate::commands::command_contract::CommandSpec for EmojiCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "emoji", + command: "emoji", + category: "general", + params: "", + summary: "Affiche les infos dun emoji", + description: "Affiche les details dun emoji fourni.", + examples: &["+emoji", "+ei", "+help emoji"], + alias_source_key: "emoji", + default_aliases: &["emj"], + } + } +} diff --git a/src/commands/end.rs b/src/commands/end.rs new file mode 100644 index 0000000..0f5c0d5 --- /dev/null +++ b/src/commands/end.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_end(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_end(ctx, msg, args).await; +} + +pub struct EndCommand; +pub static COMMAND_DESCRIPTOR: EndCommand = EndCommand; + +impl crate::commands::command_contract::CommandSpec for EndCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "end", + command: "end", + category: "admin", + params: "giveaway ", + summary: "Termine un giveaway par ID", + description: "Permet de stopper instantanement un giveaway avec l'identifiant du message.", + examples: &["+end giveaway 123456789012345678"], + alias_source_key: "end", + default_aliases: &["gend"], + } + } +} diff --git a/src/commands/giveaway.rs b/src/commands/giveaway.rs new file mode 100644 index 0000000..d00eba3 --- /dev/null +++ b/src/commands/giveaway.rs @@ -0,0 +1,27 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::advanced_tools; + +pub async fn handle_giveaway(ctx: &Context, msg: &Message, args: &[&str]) { + advanced_tools::handle_giveaway(ctx, msg, args).await; +} + +pub struct GiveawayCommand; +pub static COMMAND_DESCRIPTOR: GiveawayCommand = GiveawayCommand; + +impl crate::commands::command_contract::CommandSpec for GiveawayCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "giveaway", + command: "giveaway", + category: "admin", + params: "aucun", + summary: "Ouvre un menu de creation de giveaway", + description: "Affiche une interface rapide pour initier un giveaway depuis le salon courant.", + examples: &["+giveaway"], + alias_source_key: "giveaway", + default_aliases: &["gstart", "gw"], + } + } +} diff --git a/src/commands/help.rs b/src/commands/help.rs new file mode 100644 index 0000000..5c75643 --- /dev/null +++ b/src/commands/help.rs @@ -0,0 +1,799 @@ +use std::collections::BTreeMap; + +use serenity::builder::{ + CreateActionRow, CreateButton, CreateCommand, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateMessage, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, +}; +use serenity::model::application::{ + Command, CommandInteraction, ComponentInteractionDataKind, Interaction, +}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::alias::resolve_alias; +use crate::commands::common::{add_list_fields, truncate_text}; +use crate::db::{ + DbPoolKey, get_help_aliases_enabled, get_help_perms_enabled, get_help_type, + list_command_aliases, +}; +use crate::permissions; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HelpLayout { + Button, + Select, + Hybrid, +} + +impl HelpLayout { + fn from_str(value: &str) -> Self { + match value.to_lowercase().as_str() { + "select" => Self::Select, + "hybrid" => Self::Hybrid, + _ => Self::Button, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Button => "button", + Self::Select => "select", + Self::Hybrid => "hybrid", + } + } +} + +#[derive(Clone)] +struct HelpPage { + key: &'static str, + title: &'static str, + description: &'static str, +} + +#[derive(Clone, Copy)] +struct CommandDoc { + key: &'static str, + command: &'static str, + params: &'static str, + summary: &'static str, + description: &'static str, + examples: &'static [&'static str], + alias_source_key: Option<&'static str>, +} + +#[derive(Clone, Copy)] +struct HelpState { + layout: HelpLayout, + aliases_enabled: bool, + perms_enabled: bool, +} + +const HELP_FALLBACK_EXAMPLES: &[&str] = &["+help", "+help ping"]; +const HELP_PAGES: &[HelpPage] = &[ + HelpPage { + key: "infos", + title: "Infos", + description: "Informations sur le serveur, les membres et les profils.", + }, + HelpPage { + key: "logs", + title: "Logs", + description: "Configuration des logs de modération, messages, vocal et boosts.", + }, + HelpPage { + key: "moderation", + title: "Modération", + description: "Sanctions, nettoyage et commandes de modération générale.", + }, + HelpPage { + key: "roles", + title: "Rôles", + description: "Gestion des rôles individuels, temporaires et massifs.", + }, + HelpPage { + key: "salons_vocal", + title: "Salons & Vocal", + description: "Gestion des salons texte et commandes de déplacement vocal.", + }, + HelpPage { + key: "outils", + title: "Outils", + description: "Giveaways, utilitaires, embeds et automatisations de contenu.", + }, + HelpPage { + key: "bot", + title: "Bot & Présence", + description: "Configuration du bot, thème, activité et présence.", + }, + HelpPage { + key: "administration", + title: "Administration", + description: "Owners, blacklist, préfixes, MP et configuration globale.", + }, + HelpPage { + key: "permissions", + title: "Permissions & Aide", + description: "Permissions, alias et configuration de l'interface d'aide.", + }, +]; + +fn help_page_for_command( + meta: &crate::commands::command_contract::CommandMetadata, +) -> &'static str { + match meta.key { + "modlog" | "messagelog" | "voicelog" | "boostlog" | "rolelog" | "raidlog" + | "autoconfiglog" | "nolog" | "join" | "boostembed" | "set_modlogs" | "set_boostembed" + | "leave_settings" | "viewlogs" => "logs", + "warn" + | "mute" + | "tempmute" + | "unmute" + | "cmute" + | "tempcmute" + | "uncmute" + | "mutelist" + | "unmuteall" + | "kick" + | "ban" + | "tempban" + | "unban" + | "banlist" + | "unbanall" + | "sanctions" + | "del_sanction" + | "clear_sanctions" + | "clear_all_sanctions" + | "cleanup" + | "renew" + | "clear_messages" => "moderation", + "addrole" | "delrole" | "derank" | "massiverole" | "unmassiverole" | "temprole" + | "untemprole" | "sync" => "roles", + "lock" | "unlock" | "lockall" | "unlockall" | "hide" | "unhide" | "hideall" + | "unhideall" | "voicemove" | "voicekick" | "bringall" => "salons_vocal", + "giveaway" | "end" | "reroll" | "choose" | "calc" | "emoji" | "embed" | "say" + | "create" | "newsticker" | "button" | "autoreact" | "snipe" | "loading" | "backup" + | "autobackup" => "outils", + "shadowbot" | "set" | "theme" | "playto" | "listen" | "watch" | "compet" | "stream" + | "remove_activity" | "online" | "idle" | "dnd" | "invisible" | "change" | "changeall" => { + "bot" + } + "owner" | "unowner" | "clear_owners" | "bl" | "unbl" | "blinfo" | "clear_bl" + | "allbots" | "alladmins" | "botadmins" | "mainprefix" | "prefix" | "mp" | "invite" + | "leave" | "discussion" => "administration", + "perms" | "del" | "clear_perms" | "allperms" | "alias" | "help" | "helptype" + | "helpalias" => "permissions", + _ => match meta.category { + "general" => "infos", + "profile" => "bot", + "admin" => "administration", + "permissions" => "permissions", + _ => "infos", + }, + } +} + +fn help_page_title(key: &str) -> &'static str { + HELP_PAGES + .iter() + .find(|page| page.key == key) + .map(|page| page.title) + .unwrap_or("Infos") +} + +fn help_page_title_for_command_key(key: &str) -> &'static str { + crate::commands::command_metadata_by_key(key) + .map(|meta| help_page_title(help_page_for_command(&meta))) + .unwrap_or("Infos") +} + +fn help_metadata_lookup_key(input: &str) -> Option<&'static str> { + let normalized = help_lookup_key(input); + let underscored = normalized.replace(' ', "_"); + + crate::commands::all_command_metadata() + .into_iter() + .find(|meta| { + meta.key.eq_ignore_ascii_case(&normalized) + || meta.key.eq_ignore_ascii_case(&underscored) + || meta.command.eq_ignore_ascii_case(&normalized) + }) + .map(|meta| meta.key) +} + +fn help_page_matches_input(page: &HelpPage, input: &str) -> bool { + let normalized = help_lookup_key(input); + let aliases = match page.key { + "infos" => &["general", "info", "informations"][..], + "logs" => &["log", "journal"][..], + "moderation" => &["mod", "sanction"][..], + "roles" => &["role", "roles"][..], + "salons_vocal" => &["salon", "salons", "vocal", "voice", "channels"][..], + "outils" => &["utilitaires", "tools", "giveaway"][..], + "bot" => &["profil", "presence", "activite", "activity"][..], + "administration" => &["admin", "admins"][..], + "permissions" => &["permission", "perms", "aide", "help"][..], + _ => &[][..], + }; + + page.key.eq_ignore_ascii_case(&normalized) + || help_lookup_key(page.title).eq_ignore_ascii_case(&normalized) + || aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(&normalized)) +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +async fn current_help_state(ctx: &Context) -> HelpState { + let bot_id = ctx.cache.current_user().id; + let pool = pool(ctx).await; + + let layout = if let Some(pool) = &pool { + get_help_type(pool, bot_id) + .await + .ok() + .flatten() + .map(|value| HelpLayout::from_str(&value)) + .unwrap_or(HelpLayout::Button) + } else { + HelpLayout::Button + }; + + let aliases_enabled = if let Some(pool) = &pool { + get_help_aliases_enabled(pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or(true) + } else { + true + }; + + let perms_enabled = if let Some(pool) = &pool { + get_help_perms_enabled(pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or(true) + } else { + true + }; + + HelpState { + layout, + aliases_enabled, + perms_enabled, + } +} + +async fn aliases_map(ctx: &Context) -> BTreeMap> { + let bot_id = ctx.cache.current_user().id; + let mut out: BTreeMap> = BTreeMap::new(); + + for meta in crate::commands::all_command_metadata() { + if !meta.default_aliases.is_empty() { + out.entry(meta.alias_source_key.to_string()) + .or_default() + .extend(meta.default_aliases.iter().map(|alias| alias.to_string())); + } + } + + if let Some(pool) = pool(ctx).await { + if let Ok(rows) = list_command_aliases(&pool, bot_id).await { + for (alias, command) in rows { + out.entry(command).or_default().push(alias); + } + } + } + + for aliases in out.values_mut() { + aliases.sort(); + aliases.dedup(); + } + + out +} + +fn command_doc(key: &str) -> Option { + let meta = match key { + "mp_settings" | "mp_sent" | "mp_delete" => crate::commands::command_metadata_by_key("mp")?, + "server_list" => crate::commands::command_metadata_by_key("server")?, + "change_reset" => crate::commands::command_metadata_by_key("change")?, + "set_perm" => crate::commands::command_metadata_by_key("set")?, + "del_perm" => crate::commands::command_metadata_by_key("del")?, + other => crate::commands::command_metadata_by_key(other)?, + }; + + Some(CommandDoc { + key: meta.key, + command: meta.command, + params: meta.params, + summary: meta.summary, + description: meta.description, + examples: if meta.examples.is_empty() { + HELP_FALLBACK_EXAMPLES + } else { + meta.examples + }, + alias_source_key: Some(meta.alias_source_key), + }) +} + +fn help_lookup_key(input: &str) -> String { + input + .trim() + .trim_start_matches('+') + .to_lowercase() + .replace('_', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn help_lookup_to_key(input: &str) -> Option<&'static str> { + let matched = match help_lookup_key(input).as_str() { + "help" => Some("help"), + "ping" => Some("ping"), + "allbots" => Some("allbots"), + "alladmins" => Some("alladmins"), + "botadmins" => Some("botadmins"), + "boosters" => Some("boosters"), + "rolemembers" => Some("rolemembers"), + "serverinfo" => Some("serverinfo"), + "vocinfo" => Some("vocinfo"), + "role" => Some("role"), + "channel" => Some("channel"), + "user" => Some("user"), + "member" => Some("member"), + "pic" => Some("pic"), + "banner" => Some("banner"), + "server" => Some("server"), + "server list" => Some("server_list"), + "snipe" => Some("snipe"), + "emoji" => Some("emoji"), + "giveaway" => Some("giveaway"), + "end" | "end giveaway" => Some("end"), + "reroll" => Some("reroll"), + "choose" => Some("choose"), + "embed" => Some("embed"), + "backup" | "backup list" | "backup delete" | "backup load" => Some("backup"), + "autobackup" => Some("autobackup"), + "loading" => Some("loading"), + "create" => Some("create"), + "newsticker" => Some("newsticker"), + "massiverole" => Some("massiverole"), + "unmassiverole" => Some("unmassiverole"), + "voicemove" => Some("voicemove"), + "voicekick" => Some("voicekick"), + "cleanup" => Some("cleanup"), + "bringall" => Some("bringall"), + "renew" => Some("renew"), + "unbanall" => Some("unbanall"), + "temprole" => Some("temprole"), + "untemprole" => Some("untemprole"), + "sync" => Some("sync"), + "button" => Some("button"), + "autoreact" => Some("autoreact"), + "calc" => Some("calc"), + "shadowbot" => Some("shadowbot"), + "set" => Some("set"), + "theme" => Some("theme"), + "playto" => Some("playto"), + "listen" => Some("listen"), + "watch" => Some("watch"), + "compet" => Some("compet"), + "stream" => Some("stream"), + "remove activity" => Some("remove_activity"), + "online" => Some("online"), + "idle" => Some("idle"), + "dnd" => Some("dnd"), + "invisible" => Some("invisible"), + "mp" => Some("mp"), + "mp settings" => Some("mp_settings"), + "mp sent" => Some("mp_sent"), + "mp delete" | "mp del" => Some("mp_delete"), + "discussion" => Some("discussion"), + "owner" => Some("owner"), + "unowner" => Some("unowner"), + "clear owners" => Some("clear_owners"), + "bl" => Some("bl"), + "unbl" => Some("unbl"), + "blinfo" => Some("blinfo"), + "clear bl" => Some("clear_bl"), + "say" => Some("say"), + "invite" => Some("invite"), + "leave" => Some("leave"), + "change" => Some("change"), + "change reset" => Some("change_reset"), + "changeall" => Some("changeall"), + "mainprefix" => Some("mainprefix"), + "prefix" => Some("prefix"), + "perms" => Some("perms"), + "allperms" => Some("allperms"), + "set perm" => Some("set_perm"), + "del perm" => Some("del_perm"), + "clear perms" => Some("clear_perms"), + "alias" => Some("alias"), + "helpsetting" => Some("helpsetting"), + _ => None, + }; + + matched.or_else(|| help_metadata_lookup_key(input)) +} + +fn help_page_index(key: &str) -> Option { + HELP_PAGES + .iter() + .position(|page| help_page_matches_input(page, key)) +} + +fn help_page_from_input(input: &str) -> Option { + if let Ok(index) = input.parse::() { + if index >= 1 && index <= HELP_PAGES.len() { + return Some(index - 1); + } + } + + help_page_index(input) +} + +fn format_permission_level(level: u8) -> String { + format!("[{}]", level) +} + +fn permission_level_description(level: u8) -> &'static str { + match level { + 0 => "[0] Public", + 2 => "[2] Accès spécial", + 8 => "[8] Modérateur+", + 9 => "[9] Propriétaire", + _ => "[?] Inconnu", + } +} + +fn help_page_content( + page: &HelpPage, + alias_map: &BTreeMap>, + aliases_enabled: bool, + perms_enabled: bool, +) -> Vec { + let mut commands = crate::commands::all_command_metadata() + .into_iter() + .filter(|meta| help_page_for_command(meta).eq_ignore_ascii_case(page.key)) + .collect::>(); + commands.sort_by(|a, b| a.command.to_lowercase().cmp(&b.command.to_lowercase())); + + let mut lines = Vec::with_capacity(commands.len()); + + for meta in commands { + let label = meta.command; + let summary = meta.summary; + let alias_key = meta.alias_source_key; + let permission = if perms_enabled { + format!(" {}", format_permission_level(permissions::default_permission(meta.key))) + } else { + String::new() + }; + + if aliases_enabled { + if let Some(aliases) = alias_map.get(alias_key) { + if aliases.is_empty() { + lines.push(format!("`+{}`{} - {}", label, permission, summary)); + } else { + lines.push(format!( + "`+{}`{} - {} · alias: `{}`", + label, permission, summary, + aliases.join("`, `") + )); + } + continue; + } + } + + lines.push(format!("`+{}`{} - {}", label, permission, summary)); + } + + if lines.is_empty() { + lines.push("Aucune commande dans cette catégorie.".to_string()); + } + + lines +} + +fn build_help_embed( + page_index: usize, + state: &HelpState, + alias_map: &BTreeMap>, +) -> CreateEmbed { + let page = &HELP_PAGES[page_index]; + let lines = help_page_content(page, alias_map, state.aliases_enabled, state.perms_enabled); + + let mut embed = CreateEmbed::new() + .title(format!("Aide · {}", page.title)) + .description(format!( + "Page {}/{} · mode `{}` · aliases {} · perms {}\n{}", + page_index + 1, + HELP_PAGES.len(), + state.layout.as_str(), + if state.aliases_enabled { + "activés" + } else { + "désactivés" + }, + if state.perms_enabled { + "activées" + } else { + "désactivées" + }, + page.description, + )) + .color(0x5865F2); + + embed = add_list_fields(embed, &lines, "Commandes"); + embed +} + +fn help_components(owner_id: UserId, page_index: usize, state: &HelpState) -> Vec { + let total = HELP_PAGES.len().max(1); + let prev_page = page_index.saturating_sub(1); + let next_page = (page_index + 1).min(total - 1); + let custom_prev = format!("help:nav:{}:{}", owner_id.get(), prev_page); + let custom_next = format!("help:nav:{}:{}", owner_id.get(), next_page); + + let mut rows = Vec::new(); + + match state.layout { + HelpLayout::Button | HelpLayout::Hybrid => { + rows.push(CreateActionRow::Buttons(vec![ + CreateButton::new(custom_prev) + .label("◀ Précédent") + .style(ButtonStyle::Primary) + .disabled(page_index == 0), + CreateButton::new(custom_next) + .label("Suivant ▶") + .style(ButtonStyle::Primary) + .disabled(page_index + 1 >= total), + ])); + } + HelpLayout::Select => {} + } + + match state.layout { + HelpLayout::Select | HelpLayout::Hybrid => { + let options = HELP_PAGES + .iter() + .enumerate() + .map(|(index, page)| { + CreateSelectMenuOption::new(page.title, index.to_string()) + .description(truncate_text(page.description, 100)) + }) + .collect::>(); + + let menu = CreateSelectMenu::new( + format!("help:select:{}", owner_id.get()), + CreateSelectMenuKind::String { options }, + ) + .placeholder("Choisir une page d'aide"); + + rows.push(CreateActionRow::SelectMenu(menu)); + } + HelpLayout::Button => {} + } + + rows +} + +fn parse_help_component_id(custom_id: &str) -> Option<(&str, u64, Option)> { + let parts = custom_id.split(':').collect::>(); + if parts.len() < 3 || parts.first().copied()? != "help" { + return None; + } + + let kind = parts.get(1).copied()?; + let owner_id = parts.get(2)?.parse::().ok()?; + let page = parts.get(3).and_then(|value| value.parse::().ok()); + Some((kind, owner_id, page)) +} + +pub async fn register_slash_help(ctx: &Context) { + let _ = Command::create_global_command( + &ctx.http, + CreateCommand::new("help").description("Affiche l'aide du bot"), + ) + .await; +} + +pub async fn handle_help_slash(ctx: &Context, command: &CommandInteraction) { + let state = current_help_state(ctx).await; + let alias_map = aliases_map(ctx).await; + let embed = build_help_embed(0, &state, &alias_map); + let components = help_components(command.user.id, 0, &state); + + let _ = command + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .embed(embed) + .components(components), + ), + ) + .await; +} + +pub async fn handle_slash_interaction(ctx: &Context, interaction: &Interaction) -> bool { + let Interaction::Command(command) = interaction else { + return false; + }; + + if command.data.name != "help" { + return false; + } + + handle_help_slash(ctx, command).await; + true +} + +pub async fn handle_help_component(ctx: &Context, component: &ComponentInteraction) -> bool { + let Some((kind, owner_id, page)) = parse_help_component_id(&component.data.custom_id) else { + return false; + }; + + if component.user.id.get() != owner_id { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Seul l'auteur de l'aide peut utiliser ces contrôles.") + .ephemeral(true), + ), + ) + .await; + return true; + } + + let state = current_help_state(ctx).await; + let alias_map = aliases_map(ctx).await; + let page_index = match kind { + "nav" => page.unwrap_or(0).min(HELP_PAGES.len().saturating_sub(1)), + "select" => match &component.data.kind { + ComponentInteractionDataKind::StringSelect { values } => values + .first() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .min(HELP_PAGES.len().saturating_sub(1)), + _ => 0, + }, + _ => 0, + }; + + let embed = build_help_embed(page_index, &state, &alias_map); + let components = help_components(component.user.id, page_index, &state); + + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(embed) + .components(components), + ), + ) + .await; + + true +} + +pub async fn handle_help(ctx: &Context, msg: &Message, args: &[&str]) { + let state = current_help_state(ctx).await; + let alias_map = aliases_map(ctx).await; + + if !args.is_empty() { + let joined = args.join(" "); + + let mut resolved_key = help_lookup_to_key(&joined).map(|s| s.to_string()); + if resolved_key.is_none() { + let first = args[0]; + if let Some(key) = help_lookup_to_key(first) { + resolved_key = Some(key.to_string()); + } + } + + if resolved_key.is_none() { + let alias_input = help_lookup_key(&joined).replace(' ', "_"); + if let Some(alias_target) = resolve_alias(ctx, &alias_input).await { + resolved_key = Some(alias_target); + } + } + + if resolved_key.is_none() { + resolved_key = help_metadata_lookup_key(&joined).map(|key| key.to_string()); + } + + if let Some(key) = resolved_key { + if let Some(doc) = command_doc(&key) { + let aliases = doc + .alias_source_key + .and_then(|alias_key| alias_map.get(alias_key)) + .cloned() + .unwrap_or_default(); + + let alias_text = if aliases.is_empty() { + "Aucun alias".to_string() + } else { + aliases + .iter() + .map(|alias| format!("`{}`", alias)) + .collect::>() + .join(", ") + }; + + let examples = doc + .examples + .iter() + .map(|ex| format!("`{}`", ex)) + .collect::>() + .join("\n"); + + let embed = CreateEmbed::new() + .title(format!("Aide commande · +{}", doc.command)) + .description(doc.description) + .field("Commande", format!("`+{}`", doc.command), false) + .field("Clé ACL", format!("`{}`", doc.key), false) + .field("Catégorie", help_page_title_for_command_key(doc.key), false) + .field("Permission", permission_level_description(permissions::default_permission(doc.key)), false) + .field("Alias", alias_text, false) + .field("Paramètres", doc.params, false) + .field("Résumé", doc.summary, false) + .field("Exemples", truncate_text(&examples, 1024), false) + .color(crate::commands::common::theme_color(ctx).await); + + let _ = msg + .channel_id + .send_message(&ctx.http, CreateMessage::new().embed(embed)) + .await; + return; + } + } + } + + let page_index = args + .first() + .and_then(|input| help_page_from_input(input)) + .unwrap_or(0) + .min(HELP_PAGES.len().saturating_sub(1)); + + let embed = build_help_embed(page_index, &state, &alias_map); + let components = help_components(msg.author.id, page_index, &state); + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(components), + ) + .await; +} +pub struct HelpCommand; +pub static COMMAND_DESCRIPTOR: HelpCommand = HelpCommand; + +impl crate::commands::command_contract::CommandSpec for HelpCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "help", + command: "help", + category: "general", + params: "[commande|page]", + summary: "Affiche laide des commandes", + description: "Affiche les pages daide du bot ou la fiche detaillee dune commande avec parametres, aliases et exemples.", + examples: &["+help", "+hp", "+help help"], + alias_source_key: "help", + default_aliases: &["hp"], + } + } +} diff --git a/src/commands/helpalias.rs b/src/commands/helpalias.rs new file mode 100644 index 0000000..e46311b --- /dev/null +++ b/src/commands/helpalias.rs @@ -0,0 +1,81 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::db::{DbPoolKey, get_help_aliases_enabled, set_help_aliases_enabled}; + +pub async fn handle_helpalias(ctx: &Context, msg: &Message, args: &[&str]) { + let bot_id = ctx.cache.current_user().id; + let Some(pool) = pool(ctx).await else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + if args.is_empty() { + let enabled = get_help_aliases_enabled(&pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or(true); + + let embed = serenity::builder::CreateEmbed::new() + .title("Aliases dans help") + .description(format!( + "État actuel: `{}`", + if enabled { "on" } else { "off" } + )) + .color(0x5865F2); + send_embed(ctx, msg, embed).await; + return; + } + + let enabled = match args[0].to_lowercase().as_str() { + "on" | "true" | "yes" => true, + "off" | "false" | "no" => false, + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+helpalias `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let _ = set_help_aliases_enabled(&pool, bot_id, enabled).await; + let embed = serenity::builder::CreateEmbed::new() + .title("Help aliases mis à jour") + .description(format!( + "Aliases dans l'aide: `{}`", + if enabled { "on" } else { "off" } + )) + .color(0x57F287); + send_embed(ctx, msg, embed).await; +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} +pub struct HelpaliasCommand; +pub static COMMAND_DESCRIPTOR: HelpaliasCommand = HelpaliasCommand; + +impl crate::commands::command_contract::CommandSpec for HelpaliasCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "helpalias", + command: "helpalias", + category: "permissions", + params: "", + summary: "Active ou coupe les aliases help", + description: "Active ou desactive laffichage des aliases dans laide.", + examples: &["+helpalias", "+hs", "+help helpalias"], + alias_source_key: "helpalias", + default_aliases: &["hal"], + } + } +} diff --git a/src/commands/helpsetting.rs b/src/commands/helpsetting.rs new file mode 100644 index 0000000..5823802 --- /dev/null +++ b/src/commands/helpsetting.rs @@ -0,0 +1,180 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::db::{ + DbPoolKey, get_help_aliases_enabled, get_help_perms_enabled, get_help_type, + set_help_aliases_enabled, set_help_perms_enabled, set_help_type, +}; + +pub async fn handle_helpsetting(ctx: &Context, msg: &Message, args: &[&str]) { + let bot_id = ctx.cache.current_user().id; + let Some(pool) = pool(ctx).await else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + if args.is_empty() { + let help_type = get_help_type(&pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or_else(|| "button".to_string()); + let help_aliases = get_help_aliases_enabled(&pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or(true); + let help_perms = get_help_perms_enabled(&pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or(true); + + let embed = serenity::builder::CreateEmbed::new() + .title("Configuration de l'aide") + .description("Paramètres actuels:") + .field("Mode d'affichage", format!("`{}`", help_type), true) + .field("Aliases", format!("`{}`", if help_aliases { "on" } else { "off" }), true) + .field( + "Permissions", + format!("`{}`", if help_perms { "on" } else { "off" }), + true, + ) + .color(0x5865F2); + send_embed(ctx, msg, embed).await; + return; + } + + match args[0].to_lowercase().as_str() { + "type" | "mode" => { + if args.len() < 2 { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+helpsetting type `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let normalized = match args[1].to_lowercase().as_str() { + "button" => "button", + "select" => "select", + "hybrid" => "hybrid", + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Valeurs valides: `button`, `select`, `hybrid`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let _ = set_help_type(&pool, bot_id, normalized).await; + let embed = serenity::builder::CreateEmbed::new() + .title("Mode de help mis à jour") + .description(format!("Nouveau mode: `{}`", normalized)) + .color(0x57F287); + send_embed(ctx, msg, embed).await; + } + "aliases" | "alias" => { + if args.len() < 2 { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+helpsetting aliases `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let enabled = match args[1].to_lowercase().as_str() { + "on" | "true" | "yes" => true, + "off" | "false" | "no" => false, + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Valeurs valides: `on`, `off`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let _ = set_help_aliases_enabled(&pool, bot_id, enabled).await; + let embed = serenity::builder::CreateEmbed::new() + .title("Aliases de help mis à jour") + .description(format!("Aliases: `{}`", if enabled { "on" } else { "off" })) + .color(0x57F287); + send_embed(ctx, msg, embed).await; + } + "perms" | "permissions" => { + if args.len() < 2 { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+helpsetting perms `") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let enabled = match args[1].to_lowercase().as_str() { + "on" | "true" | "yes" => true, + "off" | "false" | "no" => false, + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Valeurs valides: `on`, `off`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + }; + + let _ = set_help_perms_enabled(&pool, bot_id, enabled).await; + let embed = serenity::builder::CreateEmbed::new() + .title("Affichage des permissions mis à jour") + .description(format!( + "Permissions: `{}`", + if enabled { "on" } else { "off" } + )) + .color(0x57F287); + send_embed(ctx, msg, embed).await; + } + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Sous-commandes: `type`, `aliases`, `perms`") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + } + } +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +pub struct HelpsettingCommand; +pub static COMMAND_DESCRIPTOR: HelpsettingCommand = HelpsettingCommand; + +impl crate::commands::command_contract::CommandSpec for HelpsettingCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + key: "helpsetting", + command: "helpsetting", + category: "permissions", + params: " [value]", + summary: "Configure l'affichage du système d'aide", + description: "Permet de configurer le mode d'affichage, l'affichage des alias et l'affichage des permissions du système d'aide.", + examples: &["+helpsetting", "+helpsetting type hybrid", "+helpsetting perms off"], + alias_source_key: "helpsetting", + default_aliases: &["hs"], + } + } +} diff --git a/src/commands/helptype.rs b/src/commands/helptype.rs new file mode 100644 index 0000000..9fd7ad2 --- /dev/null +++ b/src/commands/helptype.rs @@ -0,0 +1,78 @@ +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::db::{DbPoolKey, get_help_type, set_help_type}; + +pub async fn handle_helptype(ctx: &Context, msg: &Message, args: &[&str]) { + let bot_id = ctx.cache.current_user().id; + let Some(pool) = pool(ctx).await else { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("DB indisponible.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + if args.is_empty() { + let current = get_help_type(&pool, bot_id) + .await + .ok() + .flatten() + .unwrap_or_else(|| "button".to_string()); + let embed = serenity::builder::CreateEmbed::new() + .title("Mode help") + .description(format!( + "Mode actuel: `{}`\nValeurs: `button`, `select`, `hybrid`", + current + )) + .color(0x5865F2); + send_embed(ctx, msg, embed).await; + return; + } + + let normalized = match args[0].to_lowercase().as_str() { + "button" => "button", + "select" => "select", + "hybrid" => "hybrid", + _ => { + let embed = serenity::builder::CreateEmbed::new() + .title("Erreur") + .description("Usage: `+helptype